Compare commits

..

251 Commits

Author SHA1 Message Date
Roman Zeyde
28cbb941f1 Bump version: 0.14.7 → 0.14.8 2023-08-19 15:42:03 +03:00
Roman Zeyde
0acc6cd2ef Update email in setup.py 2023-08-19 15:36:21 +03:00
Roman Zeyde
a247e877fc No need to install Cython & hidapi 2023-08-12 11:32:33 +03:00
Roman Zeyde
8cb323c550 Update docs to reference trezor-agent instead of trezor_agent (#342) 2023-08-12 11:28:35 +03:00
Roman Zeyde
d915d8594c Merge pull request #385 from Senjuu/ssh-certificates
Add Support for ED25519 ssh-certificates
2023-07-31 20:41:43 +03:00
Senjuu
473a565fc6 Add Support for ED25519 ssh-certificates 2023-07-31 13:06:05 +02:00
Roman Zeyde
2b49eacc01 Run CI also on PRs 2023-06-03 16:40:00 +03:00
Roman Zeyde
ba8a1ba8c8 Merge pull request #423 from jsmith-dev/patch-1
Update INSTALL.md
2023-05-12 09:41:08 +03:00
Julian Smith, Main Street Ventures
bf7324ca89 Update INSTALL.md
Fix install step instruction
2023-05-11 15:45:14 +10:00
Roman Zeyde
890fd0bdfd Merge pull request #421 from romanz/stable
Mark 'libagent' package as stable
2023-04-25 19:34:31 +03:00
Roman Zeyde
1518b7bde0 Mark 'libagent' package as stable 2023-04-25 17:59:42 +03:00
Roman Zeyde
23a48e41f7 Merge pull request #420 from romanz/fixup
Remove unused imports and fix a small lint issue
2023-04-25 17:47:46 +03:00
Roman Zeyde
97416308ed Remove unused imports and fix a small lint issue 2023-04-25 17:43:40 +03:00
Roman Zeyde
58bbb24fb4 Bump version: 0.14.6 → 0.14.7 2023-02-07 20:27:12 +02:00
Roman Zeyde
f28ea64294 Merge pull request #417 from cbouvet-ledger/cbo-fix-ledger-eof
ledger.py: fix EOF not always set
2023-02-07 20:02:22 +02:00
Clement Bouvet
b782b610a9 ledger.py: fix EOF not always set 2023-02-07 16:11:32 +01:00
Roman Zeyde
f4202095f6 Merge branch 'fixes' 2023-02-04 19:09:05 +02:00
Roman Zeyde
a9bcabc664 Fix a few pycodestyle & pystyle issues 2023-02-04 19:07:50 +02:00
Roman Zeyde
83b4aeebf4 Fix a few pycodestyle issues 2023-02-04 18:41:54 +02:00
Roman Zeyde
2e61c84ca6 Merge pull request #415 from cbouvet-ledger/cbo-ledger-update
ledger: handle 0.0.8 new apdu format
2023-02-04 18:04:50 +02:00
Clement Bouvet
e17ba3f42e ledger: handle 0.0.8 new apdu format 2023-02-04 16:38:26 +01:00
Roman Zeyde
cd972ed4f3 Test on Python 3.11 2023-01-27 14:50:58 +02:00
Roman Zeyde
54e7ffb6a5 Allow prehashing for Minisign support
See https://github.com/jedisct1/minisign
2023-01-27 14:47:19 +02:00
Roman Zeyde
acd52cd771 Merge branch 'docs'
https://github.com/romanz/trezor-agent/pull/411
2023-01-18 21:13:50 +02:00
Roman Zeyde
c7384d7e0e Reformat 2023-01-18 21:11:06 +02:00
Iskra Delta
7c76bb4df7 Update README-SSH.md 2023-01-17 20:40:30 +00:00
Iskra Delta
d3817a4eec Update README-SSH.md 2023-01-17 20:39:38 +00:00
Iskra Delta
debcda4ce8 Update README-SSH.md
document the ssh signatures examples
2023-01-17 19:50:23 +00:00
Roman Zeyde
22c309cd03 Merge pull request #409 from iskradelta/patch-1
Update README-age.md
2023-01-16 20:40:04 +02:00
Iskra Delta
c300610824 Update README-age.md 2023-01-16 15:38:56 +00:00
Iskra Delta
bfccf879ce Update README-age.md
typo in examples
2023-01-16 15:37:48 +00:00
Roman Zeyde
3044cfe932 age: add more documentation and examples 2022-12-26 21:48:09 +02:00
Roman Zeyde
0bbb52f24e age: fix verbosity setting 2022-12-26 19:53:32 +02:00
Roman Zeyde
7cf3c520d3 Fix pytest invocation in tox.ini 2022-12-16 20:52:45 +02:00
Roman Zeyde
91e7970d53 Fix pycodestyle issue 2022-12-16 20:46:48 +02:00
Roman Zeyde
8322cf455e Chunkify 'base64' format for age plugin 2022-10-28 11:42:53 +03:00
Roman Zeyde
3fbb2c624e Document SSH-based Git signatures 2022-10-21 20:42:22 +03:00
Roman Zeyde
1cfdddc33a Bump version: 0.14.5 → 0.14.6 2022-10-21 12:02:20 +03:00
Roman Zeyde
523dcb139a Merge pull request #399 from JamieDriver/update_jade_api
Update Jade api version
2022-09-21 21:15:07 +03:00
Jamie C. Driver
751ef7321b Update Jade api version 2022-09-15 11:07:17 +01:00
Roman Zeyde
38a3131a07 Merge pull request #396 from JamieDriver/jade_agent_install
Update setup.py for jade-agent
2022-09-01 20:33:19 +03:00
Jamie C. Driver
7c01789529 Update setup.py for jade-agent 2022-08-17 15:55:08 +01:00
Roman Zeyde
1763b0ea0c Drop Python 3.6 in CI 2022-07-10 20:06:06 +03:00
Roman Zeyde
8c5a9bfe02 Fixup pylint issues 2022-07-10 19:53:35 +03:00
Roman Zeyde
807c25a9fc Bump 'trezor_agent' version: 0.11.0 → 0.12.0 2022-07-09 19:20:46 +03:00
Roman Zeyde
b177da9ee8 Bump version: 0.14.4 → 0.14.5 2022-07-09 19:09:04 +03:00
Roman Zeyde
4242599114 Merge pull request #395 from afreakk/master
fix: provide buf argument to unsupported_extension function
2022-05-23 08:58:04 +03:00
afreakk
54e670c7ee fix: provide buf argument to unsupported_extension function 2022-05-22 14:09:40 +02:00
Roman Zeyde
5832d4a67b Merge pull request #394 from romanz/latest-ssh
Don't fail if not all request is parsed
2022-05-21 13:17:26 +03:00
Roman Zeyde
8d4536b37a Fixup linting issues 2022-05-21 12:09:00 +03:00
Roman Zeyde
b1b3e4b7ea Don't fail if not all request is parsed
https://www.openssh.com/agent-restrict.html

266678e19e
2022-05-21 12:07:07 +03:00
Roman Zeyde
80bfda7899 Merge pull request #386 from seandlg/patch-1
Fix a typo
2022-03-27 22:53:53 +03:00
Roman Zeyde
1166917461 Merge pull request #384 from yanchenko-igor/master
Handle usupported extensions, fixes #383 fixes #379
2022-03-27 22:53:25 +03:00
Sean Eulenberg
d7f6ceb429 Fix a typo
Fix a typo
2022-03-24 09:28:47 +01:00
Igor Yanchenko
a8f2d74d02 Handle usupported extesions, fixes #383 2022-03-14 17:18:39 +02:00
Roman Zeyde
df84c4c15f Small style fixes following #382
Tested with `tox`.
2022-03-11 09:24:16 +02:00
Roman Zeyde
0662ced2f4 Merge pull request #382 from JamieDriver/blockstream_jade_support
Add support for the Blockstream Jade hww
2022-03-10 23:37:28 +02:00
Jamie C. Driver
471d0e03e7 Add support for the Blockstream Jade hww
Supports ssh and gpg, incl. ecdh/decryption.
Initially only supports curve 'nist256p1'.
2022-03-10 17:24:11 +00:00
Roman Zeyde
e4d16a361a Merge branch 'master' of https://github.com/yanchenko-igor/trezor-agent 2022-01-21 21:23:09 +02:00
Igor Yanchenko
c6f30083ff Update setup.py
Added age support
2022-01-21 20:55:26 +02:00
Senjuu
23f8ef09a5 Add Support for NIST256 ssh-certificates
Adopt suggested naming scheme

Adding new unit tests
2021-12-21 19:38:44 +02:00
Roman Zeyde
f0769655ad Add age plugin support
See https://github.com/str4d/rage/tree/main/age-plugin.

Example usage:

	RAGE_DIR=$PWD/../Rust/rage
	(cd $RAGE_DIR; cargo build --all)
	export PATH=$PATH:$RAGE_DIR/target/debug

	age-plugin-trezor -i "John Doe" | tee trezor.id
	R=$(grep recipient trezor.id | cut -f 3 -d ' ')

	date | tee msg.txt
	rage -er $R < msg.txt > enc.txt
	rage -di trezor.id < enc.txt
2021-12-14 20:43:04 +02:00
Roman Zeyde
2a6a47f400 Support SSH signatures
https://www.agwa.name/blog/post/ssh_signatures

See here for more details:
https://github.com/openssh/openssh-portable/blob/master/PROTOCOL.sshsig
https://github.com/openssh/openssh-portable/blob/master/sshsig.c
2a9c9f7272
2021-12-13 10:58:15 +02:00
Roman Zeyde
47c827519e Bump dependency to latest trezorlib 2021-12-09 20:23:00 +02:00
Roman Zeyde
a9117c965c Bump version: 0.14.3 → 0.14.4 2021-11-06 14:41:42 +02:00
Roman Zeyde
8107e6378c Don't use sys.argv for device name parsing 2021-11-05 10:03:22 +02:00
Roman Zeyde
85d2da5460 Bump version: 0.14.2 → 0.14.3 2021-11-02 09:28:03 +02:00
Roman Zeyde
5e5a96b96f Merge branch 'fixes' 2021-11-02 09:23:55 +02:00
Roman Zeyde
69c5c57489 Support "fast-path" key listing
https://dev.gnupg.org/rG40da61b89b62dcb77847dc79eb159e885f52f817#change-o4DEJvEV1Dx2

Also, refactor decoding and add a few tests.
2021-11-02 09:22:19 +02:00
Roman Zeyde
b9db213912 Use Popen.communicate to get stdout from subprocess 2021-11-01 14:07:02 +02:00
Roman Zeyde
6c2b880b7d Support daemonization of GPG agent 2021-11-01 14:07:02 +02:00
Roman Zeyde
37510a2d75 Fix FakeDevice close() and pubkey() 2021-10-25 21:15:20 +03:00
Roman Zeyde
b6de68e95c Run CI also on Python 3.10 2021-10-25 08:37:51 +03:00
Roman Zeyde
ee4b1fcdb6 Multiple style fixes 2021-10-22 19:47:15 +03:00
Roman Zeyde
6d55512619 Merge pull request #361 from melpomene/patch-1
Udev rule configuration link was dead
2021-09-20 10:20:59 +03:00
Christopher Käck
b902f43ba1 Udev rule configuration link was dead
and redirecting to the root page for documentation.
2021-09-19 22:19:32 -07:00
Roman Zeyde
338a075ed5 Allow looking TREZOR by path prefix 2021-06-21 21:32:43 +03:00
Roman Zeyde
bcea720e95 Test on Python 3.{6,7,8,9} 2021-05-22 22:13:58 +03:00
Roman Zeyde
1c6d2cb65a Update README badge 2021-05-22 22:08:23 +03:00
Roman Zeyde
53fe6cd5ad Merge branch 'github-ci' 2021-05-22 22:04:00 +03:00
Roman Zeyde
a0e7aae1d2 Enable isort check in tox 2021-05-22 21:54:20 +03:00
Roman Zeyde
7f4269ab88 Add GitHub CI
Fixup a few pylint comments
2021-05-22 21:51:45 +03:00
Roman Zeyde
36e7afde17 Remove Travis CI 2021-05-22 14:46:21 +03:00
Roman Zeyde
020572ef5f Support Signify-based signatures
http://www.openbsd.org/papers/bsdcan-signify.html
2020-12-29 09:14:06 +02:00
Roman Zeyde
dbae284487 Short-circuit calling tty if stdin is redirected 2020-12-25 16:30:22 +02:00
Roman Zeyde
f5b99c0794 Bump version: 0.14.1 → 0.14.2 2020-12-16 20:29:17 +02:00
Roman Zeyde
f66da28cc3 Unbump setup.py 2020-12-16 20:28:52 +02:00
Roman Zeyde
c3853e97c7 Merge branch 'feature/fix-continous-integration' of https://github.com/galuszkak/trezor-agent into fix-ci 2020-10-22 18:41:56 +03:00
Kamil Gałuszka
32eff19bb6 fix: linter fixes and added python 3.9 to tests 2020-10-15 01:05:40 +02:00
onlykey
fd182e744f Add OnlyKey support 2020-09-24 22:29:21 +03:00
Roman Zeyde
a12202d809 Move decompression into device.pubkey() 2020-09-24 14:41:50 +03:00
Roman Zeyde
d0e7fa7cca Require older version of isort for pylint 2020-08-05 08:52:37 +03:00
onlykey
e1bbdb4bcc Replace 'ed25519' by 'pynacl' 2020-08-05 08:51:40 +03:00
Roman Zeyde
4d9d6c0741 Fix a typo in systemd unit example 2020-07-04 11:12:19 +03:00
Roman Zeyde
4c3c5a7c53 Merge pull request #330 from Karunamon/patch-1
Quote PATH when writing agent invocation script
2020-05-17 15:28:16 +03:00
Michael Parks
362ddcc707 Quote PATH when writing agent invocation script
If the PATH contains spaces, the agent invocation script will fail parsing. This quotes the variable so that spaces don't break the script.
2020-05-17 00:41:56 -06:00
Roman Zeyde
88ff57187f Bump version: 0.14.0 → 0.14.1 2020-05-02 17:43:44 +03:00
Roman Zeyde
52d840cbbb Initialize passphrase cache at UI c-tor 2020-04-29 22:01:06 +03:00
Roman Zeyde
8c22e5030b Bump 'trezor_agent' version: 0.10.0 → 0.11.0 2020-04-17 14:42:15 +03:00
Roman Zeyde
18c80b4cca Bump version: 0.13.1 → 0.14.0 2020-04-17 14:31:41 +03:00
Roman Zeyde
7eab4933ed Add more Python version to Travis 2020-04-17 14:30:36 +03:00
Roman Zeyde
d103ebee6f Fix pylint warning 2020-04-17 14:28:50 +03:00
matejcik
d8bcca3ccb support trezorlib 0.12 2020-04-09 14:41:56 +02:00
Roman Zeyde
67ef11419a Merge pull request #320 from eli-b/patch-5
docs: Install libagent from source too
2020-04-06 23:30:46 +03:00
Eli Boyarski
d4d168c746 docs: Install libagent from source too
Installing the trezor/ledger agent from source installs the libagent module from PyPI unless libagent is already installed from source beforehand.
2020-04-06 20:44:25 +03:00
Roman Zeyde
61cfcef35c Merge branch 'NTICompass/keepkey-webusb' 2020-03-16 23:21:01 +02:00
Eric Siegel
0f627e8322 Clean up code... 2020-03-16 15:26:15 -04:00
Eric Siegel (Rocket Hazmat)
7bdfa7609d Upgrade KeepKey for new libagent code
Add get_public_node for KeepKey
2020-03-13 13:50:09 -04:00
Eric Siegel (Rocket Hazmat)
53b08f4968 Fix detecting KeepKey USB device
The new KeepKey firmware uses WebUSB instead of HID
2020-03-13 13:05:08 -04:00
Roman Zeyde
15b0218bf2 Default GPG key creation time to 0 (i.e. Jan 1 1970) 2019-10-29 09:14:26 +02:00
Roman Zeyde
f52e959639 Merge branch 'patch-2' of https://github.com/zack-shoylev/trezor-agent 2019-10-29 09:12:18 +02:00
Roman Zeyde
d98f49445e Merge branch 'patch-1' of https://github.com/korzq/trezor-agent 2019-10-26 13:52:02 +03:00
Roman Zeyde
ab6892f42f Fix pylint warnings 2019-10-26 13:47:29 +03:00
Eric Zhu
f03312d61f Update README-SSH.md 2019-10-02 17:41:51 -04:00
Roman Zeyde
b75cf74976 Merge pull request #301 from hkjn/20190925-describe-versioning
Add components section
2019-09-25 18:44:47 +03:00
Henrik Jonsson
363b4d633f Add components section 2019-09-25 12:19:33 +02:00
Zack Shoylev
b7d0ef0f94 Update README-SSH.md
Fix typo
2019-08-27 15:33:02 -05:00
Zack Shoylev
8c3744c30c Update README-SSH.md
Small systemd doc improvements.
2019-06-25 13:24:32 -05:00
Roman Zeyde
513b1259c4 Bump version: 0.13.0 → 0.13.1 2019-03-10 18:41:14 +02:00
Roman Zeyde
5984a58f65 Update .bumpversion.cfg 2019-03-10 18:41:07 +02:00
Roman Zeyde
e437591dd5 Fix prompt for symmetric encryption passphrase 2019-03-03 22:51:15 +02:00
André Vitor de Lima Matos
94ad9648f8 Fix passphrase cache
Broken since 2cb64991c3
Fix #284
2019-02-23 17:42:08 -03:00
Roman Zeyde
ed64f94bd3 Merge pull request #281 from eli-b/patch-4
Fix header numbering
2018-12-20 07:46:33 +02:00
Eli Boyarski
bf9f2593b5 Fix header numbering 2018-12-19 13:06:24 +02:00
Roman Zeyde
995fba3e93 Drop compatibility with <0.11 trezorlib 2018-12-13 00:05:47 +02:00
Roman Zeyde
34b269be1e Bump library and TREZOR-related agent versions 2018-12-12 23:59:15 +02:00
matejcik
5cfdc7734b fix style complaints 2018-12-10 16:30:56 +01:00
matejcik
2cb64991c3 Trezor: restructure code to support python-trezor 0.11 2018-12-10 16:10:55 +01:00
matejcik
a30cab1156 Trezor: bump version requirement to 0.10.1
because 0.9 doesn't work anyway due to the hidapi extra,
and there's no point of supporting 0.10.0 that doesn't have state
handling
2018-12-10 16:10:55 +01:00
matejcik
b30e6a8408 Allow devices to override connection closing 2018-12-10 16:10:55 +01:00
Roman Zeyde
8041ed883f Ignore cyclic imports pylint warning 2018-11-30 11:29:40 +02:00
walkjivefly
a71fa8de9e Add missing pre-reqs
Attempting to install 0.10.2 from PyPI failed because docutils and wheel were not installed.
2018-11-30 12:31:54 +07:00
Roman Zeyde
ddd823d976 Bump version: 0.12.0 → 0.12.1 2018-11-17 23:48:23 +02:00
Roman Zeyde
fec84288be gpg: --homedir should come before --list-secret-keys 2018-10-27 18:15:29 +03:00
Roman Zeyde
71f357c1bf Add 'hidapi' dependency 2018-08-18 12:55:46 +03:00
Eli Boyarski
8f1d008eb2 fixed typo + missing word 2018-08-06 23:19:32 +03:00
Roman Zeyde
7a351acf15 Merge remote-tracking branch 'matejcik/master' 2018-08-02 22:01:31 +03:00
Roman Zeyde
7f9aa2b147 Bump version: 0.11.3 → 0.12.0 2018-07-25 13:47:48 +03:00
Roman Zeyde
eed168341c Don't inheric from 'object' (after deprecating Python 2.x support) 2018-07-25 13:44:55 +03:00
matejcik
8b85090fba trezor: usage for TREZOR_PATH variable
This is not a great place, as the variable will work anywhere,
but I couldn't find a better place to put it.

Also fixes a typo in the service definition.
2018-07-17 16:50:53 +02:00
matejcik
8708b1e16d trezor: use TREZOR_PATH environment variable to specify device path 2018-07-17 16:45:09 +02:00
Roman Zeyde
03e7fc48e9 Improve Git-related documentation 2018-07-12 12:10:48 +03:00
Roman Zeyde
4968ca7ff3 Merge branch 'master' into neopg-wip 2018-07-01 13:52:37 +03:00
Roman Zeyde
6b6d9f5d20 Add a link to neopg-trezor wrapper at documentation 2018-07-01 13:17:13 +03:00
Roman Zeyde
c22109df24 Document argv[0] hack for NeoPG 2018-07-01 13:15:04 +03:00
Roman Zeyde
47ce035e79 Remove unused import 2018-07-01 12:52:08 +03:00
Roman Zeyde
36cbba6c57 Fix a few lint issues 2018-07-01 12:49:39 +03:00
Roman Zeyde
6afe20350b Simplify GPG command generation 2018-07-01 12:12:16 +03:00
Roman Zeyde
fa171e8923 Add short example for NeoPG usage 2018-07-01 12:08:46 +03:00
Roman Zeyde
f0bda9a3e6 Allow using $PATH when looking for GPG binary
It's needed for running neopg (instead of gnupg).
2018-07-01 12:05:25 +03:00
Roman Zeyde
71b56e15d7 Add NeoPG commandline wrapper for TREZOR-based agent
It invokes `trezor-gpg-agent` instead of `neopg agent`, by putting
its own path at argv[0].
2018-07-01 12:04:32 +03:00
Roman Zeyde
3b9c00e02a Default to $GNUPGHOME when not specified on commandline 2018-07-01 11:46:16 +03:00
Roman Zeyde
dcee59a19e Assume NeoPG binary runs GnuPG functionality 2018-07-01 11:32:02 +03:00
Roman Zeyde
a274de30b8 Parse NeoPG development versions
e.g. v0.0.5-37-g1fe5046-dirty
2018-06-30 13:05:21 +03:00
Roman Zeyde
4fe9e437ad Simplify GPG homedir setting 2018-06-30 13:03:30 +03:00
Roman Zeyde
d04527a8ed Replace GPG version assertion by an error log
since NeoPG uses different versioning
2018-06-30 13:02:50 +03:00
Roman Zeyde
3329c29cb4 Use gpg_command() for identity generation 2018-06-30 12:50:55 +03:00
Roman Zeyde
df2cb52f8d fixup! Reply with an ERR to SCD SERIALNO openpgp ASSUAN command 2018-06-30 12:49:59 +03:00
Roman Zeyde
f36ef4ffe0 Allow running NeoPG binary (instead of GnuPG) 2018-06-30 12:44:17 +03:00
Roman Zeyde
f74de828fc Reply with an ERR to SCD SERIALNO openpgp ASSUAN command
(for NeoPG)
2018-06-30 12:10:37 +03:00
Roman Zeyde
912b1cde7a Add support for file-descriptor-based socket server
(for NeoPG)
2018-06-30 12:10:03 +03:00
Roman Zeyde
b7a8c42893 Merge pull request #153 from romanz/drop-py2
setup: deprecate Python2 support
2018-06-30 11:24:52 +03:00
Roman Zeyde
1e6c4e6930 Add links to SSH/GPG usage examples 2018-06-30 11:21:47 +03:00
Roman Zeyde
a8f19e4150 Comment about SSH argument separation 2018-06-30 11:12:43 +03:00
Roman Zeyde
6a9fdf75e2 Bump version: 0.11.2 → 0.11.3 2018-06-19 21:15:14 +03:00
Roman Zeyde
6bc5b6af5e Add small example for IdentityOnly use-case 2018-06-19 19:04:05 +03:00
Roman Zeyde
8672a6901a Document IdentitiesOnly support 2018-06-19 18:49:36 +03:00
Roman Zeyde
672af98ad7 Explicitly use IdentityFile option when connecting to specific host 2018-06-19 18:38:57 +03:00
Roman Zeyde
ed531cfff8 Remove trailing whitespace
git ls-files | xargs -n1 sed -e's/[[:space:]]*$//' -i
2018-05-25 08:43:22 +03:00
Bram
bd1ae0f091 Update INSTALL.md
I've sorted out the Formula for Homebrew and it's been merged.
2018-05-24 14:01:40 +03:00
Roman Zeyde
0c762e8998 Use pinentry homebrew formula on macOS 2018-05-23 08:35:34 +03:00
Roman Zeyde
bd0df4f801 trezor: update setup.py for latest libagent and trezorlib 2018-05-05 21:05:02 +03:00
Roman Zeyde
3d1639d271 gpg: require symmetric passphrase re-entry 2018-04-25 11:18:13 +03:00
Roman Zeyde
bea899d1ef gpg: allow symmetric encryption with a passphrase 2018-04-25 11:09:58 +03:00
Roman Zeyde
ccc2174775 gpg: allow more verbose output during GnuPG pubkey import 2018-04-25 00:16:27 +03:00
Roman Zeyde
afa3fdb89c gpg: allow setting passphrase cache expriration duration 2018-04-25 00:12:34 +03:00
Roman Zeyde
2ca3941cfa ssh: allow setting passphrase cache expriration duration 2018-04-25 00:02:21 +03:00
Roman Zeyde
b1bd6cb690 gpg: refactor GETINFO handling into a separate method 2018-04-23 22:59:11 +03:00
Roman Zeyde
766536d2c4 trezor: allow expiring cached passphrase 2018-04-23 22:55:10 +03:00
Roman Zeyde
91f70e7a96 Merge pull request #238 from pruflyos/patch-1
Update INSTALL.md
2018-04-22 09:37:09 +03:00
Roman Zeyde
cf5bfd960a Merge pull request #237 from menteb/patch-2
Update to Install.md reflecting Homebrew formula
2018-04-22 09:36:45 +03:00
pruflyos
4bd769f138 Update INSTALL.md
On Fedora `python3-tk` is called `python3-tkinter`
2018-04-21 16:15:44 -04:00
Bram
91b850f184 Update to Install.md reflecting Homebrew formula 2018-04-21 13:20:22 +03:00
Roman Zeyde
c6bb090dfc Merge pull request #235 from timthelion/git-email-readme
Document the configuration of the git email setting and errors
2018-04-18 22:32:17 +03:00
Timothy Hobbs
fef4fd06c9 Document the configuration of the git email setting and errors
Signed-off-by: Timothy Hobbs <timothyhobbs@seznam.cz>
2018-04-18 12:41:38 +02:00
Roman Zeyde
bc691ae795 gpg: fix method's caching 2018-04-16 12:38:28 +03:00
Roman Zeyde
61e516e200 Add link to Ledger Nano S guide 2018-04-09 22:10:38 +03:00
Roman Zeyde
543ff7021d doc: explain how to reset cached passphrase 2018-04-08 16:33:34 +03:00
Roman Zeyde
2e0cfc8088 gpg: fail if new identity is missing 2018-04-08 16:20:55 +03:00
Roman Zeyde
18f33f8a08 README: document PIN entry depedencies 2018-04-08 10:16:58 +03:00
Roman Zeyde
2973413995 Merge pull request #227 from kvbik/patch-1
mention brew install libusb on macOS
2018-03-29 20:52:11 +03:00
Jakub Vysoký
2360693dc5 mention brew install libusb on macOS 2018-03-29 18:32:26 +02:00
Roman Zeyde
7443fc6512 Pass 'state' during TREZOR initialization 2018-03-27 16:06:18 +03:00
Roman Zeyde
5efb752979 doc: update Fedora installation instructions 2018-03-22 14:31:36 +02:00
Roman Zeyde
4546cd674b Bump version: 0.11.1 → 0.11.2 2018-03-19 09:45:56 +02:00
Roman Zeyde
5dba12f144 gpg: don't clear options on RESET assuan command 2018-03-14 13:55:59 +02:00
Roman Zeyde
887561de9f pylint: skip 'fixme' warnings 2018-03-14 12:17:07 +02:00
Roman Zeyde
6d730e0a5b ui: subprocess.Popen doesn't have 'args' attribute in Python 2 2018-03-14 12:15:08 +02:00
Roman Zeyde
d0732d16e8 ui: don't log passphrases (since the log may be persisted) 2018-03-14 12:13:44 +02:00
Roman Zeyde
dafb80ad7a trezor: don't retry on PIN/passphrase entry cancellation 2018-03-13 16:52:04 +02:00
Roman Zeyde
df6249b071 Merge remote-tracking branch 'rendaw/pinentry-docs' 2018-03-13 15:35:00 +02:00
rendaw
942f01418b Also set DISPLAY in SSH unit 2018-03-13 16:31:52 +09:00
rendaw
93b548b737 Add docs to show using the gpg agent with systemd; set PATH for ssh unit 2018-03-13 16:28:36 +09:00
rendaw
329f07249a Small reword 2018-03-13 05:57:39 +09:00
rendaw
a1f7088d33 Remove pin entry instructions from INSTALL, didn't seem that relevant 2018-03-13 05:47:31 +09:00
rendaw
25f066e113 Document --pin-entry-binary with usage guide 2018-03-13 05:43:18 +09:00
Roman Zeyde
0699273d49 util: move ASSUAN serialization to break circular import 2018-03-11 15:11:02 +02:00
Roman Zeyde
92c352e860 Bump version: 0.11.0 → 0.11.1 2018-03-11 14:40:04 +02:00
Roman Zeyde
34c03a462c ui: merge into a single module 2018-03-11 14:33:54 +02:00
Roman Zeyde
51dbecd4c2 Bump version: 0.10.0 → 0.11.0 2018-03-11 13:35:23 +02:00
Roman Zeyde
ceae65aa5a ui: use {} as default config 2018-03-11 13:34:50 +02:00
Roman Zeyde
d0497b0137 pinentry: specify device name at PIN/passphrase entry UI 2018-03-10 22:11:26 +02:00
Roman Zeyde
870152a7af gpg: allow specifying custom homedir during init 2018-03-10 18:39:56 +02:00
Roman Zeyde
cbdc52c0a4 trezor: handle passphrase on-device entry (for Model T) 2018-03-10 09:35:23 +02:00
Roman Zeyde
0c9fc33757 gpg: replace gpg-agent.conf by run-agent.sh 2018-03-08 17:47:16 +02:00
Roman Zeyde
17ea941add gpg: use pinentry UI for initialization and agent 2018-03-08 17:47:14 +02:00
Roman Zeyde
64064b5ecc ssh: use pinentry UI 2018-03-08 17:13:19 +02:00
Roman Zeyde
601a2b1336 device: refactor PIN/passphrase UI into a separate class
This would allow easier customization.
2018-03-08 17:13:16 +02:00
Roman Zeyde
2e688ccac9 setup: deprecate Python2 support 2018-03-08 09:18:37 +02:00
Roman Zeyde
b6181bb5b5 trezor: replace tk-based pinentry with GnuPG pinentry 2018-03-07 13:42:59 +02:00
Roman Zeyde
b6da299cb0 pinentry: add simple wrapper for PIN/passphrase entry 2018-03-07 13:42:56 +02:00
Roman Zeyde
04627f0899 gpg: collect OPTIONs from agent 2018-03-06 11:02:39 +02:00
Roman Zeyde
54ce6f2cec trezor: limit passphrase length 2018-03-06 10:06:00 +02:00
Roman Zeyde
a1047ba7b1 Bump version: 0.9.8 → 0.10.0 2018-03-03 21:02:10 +02:00
Roman Zeyde
e90bd0cd81 trezor: refactor transport enumeration a bit 2018-03-03 20:22:22 +02:00
slush
66e3e60370 trezor: Use composite transport for device detection. 2018-03-03 01:25:19 +01:00
slush
3f1604d609 Use Python3 by default 2018-03-03 01:24:08 +01:00
slush
d0f4cccfd2 trezor: Both Trezor One and Model T are supported. 2018-03-03 01:23:35 +01:00
Roman Zeyde
08d81c992c trezor: split pinentry tool into a separate file 2018-02-27 11:17:53 +02:00
Roman Zeyde
55a899f929 trezor: initialize cached_passphrase_ack with None (instead of 0) 2018-02-27 10:33:19 +02:00
Roman Zeyde
e7604dff68 ssh: fix small commandline documentation nits 2018-02-27 09:53:37 +02:00
rendaw
8849545700 Clarify a couple sentences 2018-02-27 03:00:16 +09:00
rendaw
d109cd73b5 Adjust ssh systemd instructions; cleanup 2018-02-27 02:48:23 +09:00
rendaw
95e98d6eda Merge remote-tracking branch 'upstream/master' into doc-enhancements2 2018-02-27 02:30:00 +09:00
rendaw
9e78d52721 SSH clarification 2018-02-27 02:29:54 +09:00
Roman Zeyde
2a76ef6819 gpg: notice encryption of gpg-agent logs (for privacy) 2018-02-24 23:49:35 +02:00
Roman Zeyde
654a3c465a Merge remote-tracking branch 'rendaw/systemd-instructions' 2018-02-24 21:35:30 +02:00
Roman Zeyde
2168115b06 ssh: fixup small refactoring bug 2018-02-24 21:23:47 +02:00
Roman Zeyde
4a9140c42d Merge branch 'serge' 2018-02-24 21:07:42 +02:00
Roman Zeyde
b20d98bf57 gpg: move socket path generation into a helper function 2018-02-24 21:03:30 +02:00
Roman Zeyde
199fb299c3 gpg: use 'sys.exit' and log homedir 2018-02-24 20:46:04 +02:00
rendaw
06e169f141 Add instructions for using SSH agent as systemd unit 2018-02-24 03:08:46 +09:00
rendaw
131111bc0e Add additional information to docs; collect and reorganize sections 2018-02-23 20:55:46 +09:00
Roman Zeyde
f4208009e0 trezor: init_device before failing PIN/passphrase entry 2018-02-22 11:19:26 +02:00
Roman Zeyde
73d60dbec0 gpg: cache the passphrase for better UX 2018-02-21 11:35:38 +02:00
Roman Zeyde
34ea224290 gpg: the scripts should be only user-readable 2018-02-20 21:21:45 +02:00
Roman Zeyde
7803026f61 gpg: allow setting passphrase from environment variable
as done by TREZOR's client library
2018-02-20 09:59:15 +02:00
Roman Zeyde
34ce1005fd build: add simple script for PyPI release 2018-02-19 15:12:01 +02:00
Roman Zeyde
8677c8ebaa trezor_agent: fix broken PyPI package 2018-02-19 13:15:49 +02:00
Serge Pokhodyaev
6363eb0d4a add -f/--foreground option to run as systemd service 2018-02-18 21:08:36 +03:00
Serge Pokhodyaev
a32bfc749b don't overwrite homedir 2018-02-18 20:24:22 +03:00
63 changed files with 2858 additions and 546 deletions

View File

@@ -1,7 +1,6 @@
[bumpversion]
commit = True
tag = True
current_version = 0.9.8
current_version = 0.14.8
[bumpversion:file:setup.py]

24
.github/workflows/ci.yml vendored Normal file
View File

@@ -0,0 +1,24 @@
name: Build
on: [push, pull_request]
jobs:
build:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ['3.7', '3.8', '3.9', '3.10', '3.11']
steps:
- uses: actions/checkout@v2
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v2
with:
python-version: ${{ matrix.python-version }}
- name: Install dependencies
run: |
python -m pip install --upgrade pip tox
- name: Build and test
run: |
tox

View File

@@ -1,5 +1,4 @@
[MESSAGES CONTROL]
disable=invalid-name, missing-docstring, locally-disabled, unbalanced-tuple-unpacking,no-else-return
disable=invalid-name, missing-docstring, locally-disabled, unbalanced-tuple-unpacking,no-else-return,fixme,duplicate-code,cyclic-import,import-outside-toplevel,consider-using-with,consider-using-f-string,unspecified-encoding
[SIMILARITIES]
min-similarity-lines=5

View File

@@ -1,29 +0,0 @@
sudo: false
language: python
python:
- "2.7"
- "3.4"
- "3.5"
- "3.6"
cache:
directories:
- $HOME/.cache/pip
before_install:
- pip install -U pip wheel
- pip install -U setuptools
- pip install -U pylint coverage pycodestyle pydocstyle
install:
- pip install -U -e .
script:
- 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,25 +1,44 @@
# Hardware-based SSH/GPG agent
# Hardware-based SSH/GPG/age agent
[![Build Status](https://travis-ci.org/romanz/trezor-agent.svg?branch=master)](https://travis-ci.org/romanz/trezor-agent)
[![Build](https://github.com/romanz/trezor-agent/actions/workflows/ci.yml/badge.svg)](https://github.com/romanz/trezor-agent/actions)
[![Chat](https://badges.gitter.im/romanz/trezor-agent.svg)](https://gitter.im/romanz/trezor-agent)
See SatoshiLabs' blog posts about this feature:
This project allows you to use various hardware security devices to operate GPG, SSH and age. Instead of keeping your key on your computer and decrypting it with a passphrase when you want to use it, the key is generated and stored on the device and never reaches your computer. Read more about the design [here](doc/DESIGN.md).
You can do things like sign your emails, git commits, and software packages, manage your passwords (with [pass](https://www.passwordstore.org/) and [passage](https://github.com/FiloSottile/passage), among others), authenticate web tunnels and file transfers, and more.
See the following blog posts about this tool:
- [TREZOR Firmware 1.3.4 enables SSH login](https://medium.com/@satoshilabs/trezor-firmware-1-3-4-enables-ssh-login-86a622d7e609)
- [TREZOR Firmware 1.3.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/)
- [A Step by Step Guide to Securing your SSH Keys with the Ledger Nano S](https://thoughts.t37.net/a-step-by-step-guide-to-securing-your-ssh-keys-with-the-ledger-nano-s-92e58c64a005)
## Installation
Currently [TREZOR One](https://trezor.io/), [TREZOR Model T](https://trezor.io/), [Keepkey](https://www.keepkey.com/), [Ledger Nano S](https://www.ledgerwallet.com/products/ledger-nano-s), and [OnlyKey](https://onlykey.io) are supported.
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.
## Components
## Usage
This repository contains source code for one library as well as
agents to interact with several different hardware devices:
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).
* [`libagent`](https://pypi.org/project/libagent/): shared library
* [`trezor-agent`](https://pypi.org/project/trezor-agent/): Using Trezor as hardware-based SSH/PGP/age agent
* [`ledger_agent`](https://pypi.org/project/ledger_agent/): Using Ledger as hardware-based SSH/PGP agent
* [`jade_agent`](https://pypi.org/project/jade_agent/): Using Blockstream Jade as hardware-based SSH/PGP agent
* [`keepkey_agent`](https://pypi.org/project/keepkey_agent/): Using KeepKey as hardware-based SSH/PGP agent
* [`onlykey-agent`](https://pypi.org/project/onlykey-agent/): Using OnlyKey as hardware-based SSH/PGP agent
For GPG, see the [following instructions](doc/README-GPG.md).
See [here](https://github.com/romanz/python-trezor#pin-entering) for PIN entering instructions.
The [/releases](/releases) page on Github contains the `libagent`
releases.
## Documentation
* **Installation** instructions are [here](doc/INSTALL.md)
* **SSH** instructions and common use cases are [here](doc/README-SSH.md)
Note: If you're using Windows, see [trezor-ssh-agent](https://github.com/martin-lizner/trezor-ssh-agent) by Martin Lízner.
* **GPG** instructions and common use cases are [here](doc/README-GPG.md)
* **age** instructions and common use cases are [here](doc/README-age.md)
* Instructions to configure a Trezor-style **PIN entry** program are [here](doc/README-PINENTRY.md)

View File

@@ -7,9 +7,9 @@ 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!!!',
description='Testing SSH/GPG agent with a fake device - NOT SAFE!!!',
author='Roman Zeyde',
author_email='roman.zeyde@gmail.com',
author_email='dev@romanzey.de',
url='http://github.com/romanz/trezor-agent',
scripts=['fake_device_agent.py'],
install_requires=[

View File

@@ -0,0 +1,7 @@
import libagent.gpg
import libagent.ssh
from libagent.device.jade import BlockstreamJade as DeviceType
ssh_agent = lambda: libagent.ssh.main(DeviceType)
gpg_tool = lambda: libagent.gpg.main(DeviceType)
gpg_agent = lambda: libagent.gpg.run_agent(DeviceType)

41
agents/jade/setup.py Normal file
View File

@@ -0,0 +1,41 @@
#!/usr/bin/env python
from setuptools import setup
setup(
name='jade_agent',
version='0.1.0',
description='Using Blockstream Jade as hardware SSH agent',
author='Jamie C. Driver',
author_email='jamie@blockstream.com',
url='http://github.com/romanz/trezor-agent',
scripts=['jade_agent.py'],
install_requires=[
'libagent>=0.14.5',
# Jade py api from github source, v0.1.37
'jadepy[requests] @ git+https://github.com/Blockstream/Jade.git@0.1.37'
],
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': [
'jade-agent = jade_agent:ssh_agent',
'jade-gpg = jade_agent:gpg_tool',
'jade-gpg-agent = jade_agent:gpg_agent',
]},
)

View File

@@ -6,7 +6,7 @@ setup(
version='0.9.0',
description='Using KeepKey as hardware SSH/GPG agent',
author='Roman Zeyde',
author_email='roman.zeyde@gmail.com',
author_email='dev@romanzey.de',
url='http://github.com/romanz/trezor-agent',
scripts=['keepkey_agent.py'],
install_requires=[

View File

@@ -6,7 +6,7 @@ setup(
version='0.9.0',
description='Using Ledger as hardware SSH/GPG agent',
author='Roman Zeyde',
author_email='roman.zeyde@gmail.com',
author_email='dev@romanzey.de',
url='http://github.com/romanz/trezor-agent',
scripts=['ledger_agent.py'],
install_requires=[

View File

@@ -0,0 +1,7 @@
import libagent.gpg
import libagent.ssh
from libagent.device.onlykey import OnlyKey as DeviceType
ssh_agent = lambda: libagent.ssh.main(DeviceType)
gpg_tool = lambda: libagent.gpg.main(DeviceType)
gpg_agent = lambda: libagent.gpg.run_agent(DeviceType)

40
agents/onlykey/setup.py Normal file
View File

@@ -0,0 +1,40 @@
#!/usr/bin/env python
from setuptools import setup
setup(
name='onlykey-agent',
version='1.2.0',
description='Using onlykey as hardware SSH/GPG agent',
author='CryptoTrust',
author_email='t@crp.to',
url='http://github.com/trustcrypto/onlykey-agent',
scripts=['onlykey_agent.py'],
install_requires=[
'libagent>=0.14.2',
'onlykey>=1.2.0'
],
platforms=['POSIX'],
classifiers=[
'Environment :: Console',
'Development Status :: 4 - Beta',
'Intended Audience :: Developers',
'Intended Audience :: Information Technology',
'Intended Audience :: System Administrators',
'License :: OSI Approved :: GNU Lesser General Public License v3 (LGPLv3)',
'Operating System :: POSIX',
'Programming Language :: Python :: 3.5',
'Programming Language :: Python :: 3.6',
'Programming Language :: Python :: 3.7',
'Programming Language :: Python :: 3.8',
'Topic :: Software Development :: Libraries :: Python Modules',
'Topic :: System :: Networking',
'Topic :: Communications',
'Topic :: Security',
'Topic :: Utilities',
],
entry_points={'console_scripts': [
'onlykey-agent = onlykey_agent:ssh_agent',
'onlykey-gpg = onlykey_agent:gpg_tool',
'onlykey-gpg-agent = onlykey_agent:gpg_agent',
]},
)

View File

@@ -3,15 +3,15 @@ from setuptools import setup
setup(
name='trezor_agent',
version='0.9.1',
version='0.12.0',
description='Using Trezor as hardware SSH/GPG agent',
author='Roman Zeyde',
author_email='roman.zeyde@gmail.com',
author_email='dev@romanzey.de',
url='http://github.com/romanz/trezor-agent',
scripts=['trezor_agent.py'],
install_requires=[
'libagent>=0.9.0',
'trezor>=0.9.0'
'libagent>=0.14.0',
'trezor[hidapi]>=0.13'
],
platforms=['POSIX'],
classifiers=[
@@ -22,10 +22,10 @@ setup(
'Intended Audience :: System Administrators',
'License :: OSI Approved :: GNU Lesser General Public License v3 (LGPLv3)',
'Operating System :: POSIX',
'Programming Language :: Python :: 2.7',
'Programming Language :: Python :: 3.4',
'Programming Language :: Python :: 3.5',
'Programming Language :: Python :: 3.6',
'Programming Language :: Python :: 3.7',
'Programming Language :: Python :: 3.8',
'Topic :: Software Development :: Libraries :: Python Modules',
'Topic :: System :: Networking',
'Topic :: Communications',
@@ -36,5 +36,7 @@ setup(
'trezor-agent = trezor_agent:ssh_agent',
'trezor-gpg = trezor_agent:gpg_tool',
'trezor-gpg-agent = trezor_agent:gpg_agent',
'trezor-signify = trezor_agent:signify_tool',
'age-plugin-trezor = trezor_agent:age_tool', # see https://github.com/str4d/rage/blob/main/age-plugin/README.md
]},
)

View File

@@ -1,7 +1,8 @@
import libagent.gpg
import libagent.ssh
from libagent import age, signify, gpg, 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)
age_tool = lambda: age.main(DeviceType)
ssh_agent = lambda: ssh.main(DeviceType)
gpg_tool = lambda: gpg.main(DeviceType)
gpg_agent = lambda: gpg.run_agent(DeviceType)
signify_tool = lambda: signify.main(DeviceType)

15
contrib/neopg-trezor Executable file
View File

@@ -0,0 +1,15 @@
#!/usr/bin/env python3
import os
import sys
agent = 'trezor-gpg-agent'
binary = 'neopg'
if sys.argv[1:2] == ['agent']:
os.execvp(agent, [agent, '-vv'] + sys.argv[2:])
else:
# HACK: pass this script's path as argv[0], so it will be invoked again
# when NeoPG tries to run its own agent:
# https://github.com/das-labor/neopg/blob/1fe50460abe01febb118641e37aa50bc429a1786/src/neopg.cpp#L114
# https://github.com/das-labor/neopg/blob/1fe50460abe01febb118641e37aa50bc429a1786/legacy/gnupg/common/asshelp.cpp#L217
os.execvp(binary, [__file__, 'gpg2'] + sys.argv[1:])

View File

@@ -6,17 +6,17 @@ SSH and GPG do this by means of a simple interprocess communication protocol (us
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.
The various hardware wallets (Trezor, KeepKey, Ledger and Jade) 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.
`trezor-agent` goes to great length to avoid using the valuable parent key.
The rationale behind this is that `trezor-agent` is to some extent condemned to *blindly* signing any NONCE given to it (e.g. as part of a challenge respone, or as the hash/hmac of someting to sign).
The rationale behind this is that `trezor-agent` is to some extent condemned to *blindly* signing any NONCE given to it (e.g. as part of a challenge respone, or as the hash/hmac of someting to sign).
And doing so with the master private key is risky - as rogue (ssh) server could possibly provide a doctored NONCE that happens to be tied to a transaction or something else.
And doing so with the master private key is risky - as rogue (ssh) server could possibly provide a doctored NONCE that happens to be tied to a transaction or something else.
It therefore uses only derived child keys pairs instead (according to the [BIP-0032: Hierarchical Deterministic Wallets][1] system) - and ones on different leafs. So the parent key is only used within the device for creating the child keys - and not exposed in any way to `trezor-agent`.
@@ -26,26 +26,26 @@ It is common for SSH users to use one (or a few) private keys with SSH on all se
So taking a commmand such as:
$ trezor-agent -c user@fqdn.com
$ trezor-agent -c user@fqdn.com
The `trezor-agent` will take the `user`@`fqdn.com`; canonicalise it (e.g. to add the ssh default port number if none was specified) and then apply some simple hashing (See [SLIP-0013 : Authentication using deterministic hierarchy][2]). The resulting 128bit hash is then used to construct a lead 'HD node' that contains an extened public private *child* key.
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).
This way the 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.
GPG uses much the same approach as SSH, except in this case it relies on [SLIP-0017 : ECDH using deterministic hierarchy][3] for the mapping to an ECDH key and it maps these to the normal GPG child key infrastructure.
Note: Keepkey does not support en-/de-cryption at this time.
### Index
The canonicalisation process ([SLIP-0013][2] and [SLIP-0017][3]) of an email address or ssh address allows for the mixing in of an extra 'index' - a unsigned 32 bit number. This allows one to have multiple, different keys, for the same address.
The canonicalisation process ([SLIP-0013][2] and [SLIP-0017][3]) of an email address or ssh address allows for the mixing in of an extra 'index' - a unsigned 32 bit number. This allows one to have multiple, different keys, for the same address.
This feature is currently not used -- it is set to '0'. This may change in the future.
[1]: https://github.com/bitcoin/bips/blob/master/bip-0032.mediawiki
[1]: https://github.com/bitcoin/bips/blob/master/bip-0032.mediawiki
[2]: https://github.com/satoshilabs/slips/blob/master/slip-0013.md
[3]: https://github.com/satoshilabs/slips/blob/master/slip-0017.md

View File

@@ -1,21 +1,30 @@
# Installation
## 1. Prerequisites
Install the following packages (depending on your distribution):
## Install dependencies
### OS dependencies
### Debian
This software needs Python, libusb, and libudev along with development files.
$ apt update && apt upgrade
$ apt install python-pip python-dev python-tk libusb-1.0-0-dev libudev-dev
You can install them on these distributions as follows:
### Fedora/RedHat
##### Debian
$ yum update
$ yum install python-pip python-devel python-tk libusb-devel libudev-devel \
$ apt-get install python3-pip python3-dev python3-tk libusb-1.0-0-dev libudev-dev
##### RedHat
$ yum install python3-pip python3-devel python3-tk libusb-devel libudev-devel \
gcc redhat-rpm-config
### OpenSUSE
##### Fedora
$ dnf install python3-pip python3-devel python3-tkinter libusb-devel libudev-devel \
gcc redhat-rpm-config
##### OpenSUSE
$ zypper install python-pip python-devel python-tk libusb-1_0-devel libudev-devel
@@ -25,58 +34,148 @@ dependencies instead:
$ zypper install python3-pip python3-devel python3-tk libusb-1_0-devel libudev-devel
## Update setuptools and pip
##### macOS
Also, update Python packages before starting the installation:
There are many different options to install python environment on macOS ([official](https://www.python.org/downloads/mac-osx/), [anaconda](https://conda.io/docs/user-guide/install/macos.html), ..). Most importantly you need `libusb`. Probably the easiest way is via [homebrew](https://brew.sh/)
$ pip install -U setuptools pip
$ brew install libusb
## Check device's firmware version
### GPG
Make sure you are running the latest firmware version on your hardware device.
Currently the following firmware versions are supported:
If you intend to use GPG make sure you have GPG installed and up to date. This software requires a GPG version >= 2.1.11.
* [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)
You can verify your installed version by running:
```
$ gpg2 --version | head -n1
gpg (GnuPG) 2.1.15
```
## TREZOR
* Follow this installation guide for [Debian](https://gist.github.com/vt0r/a2f8c0bcb1400131ff51)
* Install GPG for [macOS](https://sourceforge.net/p/gpgosx/docu/Download/)
* Install packages for Ubuntu 16.04 [here](https://launchpad.net/ubuntu/+source/gnupg2)
* Install packages for Linux Mint 18 [here](https://community.linuxmint.com/software/view/gnupg2)
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:
# 2. Install the TREZOR agent
$ pip install trezor_agent
1. Make sure you are running the latest firmware version on your Trezor:
Or, directly from the latest source code:
* [TREZOR firmware releases](https://wallet.trezor.io/data/firmware/releases.json): `1.4.2+`
2. Make sure that your `udev` rules are configured [correctly](https://wiki.trezor.io/Udev_rules).
3. Then, install the latest [trezor-agent](https://pypi.python.org/pypi/trezor-agent) package:
```
$ pip3 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
$ pip3 install --user -e trezor-agent
$ pip3 install --user -e trezor-agent/agents/trezor
```
## KeepKey
Or, through Homebrew on macOS:
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:
```
$ brew install trezor-agent
```
$ pip install keepkey_agent
# 3. Install the KeepKey agent
Or, directly from the latest source code:
1. Make sure you are running the latest firmware version on your KeepKey:
* [KeepKey firmware releases](https://github.com/keepkey/keepkey-firmware/releases): `3.0.17+`
2. Make sure that your `udev` rules are configured [correctly](https://support.keepkey.com/support/solutions/articles/6000037796-keepkey-wallet-is-not-being-recognized-by-linux).
Then, install the latest [keepkey-agent](https://pypi.python.org/pypi/keepkey-agent) package:
```
$ pip3 install keepkey_agent
```
Or, on Mac using Homebrew:
```
$ brew 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
$ pip3 install --user -e trezor-agent/agents/keepkey
```
## Ledger Nano S
# 4. Install the Ledger Nano S agent
Make sure that your `udev` rules are configured [correctly](https://ledger.zendesk.com/hc/en-us/articles/115005165269-What-if-Ledger-Wallet-is-not-recognized-on-Linux-).
Then, install the latest [ledger_agent](https://pypi.python.org/pypi/ledger_agent) package:
1. Make sure you are running the latest firmware version on your Ledger Nano S:
$ pip install ledger_agent
* [Ledger Nano S firmware releases](https://github.com/LedgerHQ/blue-app-ssh-agent): `0.0.3+` (install [SSH/PGP Agent](https://www.ledgerwallet.com/images/apps/chrome-mngr-apps.png) app)
Or, directly from the latest source code:
2. Make sure that your `udev` rules are configured [correctly](https://ledger.zendesk.com/hc/en-us/articles/115005165269-What-if-Ledger-Wallet-is-not-recognized-on-Linux-).
3. Then, install the latest [ledger-agent](https://pypi.python.org/pypi/ledger-agent) package:
```
$ pip3 install ledger-agent
```
Or, directly from the latest source code:
```
$ git clone https://github.com/romanz/trezor-agent
$ pip install --user -e trezor-agent/agents/ledger
$ pip3 install --user -e trezor-agent
$ pip3 install --user -e trezor-agent/agents/ledger
```
## Troubleshooting
# 5. Install the OnlyKey agent
1. Make sure you are running the latest firmware version on your OnlyKey:
* [OnlyKey Firmware Upgrade Guide](https://docs.crp.to/upgradeguide.html)
2. Make sure that your `udev` rules are configured [correctly](https://docs.crp.to/linux.html#udev-rule).
3. Then, install the latest [onlykey-agent](https://pypi.python.org/pypi/onlykey-agent) package:
```
$ pip3 install onlykey-agent
```
Or, directly from the latest source code:
```
$ git clone https://github.com/romanz/trezor-agent
$ pip3 install --user -e trezor-agent
$ pip3 install --user -e trezor-agent/agents/onlykey
```
# 6. Install the Blockstream Jade agent
1. Make sure you are running the latest firmware version on your Blockstream Jade:
* [Jade firmware releases](https://github.com/Blockstream/Jade/blob/master/CHANGELOG.md): `0.1.33+`
2. Make sure that your `udev` rules are configured [correctly](https://github.com/bitcoin-core/HWI/blob/master/hwilib/udev/55-usb-jade.rules).
3. If necessary, ensure the user is added to the [`dialout` group](https://help.blockstream.com/hc/en-us/articles/900005443223-My-Blockstream-Jade-is-not-recognized-by-my-computer)
4. Then, install the latest [jade-agent](https://pypi.python.org/pypi/jade-agent) package:
```
$ pip3 install jade-agent
```
Or, directly from the latest source code:
```
$ git clone https://github.com/romanz/trezor-agent
$ pip3 install --user -e trezor-agent
$ pip3 install --user -e trezor-agent/agents/jade
```
# 7. Installation Troubleshooting
If there is an import problem with the installed `protobuf` package,
see [this issue](https://github.com/romanz/trezor-agent/issues/28) for fixing it.

View File

@@ -1,62 +1,66 @@
# GPG Agent
Note: the GPG-related code is still under development, so please try the current implementation
and please let me [know](https://github.com/romanz/trezor-agent/issues/new) if something doesn't
work well for you. If possible:
* record the session (e.g. using [asciinema](https://asciinema.org))
* attach the GPG agent log from `~/.gnupg/{trezor,ledger}/gpg-agent.log`
* attach the GPG agent log from `~/.gnupg/{trezor,ledger,jade}/gpg-agent.log` (can be [encrypted](https://keybase.io/romanz))
Thanks!
# Installation
## 1. Configuration
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/)):
1. Initialize the agent GPG directory.
```
$ gpg2 --version | head -n1
gpg (GnuPG) 2.1.15
```
[![asciicast](https://asciinema.org/a/3iNw2L9QWB8R3EVdYdAxMOLK8.png)](https://asciinema.org/a/3iNw2L9QWB8R3EVdYdAxMOLK8)
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).
Run
Update you device firmware to the latest version and install your specific `agent` package:
```
$ (trezor|keepkey|ledger|jade|onlykey)-gpg init "Roman Zeyde <roman.zeyde@gmail.com>"
```
```
$ pip install --user (trezor|keepkey|ledger)_agent
```
Follow the instructions provided to complete the setup. Keep note of the timestamp value which you'll need if you want to regenerate the key later.
# Quickstart
If you'd like a Trezor-style PIN entry program, follow [these instructions](README-PINENTRY.md).
## Identity creation
[![asciicast](https://asciinema.org/a/3iNw2L9QWB8R3EVdYdAxMOLK8.png)](https://asciinema.org/a/3iNw2L9QWB8R3EVdYdAxMOLK8)
2. Add `export GNUPGHOME=~/.gnupg/(trezor|keepkey|ledger|jade|onlykey)` to your `.bashrc` or other environment file.
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>"
```
This `GNUPGHOME` contains your hardware keyring and agent settings. This agent software assumes all keys are backed by hardware devices so you can't use standard GPG keys in `GNUPGHOME` (if you do mix keys you'll receive an error when you attempt to use them).
If you wish to switch back to your software keys unset `GNUPGHOME`.
3. Log out and back into your session to ensure your environment is updated everywhere.
## 2. Usage
You can use any GPG commands or software that uses GPG as usual and will be prompted to interact with your hardware device as necessary. The agent is automatically started if it isn't running when you run any `gpg` command.
##### Restarting the agent
If you change settings or need to restart the agent for some other reason, simply kill it. It will restart the next time GPG is invoked.
## 3. Common Use Cases
### Sign and decrypt files
## 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:
### Inspect GPG keys
You can use GNU Privacy Assistant (GPA) in order to inspect the created keys and perform signature and decryption operations as usual:
```
$ sudo apt install gpa
$ GNUPGHOME=~/.gnupg/trezor gpa
$ 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:
### Sign Git commits and tags
Git can use GPG to sign and verify commits and tags (see [here](https://git-scm.com/book/en/v2/Git-Tools-Signing-Your-Work)):
```
$ git config --local commit.gpgsign 1
$ git config --local gpg.program $(which gpg2)
@@ -66,11 +70,29 @@ $ git tag v1.2.3 --sign # create GPG-signed tag
$ git tag v1.2.3 --verify # verify tag signature
```
## Password manager
Note that your git email has to correlate to your gpg key email. If you use a different email for git, you'll need to either generate a new gpg key for that email or set your git email using the command:
First install `pass` from [passwordstore.org](https://www.passwordstore.org/) and initialize it to use your TREZOR-based GPG identity:
````
$ git config user.email foo@example.com
````
If your git email is configured incorrectly, you will receive the error:
````
error: gpg failed to sign the data
fatal: failed to write commit object
````
when committing to git.
### Manage passwords
Password managers such as [pass](https://www.passwordstore.org/) rely on GPG for encryption so you can use your device with them too.
##### With `pass`:
First install `pass` from [passwordstore.org] and initialize it to use your TREZOR-based GPG identity:
```
$ export GNUPGHOME=~/.gnupg/trezor
$ pass init "Roman Zeyde <roman.zeyde@gmail.com>"
Password store initialized for Roman Zeyde <roman.zeyde@gmail.com>
```
@@ -99,10 +121,9 @@ 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
### Re-generate a 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
@@ -112,7 +133,7 @@ retrieve the timestamp with the following command (substitute "john@doe.bit" for
$ gpg2 --export 'john@doe.bit' | gpg2 --list-packets | grep created | head -n1
```
## Adding new user IDs
### Add new UIDs to your identity
After your main identity is created, you can add new user IDs using the regular GnuPG commands:
```
@@ -144,7 +165,7 @@ uid [ultimate] Foobar
ssb nistp256/35F58F26 2017-12-05 [E]
```
## GnuPG subkey generation
### Generate GnuPG subkeys
In order to add TREZOR-based subkey to an existing GnuPG identity, use the `--subkey` flag:
```
$ gpg2 -k foobar
@@ -173,3 +194,58 @@ There are 4 choices for the alternative pinentry (providing /usr/bin/pinentry).
Press <enter> to keep the current choice[*], or type selection number: 0
```
### Sign and decrypt email
Follow [these instructions](enigmail.md) to set up Enigmail in Thunderbird.
### Start the agent as a systemd unit
##### 1. Create these files in `~/.config/systemd/user`
Replace `trezor` with `keepkey` or `ledger` or `jade` or `onlykey` as required.
###### `trezor-gpg-agent.service`
````
[Unit]
Description=trezor-gpg-agent
Requires=trezor-gpg-agent.socket
[Service]
Type=simple
Environment="GNUPGHOME=%h/.gnupg/trezor"
Environment="PATH=/bin:/usr/bin:/usr/local/bin:%h/.local/bin"
ExecStart=/usr/bin/trezor-gpg-agent -vv
````
If you've installed `trezor-agent` locally you may have to change the path in `ExecStart=`.
###### `trezor-gpg-agent.socket`
````
[Unit]
Description=trezor-gpg-agent socket
[Socket]
ListenStream=%t/gnupg/S.gpg-agent
FileDescriptorName=std
SocketMode=0600
DirectoryMode=0700
[Install]
WantedBy=sockets.target
````
##### 2. Stop trezor-gpg-agent if it's already running
```
killall trezor-gpg-agent
```
##### 3. Run
```
systemctl --user start trezor-gpg-agent.service trezor-gpg-agent.socket
systemctl --user enable trezor-gpg-agent.socket
```

31
doc/README-NeoPG.md Normal file
View File

@@ -0,0 +1,31 @@
# NeoPG experimental support
1. Download build and install NeoPG from [source code](https://github.com/das-labor/neopg#installation).
2. Generate Ed25519-based identity (using a [special wrapper](https://github.com/romanz/trezor-agent/blob/c22109df24c6eb8263aa40183a016be3437b1a0c/contrib/neopg-trezor) to invoke TREZOR-based agent):
```bash
$ export NEOPG_BINARY=$PWD/contrib/neopg-trezor
$ $NEOPG_BINARY --help
$ export GNUPGHOME=/tmp/homedir
$ trezor-gpg init "FooBar" -e ed25519
sec ed25519 2018-07-01 [SC]
802AF7E2DCF4491FFBB2F032341E95EF57CD7D5E
uid [ultimate] FooBar
ssb cv25519 2018-07-01 [E]
```
3. Sign and verify signatures:
```
$ $NEOPG_BINARY -v --detach-sign FILE
neopg: starting agent '/home/roman/Code/trezor/trezor-agent/contrib/neopg-trezor'
neopg: using pgp trust model
neopg: writing to 'FILE.sig'
neopg: EDDSA/SHA256 signature from: "341E95EF57CD7D5E FooBar"
$ $NEOPG_BINARY --verify FILE.sig FILE
neopg: Signature made Sun Jul 1 11:52:51 2018 IDT
neopg: using EDDSA key 802AF7E2DCF4491FFBB2F032341E95EF57CD7D5E
neopg: Good signature from "FooBar" [ultimate]
```

69
doc/README-PINENTRY.md Normal file
View File

@@ -0,0 +1,69 @@
# Custom PIN entry
In order to use the default GPG pinentry program, install one of the following Linux packages:
```
$ apt install pinentry-{curses,gnome3,qt}
```
or (on macOS):
```
$ brew install pinentry
```
By default a standard GPG PIN entry program is used when entering your Trezor PIN, but it's difficult to use if you don't have a numeric keypad or want to use your mouse.
You can specify a custom PIN entry program such as [trezor-gpg-pinentry-tk](https://github.com/rendaw/trezor-gpg-pinentry-tk) (and separately, a passphrase entry program) to match your workflow.
The below examples use `trezor-gpg-pinentry-tk` but any GPG compatible PIN entry can be used.
##### 1. Install the PIN entry
Run
```
pip install trezor-gpg-pinentry-tk
```
##### 2. SSH
Add the flag `--pin-entry-binary trezor-gpg-pinentry-tk` to all calls to `trezor-agent`.
To automatically use this flag, add the line `pinentry=trezor-gpg-pinentry-tk` to `~/.ssh/agent.config`. **Note** this is currently broken due to [this dependency issue](https://github.com/bw2/ConfigArgParse/issues/114).
If you run the SSH agent with Systemd you'll need to add `--pin-entry-binary` to the `ExecStart` command. You may also need to add this line:
```
Environment="DISPLAY=:0"
```
to the `[Service]` section to tell the PIN entry program how to connect to the X11 server.
##### 3. GPG
If you haven't completed initialization yet, run:
```
$ (trezor|keepkey|ledger)-gpg init --pin-entry-binary trezor-gpg-pinentry-tk "Roman Zeyde <roman.zeyde@gmail.com>"
```
to configure the PIN entry at the same time.
Otherwise, open `$GNUPGHOME/trezor/run-agent.sh` and change the `--pin-entry-binary` option to `trezor-gpg-pinentry-tk` and run:
```
killall trezor-gpg-agent
```
##### 4. Troubleshooting
Any problems running the PIN entry program with GPG should appear in `$HOME/.gnupg/trezor/gpg-agent.log`.
You can get similar logs for SSH by specifying `--log-file` in the SSH command line.
The passphrase is cached by the agent (after its first entry), which needs to be restarted in order to reset the passphrase:
```
$ killall trezor-agent # (for SSH)
$ killall trezor-gpg-agent # (for GPG)
```

View File

@@ -1,18 +1,80 @@
# Screencast demo usage
# SSH Agent
## Simple usage (single SSH session)
## Configuration
SSH requires no configuration, but you may put common command line options in `~/.ssh/agent.conf` to avoid repeating them in every invocation.
See `(trezor|keepkey|ledger|jade|onlykey)-agent -h` for details on supported options and the configuration file format.
If you'd like a Trezor-style PIN entry program, follow [these instructions](README-PINENTRY.md).
## Usage
Use the `(trezor|keepkey|ledger|jade|onlykey)-agent` program to work with SSH. It has three main modes of operation:
##### 1. Export public keys
To get your public key so you can add it to `authorized_hosts` or allow
ssh access to a service that supports it, run:
```
(trezor|keepkey|ledger|jade|onlykey)-agent identity@myhost
```
The identity (ex: `identity@myhost`) is used to derive the public key and is added as a comment to the exported key string.
##### 2. Run a command with the agent's environment
Run
```
$ (trezor|keepkey|ledger|jade|onlykey)-agent identity@myhost -- COMMAND --WITH --ARGUMENTS
```
to start the agent in the background and execute the command with environment variables set up to use the SSH agent. The specified identity is used for all SSH connections. The agent will exit after the command completes.
Note the `--` separator, which is used to separate `trezor-agent`'s arguments from the SSH command arguments.
Example:
```
(trezor|keepkey|ledger|jade|onlykey)-agent -e ed25519 bob@example.com -- rsync up/ bob@example.com:/home/bob
```
As a shortcut you can run
```
$ (trezor|keepkey|ledger|jade|onlykey)-agent identity@myhost -s
```
to start a shell with the proper environment.
##### 3. Connect to a server directly via `(trezor|keepkey|ledger|jade|onlykey)-agent`
If you just want to connect to a server this is the simplest way to do it:
```
$ (trezor|keepkey|ledger|jade|onlykey)-agent user@remotehost -c
```
The identity `user@remotehost` is used as both the destination user and host as well as for key derivation, so you must generate a separate key for each host you connect to.
## Common Use Cases
### Start a single SSH session
[![Demo](https://asciinema.org/a/22959.png)](https://asciinema.org/a/22959)
## Advanced usage (multiple SSH sessions from a sub-shell)
### Start multiple SSH sessions from a sub-shell
This feature allows using regular SSH-related commands within a subprocess running user's shell.
`SSH_AUTH_SOCK` environment variable is defined for the subprocess (pointing to the SSH agent, running as a parent process).
This way the user can use SSH-related commands (e.g. `ssh`, `ssh-add`, `sshfs`, `git`, `hg`), while authenticating via the hardware device.
[![Subshell](https://asciinema.org/a/33240.png)](https://asciinema.org/a/33240)
## Loading multiple SSH identities from configuration file
### Load different SSH identities from configuration file
[![Config](https://asciinema.org/a/bdxxtgctk5syu56yfz8lcp7ny.png)](https://asciinema.org/a/bdxxtgctk5syu56yfz8lcp7ny)
# Public key generation
### Implement passwordless login
Run:
@@ -26,52 +88,188 @@ Append `hostname.pub` contents to `/home/user/.ssh/authorized_keys`
configuration file at `ssh.hostname.com`, so the remote server
would allow you to login using the corresponding private key signature.
# Usage
### Access remote Git/Mercurial repositories
Run:
Export your public key and register it in your repository web interface
(e.g. [GitHub](https://help.github.com/articles/adding-a-new-ssh-key-to-your-github-account/)):
/tmp $ trezor-agent user@ssh.hostname.com -v -c
2015-09-02 15:09:39,782 INFO getting "ssh://user@ssh.hostname.com" public key from Trezor...
2015-09-02 15:09:44,430 INFO please confirm user "roman" login to "ssh://user@ssh.hostname.com" using Trezor...
2015-09-02 15:09:46,152 INFO signature status: OK
Linux lmde 3.16.0-4-amd64 #1 SMP Debian 3.16.7-ckt11-1+deb8u3 (2015-08-04) x86_64
$ trezor-agent -v -e ed25519 git@github.com > ~/.ssh/github.pub
The programs included with the Debian GNU/Linux system are free software;
the exact distribution terms for each program are described in the
individual files in /usr/share/doc/*/copyright.
Add the following configuration to your `~/.ssh/config` file:
Debian GNU/Linux comes with ABSOLUTELY NO WARRANTY, to the extent
permitted by applicable law.
Last login: Tue Sep 1 15:57:05 2015 from localhost
~ $
Make sure to confirm SSH signature on the Trezor device when requested.
## Accessing remote Git/Mercurial repositories
Use your SSH public key to access your remote repository (e.g. [GitHub](https://help.github.com/articles/adding-a-new-ssh-key-to-your-github-account/)):
$ trezor-agent -v -e ed25519 git@github.com | xclip
Host github.com
IdentityFile ~/.ssh/github.pub
Use the following Bash alias for convenient Git operations:
$ alias git_hub='trezor-agent -v -e ed25519 git@github.com -- git'
$ alias ssh-shell='trezor-agent ~/.ssh/github.pub -v --shell'
Replace `git` with `git_hub` for remote operations:
Now, you can use regular Git commands under the "SSH-enabled" sub-shell:
$ git_hub push origin master
$ ssh-shell
$ git push origin master
The same works for Mercurial (e.g. on [BitBucket](https://confluence.atlassian.com/bitbucket/set-up-ssh-for-mercurial-728138122.html)):
$ trezor-agent -v -e ed25519 git@bitbucket.org -- hg push
$ ssh-shell
$ hg push
### Git commit signing
For more details, see the following great blog post: https://calebhearth.com/sign-git-with-ssh
$ trezor-agent -e ed25519 user@host --shell
$ ssh-add -L
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIDeAmtnhHlyg4dzGP3/OF4WHX7NoYhClS98EK22q/O5+ <ssh://user@host|ed25519>
$ git config --local user.signingkey "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIDeAmtnhHlyg4dzGP3/OF4WHX7NoYhClS98EK22q/O5+"
$ git config --local gpg.format ssh
$ git config --local commit.gpgsign true
$ git config --local gpg.ssh.allowedSignersFile $PWD/.git/allowed-signers
$ echo "user@host ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIDeAmtnhHlyg4dzGP3/OF4WHX7NoYhClS98EK22q/O5+" >> $PWD/.git/allowed-signers
$ git commit --allow-empty --message="Testing SSH signing"
[master 4a1f730] Testing SSH signing
$ git log --show-signature -1
commit 4a1f730d7f70fd31a0bda334734d0ac4dc9d97ad (HEAD -> master)
Good "git" signature for user@host with ED25519 key SHA256:aESFjLsydJHQg1vnAkq42jQDkCcn4Tde4J+v+0XFmwM
Author: Roman Zeyde <me@romanzey.de>
Date: Fri Oct 21 18:34:09 2022 +0300
Testing SSH signing
$ cat .git/config
[user]
signingkey = ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIDeAmtnhHlyg4dzGP3/OF4WHX7NoYhClS98EK22q/O5+
[gpg]
format = ssh
[commit]
gpgsign = true
[gpg "ssh"]
allowedSignersFile = /home/user/Code/test-git-ssh-sig/.git/allowed-signers
# Troubleshooting
### Start the agent as a systemd unit
##### 1. Create these files in `~/.config/systemd/user`
Replace `trezor` with `keepkey` or `ledger` or `jade` or `onlykey` as required.
###### `trezor-ssh-agent.service`
````
[Unit]
Description=trezor-agent SSH agent
Requires=trezor-ssh-agent.socket
[Service]
Type=simple
Restart=always
Environment="DISPLAY=:0"
Environment="PATH=/bin:/usr/bin:/usr/local/bin:%h/.local/bin"
ExecStart=/usr/bin/trezor-agent --foreground --sock-path %t/trezor-agent/S.ssh IDENTITY
````
If you've installed `trezor-agent` locally you may have to change the path in `ExecStart=`.
Replace `IDENTITY` with the identity you used when exporting the public key.
`IDENTITY` can be a path (starting with `/`) to a file containing a list of public keys
generated by Trezor. I.e. `/home/myUser/.ssh/trezor.conf` with one public key per line.
This is a more convenient way to have a systemd setup that has to handle multiple
keys/hosts.
When updating the file, make sure to restart trezor-agent.
If you have multiple Trezors connected, you can select which one to use via a `TREZOR_PATH`
environment variable. Use `trezorctl list` to find the correct path. Then add it
to the agent with the following line:
````
Environment="TREZOR_PATH=<your path here>"
````
Note that USB paths depend on the _USB port_ which you use.
###### `trezor-ssh-agent.socket`
````
[Unit]
Description=trezor-agent SSH agent socket
[Socket]
ListenStream=%t/trezor-agent/S.ssh
FileDescriptorName=ssh
Service=trezor-ssh-agent.service
SocketMode=0600
DirectoryMode=0700
[Install]
WantedBy=sockets.target
````
##### 2. Run
```
systemctl --user start trezor-ssh-agent.service trezor-ssh-agent.socket
systemctl --user enable trezor-ssh-agent.socket
```
##### 3. Add this line to your `.bashrc` or equivalent file:
```bash
export SSH_AUTH_SOCK=$(systemctl show --user --property=Listen trezor-ssh-agent.socket | grep -o "/run.*" | cut -d " " -f 1)
```
Make sure the SSH_AUTH_SOCK variable matches the location of the socket that trezor-agent
is listening on: `ps -x | grep trezor-agent`. In this setup trezor-agent should start
automatically when the socket is opened.
##### 4. SSH will now automatically use your device key in all terminals.
## SSH Signatures
SSH and ssh-keygen can make and verify signatures, see https://www.agwa.name/blog/post/ssh_signatures.
See here for more ssh protocol details:
- https://github.com/openssh/openssh-portable/blob/master/PROTOCOL.sshsig
- https://github.com/openssh/openssh-portable/blob/master/sshsig.c
- https://github.com/openssh/openssh-portable/commit/2a9c9f7272c1e8665155118fe6536bebdafb6166
##### generate SSH public key
```
$ trezor-agent -e ed25519 git@github.com | tee ~/.ssh/trezor-github.pub
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIIvcbhXyaXXNytCLTDfEMlLuwEhtfo0XmPP1U5RsnOZ4 <ssh://git@github.com|ed25519>
```
##### sign the given file using TREZOR
```
$ trezor-agent -e ed25519 git@github.com -- ssh-keygen -Y sign -f ~/.ssh/trezor-github.pub -n file README.md
Signing file README.md
Write signature to README.md.sig
```
##### set allowed identities for verification (using the above public key)
```
$ cat allowed
git@github.com ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIIvcbhXyaXXNytCLTDfEMlLuwEhtfo0XmPP1U5RsnOZ4 <ssh://git@github.com|ed25519>
```
##### verify the above signature
```
$ ssh-keygen -Y verify -f allowed -I git@github.com -n file -s README.md.sig -vvv < README.md
debug1: sshsig_verify_fd: signature made with hash "sha512"
debug1: sshsig_wrap_verify: verify message length 64
debug1: Valid (unverified) signature from key SHA256:6UBhPb5SOoCUfasGC1/aCBegYov0/P3ajd6eNbYg77A
debug1: parse_principals_key_and_options: allowed:1: matched principal "git@github.com"
debug1: allowed:1: matched key and principal
Good "file" signature for git@github.com with ED25519 key SHA256:6UBhPb5SOoCUfasGC1/aCBegYov0/P3ajd6eNbYg77A
```
## Troubleshooting
If SSH connection fails to work, please open an [issue](https://github.com/romanz/trezor-agent/issues)
with a verbose log attached (by running `trezor-agent -vv`) .
## Incompatible SSH options
#### `IdentitiesOnly` SSH option
Note that your local SSH configuration may ignore `trezor-agent`, if it has `IdentitiesOnly` option set to `yes`.
@@ -82,6 +280,21 @@ Note that your local SSH configuration may ignore `trezor-agent`, if it has `Ide
This option is intended for situations where ssh-agent offers many different identities.
The default is “no”.
If you are failing to connect, try running:
If you are failing to connect, save your public key using:
$ trezor-agent -vv user@host -- ssh -vv -oIdentitiesOnly=no user@host
$ trezor-agent -vv foobar@hostname.com > ~/.ssh/hostname.pub
And add the following lines to `~/.ssh/config` (providing the public key explicitly to SSH):
Host hostname.com
User foobar
IdentityFile ~/.ssh/hostname.pub
Then, the following commands should successfully command to the remote host:
$ trezor-agent -v foobar@hostname.com -s
$ ssh foobar@hostname.com
or,
$ trezor-agent -v foobar@hostname.com -c

77
doc/README-age.md Normal file
View File

@@ -0,0 +1,77 @@
# `age` Agent
Note: the age-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))
* collect the agent log by setting `TREZOR_AGE_PLUGIN_LOG` environment variable
Thanks!
## 1. Configuration
Install [age 1.1.0+](https://github.com/FiloSottile/age/releases/tag/v1.1.0) or [rage 0.9.0+](https://github.com/str4d/rage/releases/tag/v0.9.0).
Generate an identity by running:
```
$ age-plugin-trezor -i "John Doe" | tee identity
# recipient: age1wpl78afms4x36mucnd4j65sanrtj9873up47qq39h68q0aw7n4xqdlw6tk
# SLIP-0017: John Doe
AGE-PLUGIN-TREZOR-1FFHKSM3QG3HK2PEPD5D
```
## 2. Usage
### Encrypt
Use the recipient ID from above (see [age](https://github.com/FiloSottile/age#usage)/[rage](https://github.com/str4d/rage#usage) instructions):
```
$ date | age -r age1wpl78afms4x36mucnd4j65sanrtj9873up47qq39h68q0aw7n4xqdlw6tk > secret.age
```
### Decrypt
Make sure `age-plugin-trezor` is installed and available (it will be invoked by `age` for decryption):
```
$ age -d -i identity < secret.age
Mon 26 Dec 2022 21:10:26 IST
```
### Manage passwords with `passage`
First install `passage` from https://github.com/FiloSottile/passage and initialize it to use your hardware-based identity:
```
$ mkdir -p ~/.passage/store
$ age-plugin-trezor -i "John Doe" | tee ~/.passage/identities
# recipient: age1wpl78afms4x36mucnd4j65sanrtj9873up47qq39h68q0aw7n4xqdlw6tk
# SLIP-0017: John Doe
AGE-PLUGIN-TREZOR-1FFHKSM3QG3HK2PEPD5D
$ awk '/# recipient:/ {print $3}' ~/.passage/identities | tee -a ~/.passage/store/.age-recipients
age1wpl78afms4x36mucnd4j65sanrtj9873up47qq39h68q0aw7n4xqdlw6tk
```
```
$ passage generate Dev/github 32
$ passage generate Social/hackernews 32
$ passage generate Social/twitter 32
$ passage generate VPS/linode 32
$ passage
Passage
├── 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:
```
$ passage --clip VPS/linode
Copied VPS/linode to clipboard. Will clear in 45 seconds.
```

180
libagent/age/__init__.py Normal file
View File

@@ -0,0 +1,180 @@
"""
TREZOR support for AGE format.
See these links for more details:
- https://age-encryption.org/v1
- https://github.com/FiloSottile/age
- https://github.com/str4d/rage/
"""
import argparse
import base64
import io
import logging
import os
import sys
import bech32
import pkg_resources
from cryptography.exceptions import InvalidTag
from cryptography.hazmat.primitives.ciphers.aead import ChaCha20Poly1305
from .. import device, util
from . import client
log = logging.getLogger(__name__)
def bech32_decode(prefix, encoded):
"""Decode Bech32-encoded data."""
hrp, data = bech32.bech32_decode(encoded)
assert prefix == hrp
return bytes(bech32.convertbits(data, 5, 8, pad=False))
def bech32_encode(prefix, data):
"""Encode data using Bech32."""
return bech32.bech32_encode(prefix, bech32.convertbits(bytes(data), 8, 5))
def run_pubkey(device_type, args):
"""Initialize hardware-based GnuPG identity."""
log.warning('This AGE tool is still in EXPERIMENTAL mode, '
'so please note that the API and features may '
'change without backwards compatibility!')
c = client.Client(device=device_type())
pubkey = c.pubkey(identity=client.create_identity(args.identity), ecdh=True)
recipient = bech32_encode(prefix="age", data=pubkey)
print(f"# recipient: {recipient}")
print(f"# SLIP-0017: {args.identity}")
data = args.identity.encode()
encoded = bech32_encode(prefix="age-plugin-trezor-", data=data).upper()
decoded = bech32_decode(prefix="age-plugin-trezor-", encoded=encoded)
assert decoded.startswith(data)
print(encoded)
def base64_decode(encoded: str) -> bytes:
"""Decode Base64-encoded data (after padding correctly with '=')."""
k = len(encoded) % 4
pad = (4 - k) if k else 0
return base64.b64decode(encoded + ("=" * pad))
# https://github.com/FiloSottile/age/blob/v1.1.0-rc.1/internal/format/format.go#L45
BYTES_PER_LINE = 48
def base64_encode(data: bytes) -> str:
"""Encode data using Base64 (and remove '=')."""
reader = io.BytesIO(data)
chunks = map(base64.b64encode, iter(lambda: reader.read(BYTES_PER_LINE), b""))
chunks = (chunk.replace(b"=", b"") for chunk in chunks)
return b"\n".join(chunks).decode()
def decrypt(key, encrypted):
"""Decrypt age-encrypted data."""
cipher = ChaCha20Poly1305(key)
try:
return cipher.decrypt(
nonce=(b"\x00" * 12),
data=encrypted,
associated_data=None)
except InvalidTag:
return None
def run_decrypt(device_type, args):
"""Unlock hardware device (for future interaction)."""
# pylint: disable=too-many-locals
c = client.Client(device=device_type())
lines = (line.strip() for line in sys.stdin) # strip whitespace
lines = (line for line in lines if line) # skip empty lines
identities = []
stanza_map = {}
for line in lines:
log.debug("got %r", line)
if line == "-> done":
break
if line.startswith("-> add-identity "):
encoded = line.split(" ")[-1].lower()
data = bech32_decode("age-plugin-trezor-", encoded)
identity = client.create_identity(data.decode())
identities.append(identity)
elif line.startswith("-> recipient-stanza "):
file_index, tag, *args = line.split(" ")[2:]
body = next(lines)
if tag != "X25519":
continue
peer_pubkey = base64_decode(args[0])
encrypted = base64_decode(body)
stanza_map.setdefault(file_index, []).append((peer_pubkey, encrypted))
for file_index, stanzas in stanza_map.items():
_handle_single_file(file_index, stanzas, identities, c)
sys.stdout.write('-> done\n\n')
sys.stdout.flush()
sys.stdout.close()
def _handle_single_file(file_index, stanzas, identities, c):
d = c.device.__class__.__name__
for peer_pubkey, encrypted in stanzas:
for identity in identities:
id_str = identity.to_string()
msg = base64_encode(f'Please confirm {id_str} decryption on {d} device...'.encode())
sys.stdout.write(f'-> msg\n{msg}\n')
sys.stdout.flush()
key = c.ecdh(identity=identity, peer_pubkey=peer_pubkey)
result = decrypt(key=key, encrypted=encrypted)
if not result:
continue
sys.stdout.write(f'-> file-key {file_index}\n{base64_encode(result)}\n')
sys.stdout.flush()
return
def main(device_type):
"""Parse command-line arguments."""
p = argparse.ArgumentParser()
agent_package = device_type.package_name()
resources_map = {r.key: r for r in pkg_resources.require(agent_package)}
resources = [resources_map[agent_package], resources_map['libagent']]
versions = '\n'.join('{}={}'.format(r.key, r.version) for r in resources)
p.add_argument('--version', help='print the version info',
action='version', version=versions)
p.add_argument('-i', '--identity')
p.add_argument('-v', '--verbose', default=0, action='count')
p.add_argument('--age-plugin')
args = p.parse_args()
log_path = os.environ.get("TREZOR_AGE_PLUGIN_LOG")
util.setup_logging(verbosity=args.verbose, filename=log_path)
log.debug("starting age plugin: %s", args)
device_type.ui = device.ui.UI(device_type=device_type, config=vars(args))
try:
if args.identity:
run_pubkey(device_type=device_type, args=args)
elif args.age_plugin:
run_decrypt(device_type=device_type, args=args)
except Exception as e: # pylint: disable=broad-except
log.exception("age plugin failed: %s", e)
log.debug("closing age plugin")

48
libagent/age/client.py Normal file
View File

@@ -0,0 +1,48 @@
"""Device abstraction layer for AGE operations."""
import logging
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
from ..device import interface
log = logging.getLogger(__name__)
def create_identity(user_id):
"""Create AGE identity for hardware device."""
result = interface.Identity(identity_str='age://', curve_name="ed25519")
result.identity_dict['host'] = user_id
return result
class Client:
"""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 = bytes(self.device.pubkey(ecdh=ecdh, identity=identity))
assert len(pubkey) == 32
return pubkey
def ecdh(self, identity, peer_pubkey):
"""Derive shared secret using ECDH from peer public key."""
log.info('please confirm AGE decryption on %s for "%s"...',
self.device, identity.to_string())
with self.device:
assert len(peer_pubkey) == 32
result, self_pubkey = self.device.ecdh_with_pubkey(
pubkey=(b"\x40" + peer_pubkey), identity=identity)
assert result[:1] == b"\x04"
hkdf = HKDF(
algorithm=hashes.SHA256(),
length=32,
salt=((peer_pubkey + self_pubkey)),
info=b"age-encryption.org/v1/X25519")
return hkdf.derive(result[1:])

View File

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

View File

@@ -5,8 +5,8 @@ import logging
import ecdsa
from . import interface
from .. import formats
from . import interface
log = logging.getLogger(__name__)
@@ -40,8 +40,7 @@ class FakeDevice(interface.Device):
return self
def close(self):
"""Close connection."""
self.conn = None
"""Close the device."""
def pubkey(self, identity, ecdh=False):
"""Return public key."""
@@ -49,7 +48,8 @@ class FakeDevice(interface.Device):
data = self.vk.to_string()
x, y = data[:32], data[32:]
prefix = bytearray([2 + (bytearray(y)[0] & 1)])
return bytes(prefix) + x
pubkey = bytes(prefix) + x
return formats.decompress_pubkey(pubkey=pubkey, curve_name=identity.curve_name)
def sign(self, identity, blob):
"""Sign given blob and return the signature (as bytes)."""

View File

@@ -59,7 +59,7 @@ class DeviceError(Error):
"""Error during device operation."""
class Identity(object):
class Identity:
"""Represent SLIP-0013 identity, together with a elliptic curve choice."""
def __init__(self, identity_str, curve_name):
@@ -79,7 +79,7 @@ class Identity(object):
def to_string(self):
"""Return identity serialized to string."""
return u'<{}|{}>'.format(identity_to_string(self.identity_dict), self.curve_name)
return '<{}|{}>'.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."""
@@ -102,7 +102,7 @@ class Identity(object):
return self.curve_name
class Device(object):
class Device:
"""Abstract cryptographic hardware device interface."""
def __init__(self):
@@ -113,6 +113,14 @@ class Device(object):
"""Connect to device, otherwise raise NotFoundError."""
raise NotImplementedError()
def close(self):
"""Close connection to device.
By default, close the underlying connection. Overriding classes
can perform their own cleanup.
"""
self.conn.close()
def __enter__(self):
"""Allow usage as context manager."""
self.conn = self.connect()
@@ -121,7 +129,7 @@ class Device(object):
def __exit__(self, *args):
"""Close and mark as disconnected."""
try:
self.conn.close()
self.close()
except Exception as e: # pylint: disable=broad-except
log.exception('close failed: %s', e)
self.conn = None

143
libagent/device/jade.py Normal file
View File

@@ -0,0 +1,143 @@
"""Jade-related code (see https://blockstream.com/jade/)."""
import logging
import ecdsa
import semver
from .. import formats, util
from . import interface
log = logging.getLogger(__name__)
def _verify_support(identity, ecdh):
"""Make sure the device supports given configuration."""
if identity.get_curve_name(ecdh=ecdh) != formats.CURVE_NIST256:
raise NotImplementedError(
'Unsupported elliptic curve: {}'.format(identity.curve_name))
class BlockstreamJade(interface.Device):
"""Connection to Blockstream Jade device."""
MIN_SUPPORTED_FW_VERSION = semver.VersionInfo(0, 1, 33)
DEVICE_IDS = [(0x10c4, 0xea60), (0x1a86, 0x55d4)]
connection = None
@classmethod
def package_name(cls):
"""Python package name (at PyPI)."""
return 'jade-agent'
def connect(self):
"""Connect to the first matching device."""
# pylint: disable=import-error
from jadepy import JadeAPI
from serial.tools import list_ports
# Return the existing connection if we have one
if BlockstreamJade.connection is not None:
return BlockstreamJade.connection
# Jade is a serial (over usb) device, it shows as a serial/com port device.
# Scan com ports looking for the relevant vid and pid, and connect to the
# first matching device. Then call 'auth_user' - this usually requires network
# access in order to unlock the device with a PIN and the remote blind pinserver.
for devinfo in list_ports.comports():
device_product_key = (devinfo.vid, devinfo.pid)
if device_product_key in self.DEVICE_IDS:
try:
jade = JadeAPI.create_serial(devinfo.device)
# Monkey-patch a no-op 'close()' method to suppress logged errors
jade.close = lambda: log.debug("Close called")
# Connect and fetch version info
jade.connect()
verinfo = jade.get_version_info()
# Check minimum supported firmware version (ignore candidate/build parts)
fwversion = semver.VersionInfo.parse(verinfo['JADE_VERSION'])
if self.MIN_SUPPORTED_FW_VERSION > fwversion.finalize_version():
msg = ('Outdated {} firmware for device. Please update using'
' a Blockstream Green companion app')
raise ValueError(msg.format(fwversion))
# Authenticate the user (unlock with pin)
# NOTE: usually requires network access unless already unlocked
# (or temporary 'Emergency Restore' wallet is already in use).
network = 'testnet' if verinfo.get('JADE_NETWORKS') == 'TEST' else 'mainnet'
while not jade.auth_user(network):
log.warning("PIN incorrect, please try again")
# Cache the connection to jade
BlockstreamJade.connection = jade
return jade
except Exception as e:
raise interface.NotFoundError(
'{} not connected: "{}"'.format(self, e))
return None
@staticmethod
def _get_identity_string(identity):
return interface.identity_to_string(identity.identity_dict)
@staticmethod
def _load_uncompressed_pubkey(pubkey, curve_name):
assert curve_name == formats.CURVE_NIST256
assert len(pubkey) == 65 and pubkey[0] == 0x04
curve = ecdsa.NIST256p
point = ecdsa.ellipticcurve.Point(curve.curve,
util.bytes2num(pubkey[1:33]),
util.bytes2num(pubkey[33:65]))
return ecdsa.VerifyingKey.from_public_point(point, curve=curve,
hashfunc=formats.hashfunc)
def pubkey(self, identity, ecdh=False):
"""Get PublicKey object for specified BIP32 address and elliptic curve."""
_verify_support(identity, ecdh)
identity_string = self._get_identity_string(identity)
curve_name = identity.get_curve_name(ecdh=ecdh)
key_type = 'slip-0017' if ecdh else 'slip-0013'
log.debug('"%s" getting %s public key (%s) from %s',
identity_string, key_type, curve_name, self)
result = self.conn.get_identity_pubkey(identity_string, curve_name, key_type)
log.debug('result: %s', result)
assert len(result) == 33 or len(result) == 65
convert_pubkey = (formats.decompress_pubkey
if len(result) == 33 else
self._load_uncompressed_pubkey)
return convert_pubkey(pubkey=result, curve_name=curve_name)
def sign(self, identity, blob):
"""Sign given blob and return the signature (as bytes)."""
_verify_support(identity, ecdh=False)
identity_string = self._get_identity_string(identity)
curve_name = identity.get_curve_name(ecdh=False)
log.debug('"%s" signing %r (%s) on %s',
identity_string, blob, curve_name, self)
result = self.conn.sign_identity(identity_string, curve_name, blob)
log.debug('result: %s', result)
signature = result['signature']
assert len(signature) == 64 or (len(signature) == 65 and signature[0] == 0x00)
if len(signature) == 65:
signature = signature[1:]
return signature
def ecdh(self, identity, pubkey):
"""Get shared session key using Elliptic Curve Diffie-Hellman."""
_verify_support(identity, ecdh=True)
identity_string = self._get_identity_string(identity)
curve_name = identity.get_curve_name(ecdh=True)
log.debug('"%s" shared session key (%s) for %r from %s',
identity_string, curve_name, pubkey, self)
result = self.conn.get_identity_shared_key(identity_string, curve_name, pubkey)
log.debug('result: %s', result)
return result

View File

@@ -1,7 +1,7 @@
"""KeepKey-related code (see https://www.keepkey.com/)."""
from . import trezor
from .. import formats
from . import trezor
def _verify_support(identity, ecdh):
@@ -32,6 +32,9 @@ class KeepKey(trezor.Trezor):
required_version = '>=1.0.4'
def _override_state_handler(self, _):
"""No support for `state` handling on Keepkey."""
def pubkey(self, identity, ecdh=False):
"""Return public key."""
_verify_support(identity, ecdh)

View File

@@ -2,8 +2,23 @@
# pylint: disable=unused-import,import-error
from keepkeylib.client import CallException, PinException
from keepkeylib.client import CallException
from keepkeylib.client import KeepKeyClient as Client
from keepkeylib.client import PinException
from keepkeylib.messages_pb2 import PassphraseAck, PinMatrixAck
from keepkeylib.transport_hid import HidTransport as Transport
from keepkeylib.transport_hid import HidTransport
from keepkeylib.transport_webusb import WebUsbTransport
from keepkeylib.types_pb2 import IdentityType
get_public_node = Client.get_public_node
sign_identity = Client.sign_identity
Client.state = None
def find_device():
"""Returns first WebUSB or HID transport."""
for d in WebUsbTransport.enumerate():
return WebUsbTransport(d)
for d in HidTransport.enumerate():
return HidTransport(d)

View File

@@ -6,6 +6,7 @@ import struct
from ledgerblue import comm # pylint: disable=import-error
from .. import formats
from . import interface
log = logging.getLogger(__name__)
@@ -36,6 +37,24 @@ def _convert_public_key(ecdsa_curve_name, result):
class LedgerNanoS(interface.Device):
"""Connection to Ledger Nano S device."""
LEDGER_APP_NAME = "SSH/PGP Agent"
ledger_app_version = None
ledger_app_supports_end_of_frame_byte = True
def get_app_name_and_version(self, dongle):
"""Retrieve currently running Ledger application name and its version string."""
device_version_answer = dongle.exchange(binascii.unhexlify('B001000000'))
offset = 1
app_name_length = struct.unpack_from("B", device_version_answer, offset)[0]
offset += 1
app_name = device_version_answer[offset: offset + app_name_length]
offset += app_name_length
app_version_length = struct.unpack_from("B", device_version_answer, offset)[0]
offset += 1
app_version = device_version_answer[offset: offset + app_version_length]
log.debug("running app %s, version %s", app_name, app_version)
return (app_name.decode(), app_version.decode())
@classmethod
def package_name(cls):
"""Python package name (at PyPI)."""
@@ -44,10 +63,21 @@ class LedgerNanoS(interface.Device):
def connect(self):
"""Enumerate and connect to the first USB HID interface."""
try:
return comm.getDongle()
dongle = comm.getDongle(debug=True)
(app_name, self.ledger_app_version) = self.get_app_name_and_version(dongle)
version_parts = self.ledger_app_version.split(".")
if (version_parts[0] == "0" and version_parts[1] == "0" and int(version_parts[2]) <= 7):
self.ledger_app_supports_end_of_frame_byte = False
if app_name != LedgerNanoS.LEDGER_APP_NAME:
# we could launch the app here if we are in the dashboard
raise interface.DeviceError(f'{self} is not running {LedgerNanoS.LEDGER_APP_NAME}')
return dongle
except comm.CommException as e:
raise interface.NotFoundError(
'{} not connected: "{}"'.format(self, e))
raise interface.DeviceError(
'Error ({}) communicating with {}'.format(e, self))
def pubkey(self, identity, ecdh=False):
"""Get PublicKey object for specified BIP32 address and elliptic curve."""
@@ -64,28 +94,50 @@ class LedgerNanoS(interface.Device):
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:])
return formats.decompress_pubkey(
pubkey=_convert_public_key(curve_name, result[1:]),
curve_name=identity.curve_name)
def sign(self, identity, blob):
"""Sign given blob and return the signature (as bytes)."""
# pylint: disable=too-many-locals,too-many-branches
path = _expand_path(identity.get_bip32_address(ecdh=False))
if identity.identity_dict['proto'] == 'ssh':
ins = '04'
p1 = '00'
else:
ins = '08'
p1 = '00'
if identity.curve_name == 'nist256p1':
p2 = '81' if identity.identity_dict['proto'] == 'ssh' else '01'
else:
p2 = '82' if identity.identity_dict['proto'] == 'ssh' else '02'
apdu = '80' + ins + p1 + p2
apdu = binascii.unhexlify(apdu)
apdu += bytearray([len(blob) + len(path) + 1])
apdu += bytearray([len(path) // 4]) + path
apdu += blob
log.debug('apdu: %r', apdu)
result = bytearray(self.conn.exchange(bytes(apdu)))
offset = 0
result = None
while offset != len(blob):
data = bytes()
if offset == 0:
data += bytearray([len(path) // 4]) + path
chunk_size = min(len(blob) - offset, 255 - len(data))
data += blob[offset:offset + chunk_size]
if identity.identity_dict['proto'] == 'ssh':
ins = '04'
else:
ins = '08'
if identity.curve_name == 'nist256p1':
p2 = '81' if identity.identity_dict['proto'] == 'ssh' else '01'
else:
p2 = '82' if identity.identity_dict['proto'] == 'ssh' else '02'
if offset + chunk_size == len(blob) and self.ledger_app_supports_end_of_frame_byte:
# mark that we are at the end of the frame
p1 = "80" if offset == 0 else "81"
else:
p1 = "00" if offset == 0 else "01"
apdu = binascii.unhexlify('80' + ins + p1 + p2) + len(data).to_bytes(1, 'little') + data
log.debug('apdu: %r', apdu)
try:
result = bytearray(self.conn.exchange(bytes(apdu)))
except comm.CommException as e:
raise interface.DeviceError(
'Error ({}) communicating with {}'.format(e, self))
offset += chunk_size
log.debug('result: %r', result)
if identity.curve_name == 'nist256p1':
offset = 3
@@ -116,7 +168,11 @@ class LedgerNanoS(interface.Device):
apdu += bytearray([len(path) // 4]) + path
apdu += pubkey
log.debug('apdu: %r', apdu)
result = bytearray(self.conn.exchange(bytes(apdu)))
try:
result = bytearray(self.conn.exchange(bytes(apdu)))
except comm.CommException as e:
raise interface.DeviceError(
'Error ({}) communicating with {}'.format(e, self))
log.debug('result: %r', result)
assert result[0] == 0x04
return bytes(result)

377
libagent/device/onlykey.py Normal file
View File

@@ -0,0 +1,377 @@
# pylint: disable=too-many-locals,too-many-statements,too-many-branches
# pylint: disable=attribute-defined-outside-init
"""OnlyKey-related code (see https://www.onlykey.io/)."""
import codecs
import hashlib
import logging
import time
import ecdsa
import nacl.signing
import unidecode
from . import interface
# import pgpy
# from pgpy import PGPKey
log = logging.getLogger(__name__)
class OnlyKey(interface.Device):
"""Connection to OnlyKey device."""
@classmethod
def package_name(cls):
"""Python package name (at PyPI)."""
return 'onlykey-agent'
@property
def _defs(self):
from . import onlykey_defs
return onlykey_defs
def connect(self):
"""Enumerate and connect to the first USB HID interface."""
try:
self.device_name = 'OnlyKey'
self.ok = self._defs.OnlyKey()
self.ok.set_time(time.time())
self.okversion = self.ok.read_string(timeout_ms=500)
self.okversion = self.okversion[8:]
self.skeyslot = 132
self.dkeyslot = 132
except Exception as e:
raise interface.NotFoundError('{} not connected: "{}"') from e
def set_skey(self, skey):
"""Set signing key to use."""
self.skeyslot = skey
log.debug('Setting skey slot = %s', skey)
def set_dkey(self, dkey):
"""Set decryption key to use."""
self.dkeyslot = dkey
log.debug('Setting dkey slot = %s', dkey)
def import_pub(self, pubkey):
"""Import PGP public key."""
self.import_pubkey = pubkey
log.debug('Public key to import = %s', pubkey)
# self.import_pubkey_obj, _ = pgpy.PGPKey.from_blob(pubkey)
# self.import_pubkey_bytes = bytes(self.import_pubkey_obj)
def get_sk_dk(self):
"""Get default signing key and decryption key slots."""
self.set_skey(132)
self.set_dkey(132)
def sig_hash(self, sighash):
"""Set signature hashing algorithm to use."""
if sighash in (b'rsa-sha2-512', b'rsa-sha2-256'):
self.sighash = sighash
log.info('Setting RSA signature Hash Type =%s', sighash)
def close(self):
"""Close connection."""
log.info('disconnected from %s', self.device_name)
self.ok.close()
def pubkey(self, identity, ecdh=False):
"""Return public key."""
curve_name = identity.get_curve_name(ecdh=ecdh)
if identity.identity_dict['proto'] != 'ssh' and hasattr('self', 'skeyslot') is False:
self.get_sk_dk()
if identity.identity_dict['proto'] != 'ssh' and self.dkeyslot < 132 and ecdh is True:
this_slot_id = self.dkeyslot
log.info('Key Slot =%s', this_slot_id)
elif self.skeyslot < 132 and ecdh is False:
this_slot_id = self.skeyslot
log.info('Key Slot =%s', this_slot_id)
else:
this_slot_id = 132
log.info('Requesting public key from key slot =%s', this_slot_id)
log.debug('"%s" getting public key (%s) from %s',
identity.to_string(), curve_name, self)
# Calculate hash for key derivation input data
if identity.identity_dict['proto'] == 'ssh':
if identity.identity_dict.get('user'):
id_parts = unidecode.unidecode(identity.identity_dict['user'] + '@' +
identity.identity_dict['host']).encode('ascii')
else:
id_parts = unidecode.unidecode(identity.identity_dict['host']).encode('ascii')
else:
id_parts = identity.to_bytes()
log.info('Identity to hash =%s', id_parts)
h1 = hashlib.sha256()
h1.update(id_parts)
data = h1.hexdigest()
log.info('Identity hash =%s', data)
if this_slot_id > 100:
if curve_name == 'curve25519':
data = '04' + data
elif curve_name == 'secp256k1':
# Not currently supported by agent, for future use
data = '03' + data
elif curve_name == 'nist256p1':
data = '02' + data
elif curve_name == 'ed25519':
data = '01' + data
else:
data = '00' + data
self.ok.send_message(msg=self._defs.Message.OKGETPUBKEY, slot_id=this_slot_id, payload=data)
log.info('curve name= %s', repr(curve_name))
t_end = time.time() + 1.5
if curve_name != 'rsa':
while time.time() < t_end:
try:
ok_pubkey = self.ok.read_bytes(timeout_ms=100)
if len(ok_pubkey) == 64 and len(set(ok_pubkey[0:63])) != 1:
break
except Exception as e:
raise interface.DeviceError(e)
log.info('received= %s', repr(ok_pubkey))
if len(set(ok_pubkey[34:63])) == 1:
if curve_name in ('nist256p1', 'secp256k1'):
raise interface.DeviceError("Public key curve does not match requested type")
ok_pubkey = bytearray(ok_pubkey[0:32])
log.info('Received Public Key generated by OnlyKey= %s', repr(ok_pubkey.hex()))
vk = nacl.signing.VerifyKey(bytes(ok_pubkey),
encoder=nacl.encoding.RawEncoder)
log.info('vk= %s', repr(vk))
# time.sleep(3)
return vk
elif len(ok_pubkey) == 64:
ok_pubkey = bytearray(ok_pubkey[0:64])
if curve_name in ('ed25519', 'curve25519'):
raise interface.DeviceError("Public key curve does not match requested type")
log.info('Received Public Key generated by OnlyKey= %s', repr(ok_pubkey))
if identity.curve_name == 'nist256p1':
vk = ecdsa.VerifyingKey.from_string(ok_pubkey, curve=ecdsa.NIST256p)
else:
vk = ecdsa.VerifyingKey.from_string(ok_pubkey, curve=ecdsa.SECP256k1)
return vk
ok_pubkey = []
while time.time() < t_end:
try:
ok_pub_part = self.ok.read_bytes(timeout_ms=100)
if len(ok_pub_part) == 64 and len(set(ok_pub_part[0:63])) != 1:
log.info('received part= %s', repr(ok_pub_part))
ok_pubkey += ok_pub_part
# Todo know RSA type to know how many packets
except Exception as e:
raise interface.DeviceError(e)
log.info('received= %s', repr(ok_pubkey))
if len(ok_pubkey) == 256:
# https://security.stackexchange.com/questions/42268/how-do-i-get-the-rsa-bit-length-with-the-pubkey-and-openssl
ok_pubkey = b'\x00\x00\x00\x07' + b'\x73\x73\x68\x2d\x72\x73\x61' + \
b'\x00\x00\x00\x03' + b'\x01\x00\x01' + \
b'\x00\x00\x01\x01' + b'\x00' + bytes(ok_pubkey)
# ok_pubkey = b'\x00\x00\x00\x07' + b'\x72\x73\x61\x2d\x73\x68\x61\x32\x2d\x32\x35\x
# 36' + b'\x00\x00\x00\x03' + b'\x01\x00\x01' + b'\x00\x00\x01\x01' + b'\x00' + byte
# s(ok_pubkey)
elif len(ok_pubkey) == 512:
ok_pubkey = b'\x00\x00\x00\x07' + b'\x73\x73\x68\x2d\x72\x73\x61' + \
b'\x00\x00\x00\x03' + b'\x01\x00\x01' + \
b'\x00\x00\x02\x01' + b'\x00' + bytes(ok_pubkey)
else:
raise interface.DeviceError("Error response length is not a valid public key")
log.info('pubkey len = %s', len(ok_pubkey))
return ok_pubkey
def sign(self, identity, blob):
"""Sign given blob and return the signature (as bytes)."""
curve_name = identity.get_curve_name(ecdh=False)
log.debug('"%s" signing %r (%s) on %s',
identity.to_string(), blob, curve_name, self)
if identity.identity_dict['proto'] != 'ssh' and hasattr('self', 'skeyslot') is False:
self.get_sk_dk()
# Calculate hash for SSH signing
if curve_name == 'rsa':
if self.sighash == b'rsa-sha2-512':
log.info('rsa-sha2-512')
h1 = hashlib.sha512()
h1.update(blob)
data = h1.hexdigest()
data = codecs.decode(data, 'hex_codec')
elif self.sighash == b'rsa-sha2-256':
log.info('rsa-sha2-256')
h1 = hashlib.sha256()
h1.update(blob)
data = h1.hexdigest()
data = codecs.decode(data, 'hex_codec')
else:
# Calculate hash for key derivation input data
h1 = hashlib.sha256()
if identity.identity_dict['proto'] == 'ssh':
if identity.identity_dict.get('user'):
id_parts = unidecode.unidecode(identity.identity_dict['user'] + '@' +
identity.identity_dict['host']).encode('ascii')
else:
id_parts = unidecode.unidecode(identity.identity_dict['host']).encode('ascii')
else:
id_parts = identity.to_bytes()
h1.update(id_parts)
data = h1.hexdigest()
data = codecs.decode(data, 'hex_codec')
log.info('Identity to hash =%s', id_parts)
log.info('Identity hash =%s', data)
# Determine type of key to derive on OnlyKey for signature
# Slot 132 used for derived key, slots 101-116 used for stored ecc keys
# slots 1-4 used for stored RSA keys
if self.skeyslot == 132:
if curve_name == 'ed25519':
this_slot_id = 201
log.info('Key type ed25519')
elif curve_name == 'nist256p1':
this_slot_id = 202
log.info('Key type nistp256')
else:
this_slot_id = 203
log.info('Key type secp256k1')
# Send data and identity hash
raw_message = blob + data
else:
this_slot_id = self.skeyslot
# Send just data to sign
raw_message = blob
h2 = hashlib.sha256()
h2.update(raw_message)
d = h2.digest()
assert len(d) == 32
b1, b2, b3 = get_button(self, d[0]), get_button(self, d[15]), get_button(self, d[31])
log.info('Key Slot =%s', this_slot_id)
print('Enter the 3 digit challenge code on OnlyKey to authorize '+identity.to_string())
print('{} {} {}'.format(b1, b2, b3))
t_end = time.time() + 22
if curve_name != 'rsa':
self.ok.send_large_message2(msg=self._defs.Message.OKSIGN, payload=raw_message,
slot_id=this_slot_id)
while time.time() < t_end:
try:
result = self.ok.read_bytes(timeout_ms=100)
if len(result) == 64 and len(set(result[0:63])) != 1:
break
except Exception as e:
raise interface.DeviceError(e)
if len(result) >= 60:
log.info('received= %s', repr(result))
while len(result) < 64:
result.append(0)
log.info('disconnected from %s', self.device_name)
self.ok.close()
return bytes(result)
else:
self.ok.send_large_message2(msg=self._defs.Message.OKSIGN, payload=data,
slot_id=this_slot_id)
result = []
while time.time() < t_end:
try:
sig_part = self.ok.read_bytes(timeout_ms=100)
if len(sig_part) == 64 and len(set(sig_part[0:63])) != 1:
log.info('received part= %s', repr(sig_part))
result += sig_part
t_end = time.time() + 1
# Todo know RSA type to know how many packets
except Exception as e:
raise interface.DeviceError(e)
log.info('received= %s', repr(result))
return bytes(result)
raise interface.Error('failed to sign challenge')
def ecdh(self, identity, pubkey):
"""Get shared session key using Elliptic Curve Diffie-Hellman."""
curve_name = identity.get_curve_name(ecdh=True)
log.debug('"%s" shared session key (%s) for %r from %s',
identity.to_string(), curve_name, pubkey, self)
# Calculate hash for key derivation input data
h1 = hashlib.sha256()
if identity.identity_dict['proto'] == 'ssh':
if identity.identity_dict.get('user'):
id_parts = unidecode.unidecode(identity.identity_dict['user'] + '@' +
identity.identity_dict['host']).encode('ascii')
else:
id_parts = unidecode.unidecode(identity.identity_dict['host']).encode('ascii')
else:
id_parts = identity.to_bytes()
h1.update(id_parts)
log.info('Identity to hash =%s', id_parts)
data = h1.hexdigest()
log.info('Identity hash =%s', data)
data = codecs.decode(data, 'hex_codec')
# Determine type of key to derive on OnlyKey for ecdh
# Slot 132 used for derived key, slots 101-116 used for stored ecc keys,
# slots 1-4 used for stored RSA keys
if self.dkeyslot == 132:
if curve_name == 'curve25519':
this_slot_id = 204
log.info('Key type curve25519')
elif curve_name == 'nist256p1':
this_slot_id = 202
log.info('Key type nistp256')
else:
this_slot_id = 203
log.info('Key type secp256k1')
raw_message = pubkey + data
else:
this_slot_id = self.dkeyslot
raw_message = pubkey
log.info('Key Slot =%s', this_slot_id)
log.info('data hash =%s', data)
h2 = hashlib.sha256()
h2.update(raw_message)
d = h2.digest()
assert len(d) == 32
b1, b2, b3 = get_button(self, d[0]), get_button(self, d[15]), get_button(self, d[31])
self.ok.send_large_message2(msg=self._defs.Message.OKDECRYPT, payload=raw_message,
slot_id=this_slot_id)
print('Enter the 3 digit challenge code on OnlyKey to authorize ' + identity.to_string())
print('{} {} {}'.format(b1, b2, b3))
t_end = time.time() + 22
if curve_name != 'rsa':
while time.time() < t_end:
try:
result = self.ok.read_bytes(timeout_ms=100)
if len(result) == 64 and len(set(result[0:63])) != 1:
break
except Exception as e:
raise interface.DeviceError(e)
if len(set(result[34:63])) == 1:
result = b'\x04' + bytes(result[0:32])
else:
result = []
while time.time() < t_end:
try:
dec_part = self.ok.read_bytes(timeout_ms=100)
if len(dec_part) == 64 and len(set(dec_part[0:63])) != 1:
log.info('received part= %s', repr(dec_part))
result += dec_part
t_end = time.time() + 1
# Todo know RSA type to know how many packets
except Exception as e:
raise interface.DeviceError(e)
log.info('received= %s', repr(result))
log.info('disconnected from %s', self.device_name)
self.ok.close()
return bytes(result)
def get_button(self, byte):
"""Return button number."""
if str(self.okversion) == 'v0.2-beta.8c':
return byte % 5 + 1
else:
return byte % 6 + 1

View File

@@ -0,0 +1,5 @@
"""OnlyKey-related definitions."""
# pylint: disable=unused-import,import-error,no-name-in-module
from onlykey import Message, OnlyKey

View File

@@ -2,35 +2,15 @@
import binascii
import logging
import os
import subprocess
import sys
import semver
from .. import formats
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:
log.error('UI failed: %r', err)
raise sp.CalledProcessError(exitcode, args)
return out.decode('ascii')
def _is_open_tty(stream):
return not stream.closed and os.isatty(stream.fileno())
class Trezor(interface.Device):
"""Connection to TREZOR device."""
@@ -42,49 +22,12 @@ class Trezor(interface.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,
'udp': trezor_defs.UdpTransport,
'hid': trezor_defs.HidTransport,
}[os.environ.get('TREZOR_TRANSPORT', 'hid')]
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
ui = None # can be overridden by device's users
cached_session_id = None
def _verify_version(self, connection):
f = connection.features
@@ -103,31 +46,37 @@ class Trezor(interface.Device):
current_version))
def connect(self):
"""Enumerate and connect to the first USB HID interface."""
for transport in self._defs.Transport.enumerate():
log.debug('transport: %s', transport)
for _ in range(5):
connection = self._defs.Client(transport)
self._override_pin_handler(connection)
self._override_passphrase_handler(connection)
self._verify_version(connection)
"""Enumerate and connect to the first available interface."""
transport = self._defs.find_device()
if not transport:
raise interface.NotFoundError('{} not connected'.format(self))
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
log.debug('using transport: %s', transport)
for _ in range(5): # Retry a few times in case of PIN failures
connection = self._defs.Client(transport=transport,
ui=self.ui,
session_id=self.__class__.cached_session_id)
self._verify_version(connection)
raise interface.NotFoundError('{} not connected'.format(self))
try:
# unlock PIN and passphrase
self._defs.get_address(connection,
"Testnet",
self._defs.PASSPHRASE_TEST_PATH)
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
return None
def close(self):
"""Close connection."""
self.conn.close()
self.__class__.cached_session_id = self.conn.session_id
super().close()
def pubkey(self, identity, ecdh=False):
"""Return public key."""
@@ -135,10 +84,13 @@ class Trezor(interface.Device):
log.debug('"%s" getting public key (%s) from %s',
identity.to_string(), curve_name, self)
addr = identity.get_bip32_address(ecdh=ecdh)
result = self.conn.get_public_node(
n=addr, ecdsa_curve_name=curve_name)
result = self._defs.get_public_node(
self.conn,
n=addr,
ecdsa_curve_name=curve_name)
log.debug('result: %s', result)
return bytes(result.node.public_key)
pubkey = bytes(result.node.public_key)
return formats.decompress_pubkey(pubkey=pubkey, curve_name=identity.curve_name)
def _identity_proto(self, identity):
result = self._defs.IdentityType()
@@ -147,12 +99,18 @@ class Trezor(interface.Device):
return result
def sign(self, identity, blob):
"""Sign given blob and return the signature (as bytes)."""
sig, _ = self.sign_with_pubkey(identity, blob)
return sig
def sign_with_pubkey(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(
result = self._defs.sign_identity(
self.conn,
identity=self._identity_proto(identity),
challenge_hidden=blob,
challenge_visual='',
@@ -160,27 +118,37 @@ class Trezor(interface.Device):
log.debug('result: %s', result)
assert len(result.signature) == 65
assert result.signature[:1] == b'\x00'
return bytes(result.signature[1:])
except self._defs.CallException as e:
return bytes(result.signature[1:]), bytes(result.public_key)
except self._defs.TrezorFailure 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."""
session_key, _ = self.ecdh_with_pubkey(identity, pubkey)
return session_key
def ecdh_with_pubkey(self, identity, pubkey):
"""Get shared session key using Elliptic Curve Diffie-Hellman & self public key."""
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(
result = self._defs.get_ecdh_session_key(
self.conn,
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 bytes(result.session_key)
except self._defs.CallException as e:
self_pubkey = result.public_key
if self_pubkey:
self_pubkey = bytes(self_pubkey[1:])
return bytes(result.session_key), self_pubkey
except self._defs.TrezorFailure as e:
msg = '{} error: {}'.format(self, e)
log.debug(msg, exc_info=True)
raise interface.DeviceError(msg)

View File

@@ -1,10 +1,30 @@
"""TREZOR-related definitions."""
# pylint: disable=unused-import,import-error
# pylint: disable=unused-import,import-error,no-name-in-module,no-member
import logging
import os
from trezorlib.client import CallException, PinException
import mnemonic
import semver
import trezorlib
from trezorlib.btc import get_address, get_public_node
from trezorlib.client import PASSPHRASE_TEST_PATH
from trezorlib.client import TrezorClient as Client
from trezorlib.messages import IdentityType, PassphraseAck, PinMatrixAck
from trezorlib.transport_bridge import BridgeTransport
from trezorlib.transport_hid import HidTransport
from trezorlib.transport_udp import UdpTransport
from trezorlib.exceptions import PinException, TrezorFailure
from trezorlib.messages import IdentityType
from trezorlib.misc import get_ecdh_session_key, sign_identity
from trezorlib.transport import get_transport
log = logging.getLogger(__name__)
def find_device():
"""Selects a transport based on `TREZOR_PATH` environment variable.
If unset, picks first connected device.
"""
try:
return get_transport(os.environ.get("TREZOR_PATH"), prefix_search=True)
except Exception as e: # pylint: disable=broad-except
log.debug("Failed to find a Trezor device: %s", e)
return None

156
libagent/device/ui.py Normal file
View File

@@ -0,0 +1,156 @@
"""UIs for PIN/passphrase entry."""
import logging
import os
import subprocess
import sys
from .. import util
try:
from trezorlib.client import PASSPHRASE_ON_DEVICE
except ImportError:
PASSPHRASE_ON_DEVICE = object()
log = logging.getLogger(__name__)
class UI:
"""UI for PIN/passphrase entry (for TREZOR devices)."""
def __init__(self, device_type, config=None):
"""C-tor."""
default_pinentry = 'pinentry' # by default, use GnuPG pinentry tool
if config is None:
config = {}
self.pin_entry_binary = config.get('pin_entry_binary',
default_pinentry)
self.passphrase_entry_binary = config.get('passphrase_entry_binary',
default_pinentry)
self.options_getter = create_default_options_getter()
self.device_name = device_type.__name__
self.cached_passphrase_ack = util.ExpiringCache(
seconds=float(config.get('cache_expiry_seconds', 'inf')))
def get_pin(self, _code=None):
"""Ask the user for (scrambled) PIN."""
description = (
'Use the numeric keypad to describe number positions.\n'
'The layout is:\n'
' 7 8 9\n'
' 4 5 6\n'
' 1 2 3')
return interact(
title='{} PIN'.format(self.device_name),
prompt='PIN:',
description=description,
binary=self.pin_entry_binary,
options=self.options_getter())
def get_passphrase(self, prompt='Passphrase:', available_on_device=False):
"""Ask the user for passphrase."""
passphrase = None
if self.cached_passphrase_ack:
passphrase = self.cached_passphrase_ack.get()
if passphrase is None:
env_passphrase = os.environ.get("TREZOR_PASSPHRASE")
if env_passphrase is not None:
passphrase = env_passphrase
elif available_on_device:
passphrase = PASSPHRASE_ON_DEVICE
else:
passphrase = interact(
title='{} passphrase'.format(self.device_name),
prompt=prompt,
description=None,
binary=self.passphrase_entry_binary,
options=self.options_getter())
if self.cached_passphrase_ack:
self.cached_passphrase_ack.set(passphrase)
return passphrase
def button_request(self, _code=None):
"""Called by TrezorClient when device interaction is required."""
# XXX: show notification to the user?
def create_default_options_getter():
"""Return current TTY and DISPLAY settings for GnuPG pinentry."""
options = []
if sys.stdin.isatty(): # short-circuit calling `tty`
try:
ttyname = subprocess.check_output(args=['tty']).strip()
options.append(b'ttyname=' + ttyname)
except subprocess.CalledProcessError as e:
log.warning('no TTY found: %s', e)
display = os.environ.get('DISPLAY')
if display is not None:
options.append('display={}'.format(display).encode('ascii'))
else:
log.warning('DISPLAY not defined')
log.info('using %s for pinentry options', options)
return lambda: options
def write(p, line):
"""Send and flush a single line to the subprocess' stdin."""
log.debug('%s <- %r', p.args, line)
p.stdin.write(line)
p.stdin.flush()
class UnexpectedError(Exception):
"""Unexpected response."""
def expect(p, prefixes, confidential=False):
"""Read a line and return it without required prefix."""
resp = p.stdout.readline()
log.debug('%s -> %r', p.args, resp if not confidential else '********')
for prefix in prefixes:
if resp.startswith(prefix):
return resp[len(prefix):]
raise UnexpectedError(resp)
def interact(title, description, prompt, binary, options):
"""Use GPG pinentry program to interact with the user."""
args = [binary]
p = subprocess.Popen(args=args,
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
env=os.environ)
p.args = args # TODO: remove after Python 2 deprecation.
expect(p, [b'OK'])
title = util.assuan_serialize(title.encode('ascii'))
write(p, b'SETTITLE ' + title + b'\n')
expect(p, [b'OK'])
if description:
description = util.assuan_serialize(description.encode('ascii'))
write(p, b'SETDESC ' + description + b'\n')
expect(p, [b'OK'])
if prompt:
prompt = util.assuan_serialize(prompt.encode('ascii'))
write(p, b'SETPROMPT ' + prompt + b'\n')
expect(p, [b'OK'])
log.debug('setting %d options', len(options))
for opt in options:
write(p, b'OPTION ' + opt + b'\n')
expect(p, [b'OK', b'ERR'])
write(p, b'GETPIN\n')
pin = expect(p, [b'OK', b'D '], confidential=True)
p.communicate() # close stdin and wait for the process to exit
exit_code = p.wait()
if exit_code:
raise subprocess.CalledProcessError(exit_code, binary)
return pin.decode('ascii').strip()

View File

@@ -5,7 +5,7 @@ import io
import logging
import ecdsa
import ed25519
import nacl.signing
from . import util
@@ -21,12 +21,16 @@ ECDH_NIST256 = 'nist256p1'
ECDH_CURVE25519 = 'curve25519'
# SSH key types
SSH_CERT_POSTFIX = b'-cert-v01@openssh.com'
SSH_NIST256_DER_OCTET = b'\x04'
SSH_NIST256_KEY_PREFIX = b'ecdsa-sha2-'
SSH_NIST256_CURVE_NAME = b'nistp256'
SSH_NIST256_KEY_TYPE = SSH_NIST256_KEY_PREFIX + SSH_NIST256_CURVE_NAME
SSH_NIST256_CERT_TYPE = SSH_NIST256_KEY_TYPE + SSH_CERT_POSTFIX
SSH_ED25519_KEY_TYPE = b'ssh-ed25519'
SUPPORTED_KEY_TYPES = {SSH_NIST256_KEY_TYPE, SSH_ED25519_KEY_TYPE}
SSH_ED25519_CERT_TYPE = SSH_ED25519_KEY_TYPE + SSH_CERT_POSTFIX
SUPPORTED_KEY_TYPES = {SSH_NIST256_KEY_TYPE, SSH_NIST256_CERT_TYPE,
SSH_ED25519_KEY_TYPE, SSH_ED25519_CERT_TYPE}
hashfunc = hashlib.sha256
@@ -41,6 +45,20 @@ def fingerprint(blob):
return ':'.join('{:02x}'.format(c) for c in bytearray(digest))
def __skip_certificate_fields(s):
_serial_number = util.recv(s, '>Q')
_type = util.recv(s, '>L')
_key_id = util.read_frame(s)
_valid_principals = util.read_frame(s)
_valid_after = util.recv(s, '>Q')
_valid_before = util.recv(s, '>Q')
_critical_options = util.read_frame(s)
_extensions = util.read_frame(s)
_reserved = util.read_frame(s)
_signature_key = util.read_frame(s)
_signature = util.read_frame(s)
def parse_pubkey(blob):
"""
Parse SSH public key from given blob.
@@ -49,6 +67,7 @@ def parse_pubkey(blob):
The verifier returns the signatures in the required SSH format.
Currently, NIST256P1 and ED25519 elliptic curves are supported.
"""
# pylint: disable=too-many-locals
fp = fingerprint(blob)
s = io.BytesIO(blob)
key_type = util.read_frame(s)
@@ -57,10 +76,16 @@ def parse_pubkey(blob):
result = {'blob': blob, 'type': key_type, 'fingerprint': fp}
if key_type == SSH_NIST256_KEY_TYPE:
if key_type in (SSH_NIST256_KEY_TYPE, SSH_NIST256_CERT_TYPE):
if key_type == SSH_NIST256_CERT_TYPE:
_nonce = util.read_frame(s)
curve_name = util.read_frame(s)
log.debug('curve name: %s', curve_name)
point = util.read_frame(s)
if key_type == SSH_NIST256_CERT_TYPE:
__skip_certificate_fields(s)
assert s.read() == b''
_type, point = point[:1], point[1:]
assert _type == SSH_NIST256_DER_OCTET
@@ -82,14 +107,20 @@ def parse_pubkey(blob):
result.update(point=coords, curve=CURVE_NIST256,
verifier=ecdsa_verifier)
if key_type == SSH_ED25519_KEY_TYPE:
if key_type in (SSH_ED25519_KEY_TYPE, SSH_ED25519_CERT_TYPE):
if key_type == SSH_ED25519_CERT_TYPE:
_nonce = util.read_frame(s)
pubkey = util.read_frame(s)
if key_type == SSH_ED25519_CERT_TYPE:
__skip_certificate_fields(s)
assert s.read() == b''
def ed25519_verify(sig, msg):
assert len(sig) == 64
vk = ed25519.VerifyingKey(pubkey)
vk.verify(sig, msg)
vk = nacl.signing.VerifyKey(bytes(pubkey),
encoder=nacl.encoding.RawEncoder)
vk.verify(msg, sig)
log.debug('verify signature')
return sig
result.update(curve=CURVE_ED25519, verifier=ed25519_verify)
@@ -99,9 +130,9 @@ def parse_pubkey(blob):
def _decompress_ed25519(pubkey):
"""Load public key from the serialized blob (stripping the prefix byte)."""
if pubkey[:1] == b'\x00':
if pubkey[:1] in {b'\x00', b'\x01'}:
# set by Trezor fsm_msgSignIdentity() and fsm_msgGetPublicKey()
return ed25519.VerifyingKey(pubkey[1:])
return nacl.signing.VerifyKey(pubkey[1:], encoder=nacl.encoding.RawEncoder)
else:
return None
@@ -161,8 +192,8 @@ def serialize_verifying_key(vk):
Currently, NIST256P1 and ED25519 elliptic curves are supported.
Raise TypeError on unsupported key format.
"""
if isinstance(vk, ed25519.keys.VerifyingKey):
pubkey = vk.to_bytes()
if isinstance(vk, nacl.signing.VerifyKey):
pubkey = vk.encode(encoder=nacl.encoding.RawEncoder)
key_type = SSH_ED25519_KEY_TYPE
blob = util.frame(SSH_ED25519_KEY_TYPE) + util.frame(pubkey)
return key_type, blob
@@ -188,7 +219,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 u'{} {} {}\n'.format(key_type.decode('ascii'), b64, label)
return '{} {} {}\n'.format(key_type.decode('ascii'), b64, label)
def import_public_key(line):

View File

@@ -18,12 +18,12 @@ import subprocess
import sys
import time
import daemon
import pkg_resources
import semver
from . import agent, client, encode, keyring, protocol
from .. import device, formats, server, util
from . import agent, client, encode, keyring, protocol
log = logging.getLogger(__name__)
@@ -86,7 +86,8 @@ def verify_gpg_version():
required_gpg = '>=2.1.11'
msg = 'Existing GnuPG has version "{}" ({} required)'.format(existing_gpg,
required_gpg)
assert semver.match(existing_gpg, required_gpg), msg
if not semver.match(existing_gpg, required_gpg):
log.error(msg)
def check_output(args):
@@ -121,12 +122,19 @@ def run_init(device_type, args):
verify_gpg_version()
# Prepare new GPG home directory for hardware-based identity
device_name = os.path.basename(sys.argv[0]).rsplit('-', 1)[0]
device_name = device_type.package_name().rsplit('-', 1)[0]
log.info('device name: %s', device_name)
homedir = os.path.expanduser('~/.gnupg/{}'.format(device_name))
homedir = args.homedir
if not homedir:
homedir = os.path.expanduser('~/.gnupg/{}'.format(device_name))
log.info('GPG home directory: %s', homedir)
check_call(['rm', '-rf', homedir])
if os.path.exists(homedir):
log.error('GPG home directory %s exists, '
'remove it manually if required', homedir)
sys.exit(1)
check_call(['mkdir', '-p', homedir])
check_call(['chmod', '700', homedir])
@@ -134,11 +142,16 @@ def run_init(device_type, args):
# 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])
f.write(r"""#!/bin/sh
export PATH="{0}"
{1} \
-vv \
--pin-entry-binary={pin_entry_binary} \
--passphrase-entry-binary={passphrase_entry_binary} \
--cache-expiry-seconds={cache_expiry_seconds} \
$*
""".format(os.environ['PATH'], agent_path, **vars(args)))
check_call(['chmod', '700', f.name])
run_agent_script = f.name
# Prepare GPG configuration file
@@ -149,13 +162,6 @@ 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
@@ -169,25 +175,28 @@ else
${{COMMAND}}
fi
""".format(homedir))
check_call(['chmod', 'u+x', f.name])
check_call(['chmod', '700', f.name])
# Generate new GPG identity and import into GPG keyring
pubkey = write_file(os.path.join(homedir, 'pubkey.asc'),
export_public_key(device_type, args))
gpg_binary = keyring.get_gnupg_binary()
check_call([gpg_binary, '--homedir', homedir, '--quiet',
'--import', pubkey.name])
verbosity = ('-' + ('v' * args.verbose)) if args.verbose else '--quiet'
check_call(keyring.gpg_command(['--homedir', homedir, verbosity,
'--import', pubkey.name]))
# Make new GPG identity with "ultimate" trust (via its fingerprint)
out = check_output([gpg_binary, '--homedir', homedir, '--list-public-keys',
'--with-fingerprint', '--with-colons'])
out = check_output(keyring.gpg_command(['--homedir', homedir,
'--list-public-keys',
'--with-fingerprint',
'--with-colons']))
fpr = re.findall('fpr:::::::::([0-9A-F]+):', out)[0]
f = write_file(os.path.join(homedir, 'ownertrust.txt'), fpr + ':6\n')
check_call([gpg_binary, '--homedir', homedir,
'--import-ownertrust', f.name])
check_call(keyring.gpg_command(['--homedir', homedir,
'--import-ownertrust', f.name]))
# Load agent and make sure it responds with the new identity
check_call([gpg_binary, '--list-secret-keys'], env={'GNUPGHOME': homedir})
check_call(keyring.gpg_command(['--homedir', homedir,
'--list-secret-keys', args.user_id]))
def run_unlock(device_type, args):
@@ -197,30 +206,68 @@ def run_unlock(device_type, args):
log.info('unlocked %s device', d)
def _server_from_assuan_fd(env):
fd = env.get('_assuan_connection_fd')
if fd is None:
return None
log.info('using fd=%r for UNIX socket server', fd)
return server.unix_domain_socket_server_from_fd(int(fd))
def _server_from_sock_path(env):
sock_path = keyring.get_agent_sock_path(env=env)
return server.unix_domain_socket_server(sock_path)
def run_agent(device_type):
"""Run a simple GPG-agent server."""
parser = argparse.ArgumentParser()
parser.add_argument('--homedir', default=os.environ.get('GNUPGHOME'))
args, _ = parser.parse_known_args()
p = argparse.ArgumentParser()
p.add_argument('--homedir', default=os.environ.get('GNUPGHOME'))
p.add_argument('-v', '--verbose', default=0, action='count')
p.add_argument('--server', default=False, action='store_true',
help='Use stdin/stdout for communication with GPG.')
p.add_argument('--daemon', default=False, action='store_true',
help='Daemonize the agent.')
p.add_argument('--pin-entry-binary', type=str, default='pinentry',
help='Path to PIN entry UI helper.')
p.add_argument('--passphrase-entry-binary', type=str, default='pinentry',
help='Path to passphrase entry UI helper.')
p.add_argument('--cache-expiry-seconds', type=float, default=float('inf'),
help='Expire passphrase from cache after this duration.')
args, _ = p.parse_known_args()
if args.daemon:
with daemon.DaemonContext():
run_agent_internal(args, device_type)
else:
run_agent_internal(args, device_type)
def run_agent_internal(args, device_type):
"""Actually run the server."""
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)
log_file = os.path.join(args.homedir, 'gpg-agent.log')
util.setup_logging(verbosity=args.verbose, filename=log_file)
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)
env = {'GNUPGHOME': args.homedir, 'PATH': os.environ['PATH']}
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:
device_type.ui = device.ui.UI(device_type=device_type,
config=vars(args))
handler = agent.Handler(device=device_type(),
pubkey_bytes=pubkey_bytes)
sock_server = _server_from_assuan_fd(os.environ)
if sock_server is None:
sock_server = _server_from_sock_path(env)
with sock_server as sock:
for conn in agent.yield_connections(sock):
with contextlib.closing(conn):
try:
@@ -228,15 +275,21 @@ def run_agent(device_type):
except agent.AgentStop:
log.info('stopping gpg-agent')
return
except IOError as e:
log.info('connection closed: %s', e)
return
except Exception as e: # pylint: disable=broad-except
log.exception('handler failed: %s', e)
except Exception as e: # pylint: disable=broad-except
log.exception('gpg-agent failed: %s', e)
def main(device_type):
"""Parse command-line arguments."""
parser = argparse.ArgumentParser()
epilog = ('See https://github.com/romanz/trezor-agent/blob/master/'
'doc/README-GPG.md for usage examples.')
parser = argparse.ArgumentParser(epilog=epilog)
agent_package = device_type.package_name()
resources_map = {r.key: r for r in pkg_resources.require(agent_package)}
@@ -252,9 +305,20 @@ def main(device_type):
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('-t', '--time', type=int, default=0)
p.add_argument('-v', '--verbose', default=0, action='count')
p.add_argument('-s', '--subkey', default=False, action='store_true')
p.add_argument('--homedir', type=str, default=os.environ.get('GNUPGHOME'),
help='Customize GnuPG home directory for the new identity.')
p.add_argument('--pin-entry-binary', type=str, default='pinentry',
help='Path to PIN entry UI helper.')
p.add_argument('--passphrase-entry-binary', type=str, default='pinentry',
help='Path to passphrase entry UI helper.')
p.add_argument('--cache-expiry-seconds', type=float, default=float('inf'),
help='Expire passphrase from cache after this duration.')
p.set_defaults(func=run_init)
p = subparsers.add_parser('unlock', help='unlock the hardware device')
@@ -262,4 +326,6 @@ def main(device_type):
p.set_defaults(func=run_unlock)
args = parser.parse_args()
device_type.ui = device.ui.UI(device_type=device_type, config=vars(args))
return args.func(device_type=device_type, args=args)

View File

@@ -2,8 +2,8 @@
import binascii
import logging
from . import client, decode, keyring, protocol
from .. import util
from . import client, decode, keyring, protocol
log = logging.getLogger(__name__)
@@ -21,25 +21,17 @@ def yield_connections(sock):
yield conn
def serialize(data):
"""Serialize data according to ASSUAN protocol."""
for c in [b'%', b'\n', b'\r']:
escaped = '%{:02X}'.format(ord(c)).encode('ascii')
data = data.replace(c, escaped)
return data
def sig_encode(r, s):
"""Serialize ECDSA signature data into GPG S-expression."""
r = serialize(util.num2bytes(r, 32))
s = serialize(util.num2bytes(s, 32))
r = util.assuan_serialize(util.num2bytes(r, 32))
s = util.assuan_serialize(util.num2bytes(s, 32))
return b'(7:sig-val(5:ecdsa(1:r32:' + r + b')(1:s32:' + s + b')))'
def _serialize_point(data):
prefix = '{}:'.format(len(data)).encode('ascii')
# https://www.gnupg.org/documentation/manuals/assuan/Server-responses.html
return b'(5:value' + serialize(prefix + data) + b')'
return b'(5:value' + util.assuan_serialize(prefix + data) + b')'
def parse_ecdh(line):
@@ -77,38 +69,79 @@ class AgentStop(Exception):
"""Raised to close the agent."""
class Handler(object):
# pylint: disable=too-many-instance-attributes
class Handler:
"""GPG agent requests' handler."""
def _get_options(self):
return self.options
def __init__(self, device, pubkey_bytes):
"""C-tor."""
self.reset()
self.options = []
device.ui.options_getter = self._get_options
self.client = client.Client(device=device)
# Cache 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'RESET': lambda *_: self.reset(),
b'OPTION': lambda _, args: self.handle_option(*args),
b'SETKEYDESC': None,
b'NOP': None,
b'GETINFO': lambda conn, _: keyring.sendline(conn, b'D ' + self.version),
b'GETINFO': self.handle_getinfo,
b'AGENT_ID': lambda conn, _: keyring.sendline(conn, b'D TREZOR'), # "Fake" agent ID
b'SIGKEY': lambda _, args: self.set_key(*args),
b'SETKEY': lambda _, args: self.set_key(*args),
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'HAVEKEY': lambda conn, args: self.have_key(conn, *args),
b'KEYINFO': _key_info,
b'SCD': self.handle_scd,
b'GET_PASSPHRASE': self.handle_get_passphrase,
}
def reset(self):
"""Reset agent's state variables."""
self.keygrip = None
self.digest = None
self.algo = None
def handle_option(self, opt):
"""Store GPG agent-related options (e.g. for pinentry)."""
self.options.append(opt)
log.debug('options: %s', self.options)
def handle_get_passphrase(self, conn, _):
"""Allow simple GPG symmetric encryption (using a passphrase)."""
p1 = self.client.device.ui.get_passphrase('Symmetric encryption:')
p2 = self.client.device.ui.get_passphrase('Re-enter encryption:')
if p1 == p2:
result = b'D ' + util.assuan_serialize(p1.encode('ascii'))
keyring.sendline(conn, result, confidential=True)
else:
log.warning('Passphrase does not match!')
def handle_getinfo(self, conn, args):
"""Handle some of the GETINFO messages."""
result = None
if args[0] == b'version':
result = self.version
elif args[0] == b's2k_count':
# Use highest number of S2K iterations.
# https://www.gnupg.org/documentation/manuals/gnupg/OpenPGP-Options.html
# https://tools.ietf.org/html/rfc4880#section-3.7.1.3
result = '{}'.format(64 << 20).encode('ascii')
else:
log.warning('Unknown GETINFO command: %s', args)
if result:
keyring.sendline(conn, b'D ' + result)
def handle_scd(self, conn, args):
"""No support for smart-card device protocol."""
reply = {
@@ -118,7 +151,7 @@ class Handler(object):
raise AgentError(b'ERR 100696144 No such device <SCD>')
keyring.sendline(conn, b'D ' + reply)
@util.memoize
@util.memoize_method # global cache for key grips
def get_identity(self, keygrip):
"""
Returns device.interface.Identity that matches specified keygrip.
@@ -131,7 +164,7 @@ class Handler(object):
# 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)
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)
@@ -165,9 +198,16 @@ class Handler(object):
ec_point = self.client.ecdh(identity=identity, pubkey=remote_pubkey)
keyring.sendline(conn, b'D ' + _serialize_point(ec_point))
@util.memoize
def have_key(self, *keygrips):
def have_key(self, conn, *keygrips):
"""Check if any keygrip corresponds to a TREZOR-based key."""
if len(keygrips) == 1 and keygrips[0].startswith(b"--list="):
# Support "fast-path" key listing:
# https://dev.gnupg.org/rG40da61b89b62dcb77847dc79eb159e885f52f817
keygrips = list(decode.iter_keygrips(pubkey_bytes=self.pubkey_bytes))
log.debug('keygrips: %r', keygrips)
keyring.sendline(conn, b'D ' + util.assuan_serialize(b''.join(keygrips)))
return
for keygrip in keygrips:
try:
self.get_identity(keygrip=keygrip)

View File

@@ -15,7 +15,7 @@ def create_identity(user_id, curve_name):
return result
class Client(object):
class Client:
"""Sign messages and get public keys from a hardware device."""
def __init__(self, device):
@@ -25,9 +25,7 @@ class Client(object):
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)
return self.device.pubkey(ecdh=ecdh, identity=identity)
def sign(self, identity, digest):
"""Sign the digest and return a serialized signature."""

View File

@@ -7,10 +7,10 @@ import logging
import struct
import ecdsa
import ed25519
import nacl.signing
from . import protocol
from .. import util
from . import protocol
log = logging.getLogger(__name__)
@@ -67,7 +67,8 @@ def _parse_ed25519_pubkey(mpi):
prefix, value = util.split_bits(mpi, 8, 256)
if prefix != 0x40:
raise ValueError('Invalid MPI prefix: {}'.format(prefix))
return ed25519.VerifyingKey(util.num2bytes(value, size=32))
vk = nacl.signing.VerifyKey(util.num2bytes(value, size=32), encoder=nacl.encoding.RawEncoder)
return vk
SUPPORTED_CURVES = {
@@ -281,18 +282,20 @@ HASH_ALGORITHMS = {
}
def load_by_keygrip(pubkey_bytes, keygrip):
"""Return public key and first user ID for specified keygrip."""
def _parse_pubkey_packets(pubkey_bytes):
stream = io.BytesIO(pubkey_bytes)
packets = list(parse_packets(stream))
packets_per_pubkey = []
for p in packets:
for p in parse_packets(stream):
if p['type'] == 'pubkey':
# Add a new packet list for each pubkey.
packets_per_pubkey.append([])
packets_per_pubkey[-1].append(p)
return packets_per_pubkey
for packets in packets_per_pubkey:
def load_by_keygrip(pubkey_bytes, keygrip):
"""Return public key and first user ID for specified keygrip."""
for packets in _parse_pubkey_packets(pubkey_bytes):
user_ids = [p for p in packets if p['type'] == 'user_id']
for p in packets:
if p.get('keygrip') == keygrip:
@@ -300,6 +303,15 @@ def load_by_keygrip(pubkey_bytes, keygrip):
raise KeyError('{} keygrip not found'.format(util.hexlify(keygrip)))
def iter_keygrips(pubkey_bytes):
"""Iterate over all keygrips in this pubkey."""
for packets in _parse_pubkey_packets(pubkey_bytes):
for p in packets:
keygrip = p.get('keygrip')
if keygrip:
yield keygrip
def load_signature(stream, original_data):
"""Load signature from stream, and compute GPG digest for verification."""
signature, = list(parse_packets((stream)))

View File

@@ -2,8 +2,8 @@
import io
import logging
from . import decode, keyring, protocol
from .. import util
from . import decode, keyring, protocol
log = logging.getLogger(__name__)
@@ -11,7 +11,7 @@ log = logging.getLogger(__name__)
def create_primary(user_id, pubkey, signer_func, secret_bytes=b''):
"""Export new primary GPG public key, ready for "gpg2 --import"."""
pubkey_packet = protocol.packet(tag=(5 if secret_bytes else 6),
blob=(pubkey.data() + secret_bytes))
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] +
@@ -51,7 +51,7 @@ def create_primary(user_id, pubkey, signer_func, secret_bytes=b''):
def create_subkey(primary_bytes, subkey, signer_func, secret_bytes=b''):
"""Export new subkey to GPG primary key."""
subkey_packet = protocol.packet(tag=(7 if secret_bytes else 14),
blob=(subkey.data() + secret_bytes))
blob=subkey.data() + secret_bytes)
packets = list(decode.parse_packets(io.BytesIO(primary_bytes)))
primary, user_id, signature = packets[:3]

View File

@@ -17,8 +17,11 @@ log = logging.getLogger(__name__)
def check_output(args, env=None, sp=subprocess):
"""Call an external binary and return its stdout."""
log.debug('calling %s with env %s', args, env)
output = sp.check_output(args=args, env=env)
p = sp.Popen(args=args, env=env, stdin=sp.PIPE, stdout=sp.PIPE, stderr=sp.PIPE)
(output, error) = p.communicate()
log.debug('output: %r', output)
if error:
log.debug('error: %r', error)
return output
@@ -48,9 +51,9 @@ def communicate(sock, msg):
return recvline(sock)
def sendline(sock, msg):
def sendline(sock, msg, confidential=False):
"""Send a binary message, followed by EOL."""
log.debug('<- %r', msg)
log.debug('<- %r', ('<snip>' if confidential else msg))
sock.sendall(msg + b'\n')
@@ -173,9 +176,7 @@ def sign_digest(sock, keygrip, digest, sp=subprocess, environ=None):
assert communicate(sock, 'PKSIGN') == b'OK'
while True:
line = recvline(sock).strip()
if line.startswith(b'S PROGRESS'):
continue
else:
if not line.startswith(b'S PROGRESS'):
break
line = unescape(line)
log.debug('unescaped: %r', line)
@@ -198,8 +199,10 @@ def get_gnupg_components(sp=subprocess):
@util.memoize
def get_gnupg_binary(sp=subprocess):
def get_gnupg_binary(sp=subprocess, neopg_binary=None):
"""Starting GnuPG 2.2.x, the default installation uses `gpg`."""
if neopg_binary:
return neopg_binary
return get_gnupg_components(sp=sp)['gpg']
@@ -207,11 +210,8 @@ def gpg_command(args, env=None):
"""Prepare common GPG command line arguments."""
if env is None:
env = os.environ
cmd = [get_gnupg_binary()]
homedir = env.get('GNUPGHOME')
if homedir:
cmd.extend(['--homedir', homedir])
return cmd + args
cmd = get_gnupg_binary(neopg_binary=env.get('NEOPG_BINARY'))
return [cmd] + args
def get_keygrip(user_id, sp=subprocess):
@@ -225,8 +225,10 @@ def gpg_version(sp=subprocess):
"""Get a keygrip of the primary GPG key of the specified user."""
args = gpg_command(['--version'])
output = check_output(args=args, sp=sp)
line = output.split(b'\n')[0] # b'gpg (GnuPG) 2.1.11'
return line.split(b' ')[-1] # b'2.1.11'
line = output.split(b'\n', maxsplit=1)[0] # b'gpg (GnuPG) 2.1.11'
line = line.split(b' ')[-1] # b'2.1.11'
line = line.split(b'-')[0] # remove trailing version parts
return line.split(b'v')[-1] # remove 'v' prefix
def export_public_key(user_id, env=None, sp=subprocess):

View File

@@ -5,6 +5,8 @@ import hashlib
import logging
import struct
import nacl.signing
from .. import formats, util
log = logging.getLogger(__name__)
@@ -92,7 +94,7 @@ def _serialize_nist256(vk):
def _serialize_ed25519(vk):
return mpi((0x40 << 256) |
util.bytes2num(vk.to_bytes()))
util.bytes2num(vk.encode(encoder=nacl.encoding.RawEncoder)))
def _compute_keygrip(params):
@@ -131,7 +133,7 @@ def keygrip_ed25519(vk):
['b', util.num2bytes(0x2DFC9311D490018C7338BF8688861767FF8FF5B2BEBE27548A14B235ECA6874A, size=32)], # nopep8
['g', util.num2bytes(0x04216936D3CD6E53FEC0A4E231FDD6DC5C692CC7609525A7B2C9562D608F25D51A6666666666666666666666666666666666666666666666666666666666666658, size=65)], # nopep8
['n', util.num2bytes(0x1000000000000000000000000000000014DEF9DEA2F79CD65812631A5CF5D3ED, size=32)], # nopep8
['q', vk.to_bytes()],
['q', vk.encode(encoder=nacl.encoding.RawEncoder)],
])
@@ -144,7 +146,7 @@ def keygrip_curve25519(vk):
['b', b'\x01'],
['g', util.num2bytes(0x04000000000000000000000000000000000000000000000000000000000000000920ae19a1b8a086b4e01edd2c7748d14c923d4d7e6d7c61b229e9c5a27eced3d9, size=65)], # nopep8
['n', util.num2bytes(0x1000000000000000000000000000000014DEF9DEA2F79CD65812631A5CF5D3ED, size=32)], # nopep8
['q', vk.to_bytes()],
['q', vk.encode(encoder=nacl.encoding.RawEncoder)],
])
@@ -185,7 +187,7 @@ def get_curve_name_by_oid(oid):
raise KeyError('Unknown OID: {!r}'.format(oid))
class PublicKey(object):
class PublicKey:
"""GPG representation for public key packets."""
def __init__(self, curve_name, created, verifying_key, ecdh=False):

Binary file not shown.

View File

@@ -0,0 +1,11 @@
from .. import agent
def test_sig_encode():
SIG = (
b'(7:sig-val(5:ecdsa(1:r32:\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00'
b'\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00'
b'\x00\x00\x00\x00\x0c)(1:s32:\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00'
b'\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00'
b'\x00\x00\x00\x00")))')
assert agent.sig_encode(12, 34) == SIG

View File

@@ -1,11 +1,10 @@
import glob
import io
import os
import pathlib
import pytest
from .. import decode, protocol
from ... import util
from .. import decode, protocol
def test_subpackets():
@@ -30,8 +29,8 @@ def test_mpi():
assert decode.parse_mpis(util.Reader(s), n=2) == [0x123, 5]
cwd = os.path.join(os.path.dirname(__file__))
input_files = glob.glob(os.path.join(cwd, '*.gpg'))
cwd = pathlib.Path(__file__).parent
input_files = cwd.glob('*.gpg')
@pytest.fixture(params=input_files)
@@ -60,3 +59,20 @@ def test_has_custom_subpacket():
def test_load_by_keygrip_missing():
with pytest.raises(KeyError):
decode.load_by_keygrip(pubkey_bytes=b'', keygrip=b'')
def test_keygrips():
pubkey_bytes = (cwd / "romanz-pubkey.gpg").open("rb").read()
keygrips = list(decode.iter_keygrips(pubkey_bytes))
assert [k.hex() for k in keygrips] == [
'7b2497258d76bc6539ed88d018cd1c739e2dbb6c',
'30ae97f3d8e0e34c5ed80e1715fd442ca24c0a8e',
]
for keygrip in keygrips:
pubkey_dict, user_ids = decode.load_by_keygrip(pubkey_bytes, keygrip)
assert pubkey_dict['keygrip'] == keygrip
assert [u['value'] for u in user_ids] == [
b'Roman Zeyde <roman.zeyde@gmail.com>',
b'Roman Zeyde <me@romanzey.de>',
]

View File

@@ -41,7 +41,7 @@ def test_parse_rsa():
assert keyring.parse_sig(sig) == (0x1020304,)
class FakeSocket(object):
class FakeSocket:
def __init__(self):
self.rx = io.BytesIO()
self.tx = io.BytesIO()
@@ -53,6 +53,14 @@ class FakeSocket(object):
self.tx.write(data)
def mock_subprocess(output, error=b''):
sp = mock.Mock(spec=['Popen', 'PIPE'])
p = mock.Mock(spec=['communicate'])
sp.Popen.return_value = p
p.communicate.return_value = (output, error)
return sp
def test_sign_digest():
sock = FakeSocket()
sock.rx.write(b'OK Pleased to meet you, process XYZ\n')
@@ -61,10 +69,8 @@ def test_sign_digest():
sock.rx.seek(0)
keygrip = '1234'
digest = b'A' * 32
sp = mock.Mock(spec=['check_output'])
sp.check_output.return_value = '/dev/pts/0'
sig = keyring.sign_digest(sock=sock, keygrip=keygrip,
digest=digest, sp=sp,
digest=digest, sp=mock_subprocess('/dev/pts/0'),
environ={'DISPLAY': ':0'})
assert sig == (0x30313233343536373839414243444546,)
assert sock.tx.getvalue() == b'''RESET
@@ -85,8 +91,7 @@ def test_iterlines():
def test_get_agent_sock_path():
sp = mock.Mock(spec=['check_output'])
sp.check_output.return_value = b'''sysconfdir:/usr/local/etc/gnupg
sp = mock_subprocess(b'''sysconfdir:/usr/local/etc/gnupg
bindir:/usr/local/bin
libexecdir:/usr/local/libexec
libdir:/usr/local/lib/gnupg
@@ -96,6 +101,6 @@ 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,9 +1,9 @@
import ecdsa
import ed25519
import nacl.signing
import pytest
from .. import protocol
from ... import formats
from .. import protocol
def test_packet():
@@ -83,8 +83,8 @@ def test_nist256p1_ecdh():
def test_ed25519():
sk = ed25519.SigningKey(b'\x00' * 32)
vk = sk.get_verifying_key()
sk = nacl.signing.SigningKey(b'\x00'*32, encoder=nacl.encoding.RawEncoder)
vk = sk.verify_key
pk = protocol.PublicKey(curve_name=formats.CURVE_ED25519,
created=42, verifying_key=vk)
assert repr(pk) == 'GPG public key ed25519/36B40FE6'
@@ -92,8 +92,8 @@ def test_ed25519():
def test_curve25519():
sk = ed25519.SigningKey(b'\x00' * 32)
vk = sk.get_verifying_key()
sk = nacl.signing.SigningKey(b'\x00'*32, encoder=nacl.encoding.RawEncoder)
vk = sk.verify_key
pk = protocol.PublicKey(curve_name=formats.ECDH_CURVE25519,
created=42, verifying_key=vk)
assert repr(pk) == 'GPG public key curve25519/69460384'

View File

@@ -39,6 +39,43 @@ def unix_domain_socket_server(sock_path):
remove_file(sock_path)
class FDServer:
"""File-descriptor based server (for NeoPG)."""
def __init__(self, fd):
"""C-tor."""
self.fd = fd
self.sock = socket.fromfd(fd, socket.AF_UNIX, socket.SOCK_STREAM)
def accept(self):
"""Use the same socket for I/O."""
return self, None
def recv(self, n):
"""Forward to underlying socket."""
return self.sock.recv(n)
def sendall(self, data):
"""Forward to underlying socket."""
return self.sock.sendall(data)
def close(self):
"""Not needed."""
def settimeout(self, _):
"""Not needed."""
def getsockname(self):
"""Simple representation."""
return '<fd: {}>'.format(self.fd)
@contextlib.contextmanager
def unix_domain_socket_server_from_fd(fd):
"""Build UDS-based socket server from a file descriptor."""
yield FDServer(fd)
def handle_connection(conn, handler, mutex):
"""
Handle a single connection using the specified protocol handler in a loop.
@@ -97,7 +134,7 @@ def server_thread(sock, handle_conn, quit_event):
break
# Handle connections from SSH concurrently.
threading.Thread(target=handle_conn,
kwargs=dict(conn=conn)).start()
kwargs={'conn': conn}).start()
log.debug('server thread stopped')
@@ -122,7 +159,7 @@ def run_process(command, environ):
try:
p = subprocess.Popen(args=command, env=env)
except OSError as e:
raise OSError('cannot run %r: %s' % (command, e))
raise OSError('cannot run %r: %s' % (command, e)) from e
log.debug('subprocess %d is running', p.pid)
ret = p.wait()
log.debug('subprocess %d exited: %d', p.pid, ret)

View File

@@ -0,0 +1,119 @@
"""TREZOR support for Ed25519 signify/minisign signatures."""
import argparse
import binascii
import hashlib
import logging
import sys
import time
from .. import util
from ..device import interface, ui
log = logging.getLogger(__name__)
def _create_identity(user_id):
result = interface.Identity(identity_str='signify://', curve_name='ed25519')
result.identity_dict['host'] = user_id
return result
class Client:
"""Sign messages and get public keys from a hardware device."""
def __init__(self, device):
"""C-tor."""
self.device = device
def pubkey(self, identity):
"""Return public key as VerifyingKey object."""
with self.device:
return bytes(self.device.pubkey(ecdh=False, identity=identity))
def sign_with_pubkey(self, identity, data):
"""Sign the data and return a signature."""
log.info('please confirm Signify signature on %s for "%s"...',
self.device, identity.to_string())
log.debug('signing data: %s', util.hexlify(data))
with self.device:
sig, pubkey = self.device.sign_with_pubkey(blob=data, identity=identity)
assert len(sig) == 64
assert len(pubkey) == 33
assert pubkey[:1] == b"\x00"
return sig, pubkey[1:]
ALG_SIGNIFY = b'Ed'
ALG_MINISIGN = b'ED' # prehashes the data before signing
def format_payload(pubkey, data, sig_alg):
"""See http://www.openbsd.org/papers/bsdcan-signify.html for details."""
keynum = hashlib.sha256(pubkey).digest()[:8]
return binascii.b2a_base64(sig_alg + keynum + data).decode("ascii")
def run_pubkey(device_type, args):
"""Export hardware-based Signify public key."""
util.setup_logging(verbosity=args.verbose)
log.warning('This Signify tool is still in EXPERIMENTAL mode, '
'so please note that the key derivation, API, and features '
'may change without backwards compatibility!')
identity = _create_identity(user_id=args.user_id)
pubkey = Client(device=device_type()).pubkey(identity=identity)
comment = f'untrusted comment: identity {identity.to_string()}\n'
payload = format_payload(pubkey=pubkey, data=pubkey, sig_alg=ALG_SIGNIFY)
print(comment + payload, end="")
def run_sign(device_type, args):
"""Prehash & sign an input blob using Ed25519."""
util.setup_logging(verbosity=args.verbose)
identity = _create_identity(user_id=args.user_id)
data_to_sign = sys.stdin.buffer.read()
sig_alg = ALG_SIGNIFY
if args.prehash:
# See https://github.com/jedisct1/minisign/commit/6e1023d20758b6fdb2a4b697213b0bf608ba4020
# Released in https://github.com/jedisct1/minisign/releases/tag/0.6
sig_alg = ALG_MINISIGN
data_to_sign = hashlib.blake2b(data_to_sign).digest()
sig, pubkey = Client(device=device_type()).sign_with_pubkey(identity, data_to_sign)
pubkey_str = format_payload(pubkey=pubkey, data=pubkey, sig_alg=sig_alg)
sig_str = format_payload(pubkey=pubkey, data=sig, sig_alg=sig_alg)
untrusted_comment = f'untrusted comment: pubkey {pubkey_str}'
print(untrusted_comment + sig_str, end="")
comment_to_sign = sig + args.comment.encode()
sig, _ = Client(device=device_type()).sign_with_pubkey(identity, comment_to_sign)
sig_str = binascii.b2a_base64(sig).decode("ascii")
print(f'trusted comment: {args.comment}\n' + sig_str, end="")
def main(device_type):
"""Parse command-line arguments."""
parser = argparse.ArgumentParser()
subparsers = parser.add_subparsers(title='Action', dest='action')
subparsers.required = True
p = subparsers.add_parser('pubkey')
p.add_argument('user_id')
p.add_argument('-v', '--verbose', default=0, action='count')
p.set_defaults(func=run_pubkey)
p = subparsers.add_parser('sign')
p.add_argument('user_id')
p.add_argument('-v', '--verbose', default=0, action='count')
p.add_argument('-c', '--comment', default=time.asctime())
p.add_argument('-H', '--prehash', default=False, action='store_true')
p.set_defaults(func=run_sign)
args = parser.parse_args()
device_type.ui = ui.UI(device_type=device_type, config=vars(args))
device_type.ui.cached_passphrase_ack = util.ExpiringCache(seconds=float(60))
return args.func(device_type=device_type, args=args)

View File

@@ -11,9 +11,9 @@ import sys
import tempfile
import threading
import pkg_resources
import configargparse
import daemon
import pkg_resources
from .. import device, formats, server, util
from . import client, protocol
@@ -23,9 +23,11 @@ log = logging.getLogger(__name__)
UNIX_SOCKET_TIMEOUT = 0.1
def ssh_args(label):
def ssh_args(conn):
"""Create SSH command for connecting specified server."""
identity = device.interface.string_to_identity(label)
I, = conn.identities
identity = I.identity_dict
pubkey_tempfile, = conn.public_keys_as_files()
args = []
if 'port' in identity:
@@ -33,12 +35,15 @@ def ssh_args(label):
if 'user' in identity:
args += ['-l', identity['user']]
args += ['-o', 'IdentityFile={}'.format(pubkey_tempfile.name)]
args += ['-o', 'IdentitiesOnly=true']
return args + [identity['host']]
def mosh_args(label):
def mosh_args(conn):
"""Create SSH command for connecting specified server."""
identity = device.interface.string_to_identity(label)
I, = conn.identities
identity = I.identity_dict
args = []
if 'port' in identity:
@@ -60,7 +65,10 @@ def _to_unicode(s):
def create_agent_parser(device_type):
"""Create an ArgumentParser for this tool."""
p = configargparse.ArgParser(default_config_files=['~/.ssh/agent.config'])
epilog = ('See https://github.com/romanz/trezor-agent/blob/master/'
'doc/README-SSH.md for usage examples.')
p = configargparse.ArgParser(default_config_files=['~/.ssh/agent.config'],
epilog=epilog)
p.add_argument('-v', '--verbose', default=0, action='count')
agent_package = device_type.package_name()
@@ -70,8 +78,7 @@ def create_agent_parser(device_type):
p.add_argument('--version', help='print the version info',
action='version', version=versions)
curve_names = [name for name in formats.SUPPORTED_CURVES]
curve_names = ', '.join(sorted(curve_names))
curve_names = ', '.join(sorted(formats.SUPPORTED_CURVES))
p.add_argument('-e', '--ecdsa-curve-name', metavar='CURVE',
default=formats.CURVE_NIST256,
help='specify ECDSA curve name: ' + curve_names)
@@ -85,9 +92,18 @@ def create_agent_parser(device_type):
p.add_argument('--sock-path', type=str,
help='Path to the UNIX domain socket of the agent.')
p.add_argument('--pin-entry-binary', type=str, default='pinentry',
help='Path to PIN entry UI helper.')
p.add_argument('--passphrase-entry-binary', type=str, default='pinentry',
help='Path to passphrase entry UI helper.')
p.add_argument('--cache-expiry-seconds', type=float, default=float('inf'),
help='Expire passphrase from cache after this duration.')
g = p.add_mutually_exclusive_group()
g.add_argument('-d', '--daemonize', default=False, action='store_true',
help='Daemonize the agent and print its UNIX socket path')
g.add_argument('-f', '--foreground', default=False, action='store_true',
help='Run agent in foreground with specified UNIX socket path')
g.add_argument('-s', '--shell', default=False, action='store_true',
help=('run ${SHELL} as subprocess under SSH agent, allowing '
'regular SSH-based tools to be used in the shell'))
@@ -122,9 +138,9 @@ def serve(handler, sock_path, timeout=UNIX_SOCKET_TIMEOUT):
handle_conn = functools.partial(server.handle_connection,
handler=handler,
mutex=device_mutex)
kwargs = dict(sock=sock,
handle_conn=handle_conn,
quit_event=quit_event)
kwargs = {'sock': sock,
'handle_conn': handle_conn,
'quit_event': quit_event}
with server.spawn(server.server_thread, kwargs):
try:
yield environ
@@ -176,7 +192,7 @@ def import_public_keys(contents):
yield line
class JustInTimeConnection(object):
class JustInTimeConnection:
"""Connect to the device just before the needed operation."""
def __init__(self, conn_factory, identities, public_keys=None):
@@ -184,6 +200,7 @@ class JustInTimeConnection(object):
self.conn_factory = conn_factory
self.identities = identities
self.public_keys_cache = public_keys
self.public_keys_tempfiles = []
def public_keys(self):
"""Return a list of SSH public keys (in textual format)."""
@@ -200,6 +217,17 @@ class JustInTimeConnection(object):
pk['identity'] = identity
return public_keys
def public_keys_as_files(self):
"""Store public keys as temporary SSH identity files."""
if not self.public_keys_tempfiles:
for pk in self.public_keys():
f = tempfile.NamedTemporaryFile(prefix='trezor-ssh-pubkey-', mode='w')
f.write(pk)
f.flush()
self.public_keys_tempfiles.append(f)
return self.public_keys_tempfiles
def sign(self, blob, identity):
"""Sign a given blob using the specified identity on the device."""
conn = self.conn_factory()
@@ -211,6 +239,17 @@ def _dummy_context():
yield
def _get_sock_path(args):
sock_path = args.sock_path
if not sock_path:
if args.foreground:
log.error('running in foreground mode requires specifying UNIX socket path')
sys.exit(1)
else:
sock_path = tempfile.mktemp(prefix='trezor-ssh-agent-')
return sock_path
@handle_connection_error
def main(device_type):
"""Run ssh-agent using given hardware client factory."""
@@ -218,6 +257,7 @@ def main(device_type):
util.setup_logging(verbosity=args.verbose, filename=args.log_file)
public_keys = None
filename = None
if args.identity.startswith('/'):
filename = args.identity
contents = open(filename, 'rb').read().decode('utf-8')
@@ -229,36 +269,38 @@ def main(device_type):
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'
identity.identity_dict['proto'] = 'ssh'
log.info('identity #%d: %s', index, identity.to_string())
sock_path = args.sock_path
if not sock_path:
sock_path = tempfile.mktemp(prefix='trezor-ssh-agent-')
# override default PIN/passphrase entry tools (relevant for TREZOR/Keepkey):
device_type.ui = device.ui.UI(device_type=device_type, config=vars(args))
conn = JustInTimeConnection(
conn_factory=lambda: client.Client(device_type()),
identities=identities, public_keys=public_keys)
sock_path = _get_sock_path(args)
command = args.command
context = _dummy_context()
if args.connect:
command = ['ssh'] + ssh_args(args.identity) + args.command
command = ['ssh'] + ssh_args(conn) + args.command
elif args.mosh:
command = ['mosh'] + mosh_args(args.identity) + args.command
command = ['mosh'] + mosh_args(conn) + args.command
elif args.daemonize:
out = 'SSH_AUTH_SOCK={0}; export SSH_AUTH_SOCK;\n'.format(sock_path)
sys.stdout.write(out)
sys.stdout.flush()
context = daemon.DaemonContext()
log.info('running the agent as a daemon on %s', sock_path)
elif args.foreground:
log.info('running the agent on %s', sock_path)
use_shell = bool(args.shell)
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 or args.daemonize:
if command or args.daemonize or args.foreground:
with context:
return run_server(conn=conn, command=command, sock_path=sock_path,
debug=args.debug, timeout=args.timeout)

View File

@@ -11,7 +11,7 @@ from . import formats, util
log = logging.getLogger(__name__)
class Client(object):
class Client:
"""Client wrapper for SSH authentication device."""
def __init__(self, device):
@@ -20,46 +20,63 @@ class Client(object):
def export_public_keys(self, identities):
"""Export SSH public keys from the device."""
public_keys = []
pubkeys = []
with self.device:
for i in identities:
pubkey = self.device.pubkey(identity=i)
vk = formats.decompress_pubkey(pubkey=pubkey,
curve_name=i.curve_name)
public_key = formats.export_public_key(vk=vk,
label=i.to_string())
public_keys.append(public_key)
return public_keys
vk = self.device.pubkey(identity=i)
label = i.to_string()
pubkey = formats.export_public_key(vk=vk, label=label)
pubkeys.append(pubkey)
return pubkeys
def sign_ssh_challenge(self, blob, identity):
"""Sign given blob using a private key on the device."""
msg = _parse_ssh_blob(blob)
log.debug('%s: user %r via %r (%r)',
msg['conn'], msg['user'], msg['auth'], msg['key_type'])
log.debug('nonce: %r', msg['nonce'])
fp = msg['public_key']['fingerprint']
log.debug('fingerprint: %s', fp)
log.debug('hidden challenge size: %d bytes', len(blob))
log.debug('blob: %r', blob)
msg = parse_ssh_blob(blob)
if msg['sshsig']:
log.info('please confirm "%s" signature for "%s" using %s...',
msg['namespace'], identity.to_string(), self.device)
else:
log.debug('%s: user %r via %r (%r)',
msg['conn'], msg['user'], msg['auth'], msg['key_type'])
log.debug('nonce: %r', msg['nonce'])
fp = msg['public_key']['fingerprint']
log.debug('fingerprint: %s', fp)
log.debug('hidden challenge size: %d bytes', len(blob))
log.info('please confirm user "%s" login to "%s" using %s...',
msg['user'].decode('ascii'), identity.to_string(),
self.device)
log.info('please confirm user "%s" login to "%s" using %s...',
msg['user'].decode('ascii'), identity.to_string(),
self.device)
with self.device:
return self.device.sign(blob=blob, identity=identity)
def _parse_ssh_blob(data):
def parse_ssh_blob(data):
"""Parse binary data into a dict."""
res = {}
i = io.BytesIO(data)
res['nonce'] = util.read_frame(i)
i.read(1) # SSH2_MSG_USERAUTH_REQUEST == 50 (from ssh2.h, line 108)
res['user'] = util.read_frame(i)
res['conn'] = util.read_frame(i)
res['auth'] = util.read_frame(i)
i.read(1) # have_sig == 1 (from sshconnect2.c, line 1056)
res['key_type'] = util.read_frame(i)
public_key = util.read_frame(i)
res['public_key'] = formats.parse_pubkey(public_key)
assert not i.read()
if data.startswith(b'SSHSIG'):
i = io.BytesIO(data[6:])
# https://github.com/openssh/openssh-portable/blob/master/PROTOCOL.sshsig
res['sshsig'] = True
res['namespace'] = util.read_frame(i)
res['reserved'] = util.read_frame(i)
res['hashalg'] = util.read_frame(i)
res['message'] = util.read_frame(i)
else:
i = io.BytesIO(data)
res['sshsig'] = False
res['nonce'] = util.read_frame(i)
i.read(1) # SSH2_MSG_USERAUTH_REQUEST == 50 (from ssh2.h, line 108)
res['user'] = util.read_frame(i)
res['conn'] = util.read_frame(i)
res['auth'] = util.read_frame(i)
i.read(1) # have_sig == 1 (from sshconnect2.c, line 1056)
res['key_type'] = util.read_frame(i)
public_key = util.read_frame(i)
res['public_key'] = formats.parse_pubkey(public_key)
unparsed = i.read()
if unparsed:
log.warning('unparsed blob: %r', unparsed)
return res

View File

@@ -16,31 +16,33 @@ log = logging.getLogger(__name__)
# Taken from https://github.com/openssh/openssh-portable/blob/master/authfd.h
COMMANDS = dict(
SSH_AGENTC_REQUEST_RSA_IDENTITIES=1,
SSH_AGENT_RSA_IDENTITIES_ANSWER=2,
SSH_AGENTC_RSA_CHALLENGE=3,
SSH_AGENT_RSA_RESPONSE=4,
SSH_AGENT_FAILURE=5,
SSH_AGENT_SUCCESS=6,
SSH_AGENTC_ADD_RSA_IDENTITY=7,
SSH_AGENTC_REMOVE_RSA_IDENTITY=8,
SSH_AGENTC_REMOVE_ALL_RSA_IDENTITIES=9,
SSH2_AGENTC_REQUEST_IDENTITIES=11,
SSH2_AGENT_IDENTITIES_ANSWER=12,
SSH2_AGENTC_SIGN_REQUEST=13,
SSH2_AGENT_SIGN_RESPONSE=14,
SSH2_AGENTC_ADD_IDENTITY=17,
SSH2_AGENTC_REMOVE_IDENTITY=18,
SSH2_AGENTC_REMOVE_ALL_IDENTITIES=19,
SSH_AGENTC_ADD_SMARTCARD_KEY=20,
SSH_AGENTC_REMOVE_SMARTCARD_KEY=21,
SSH_AGENTC_LOCK=22,
SSH_AGENTC_UNLOCK=23,
SSH_AGENTC_ADD_RSA_ID_CONSTRAINED=24,
SSH2_AGENTC_ADD_ID_CONSTRAINED=25,
SSH_AGENTC_ADD_SMARTCARD_KEY_CONSTRAINED=26,
)
COMMANDS = {
"SSH_AGENTC_REQUEST_RSA_IDENTITIES": 1,
"SSH_AGENT_RSA_IDENTITIES_ANSWER": 2,
"SSH_AGENTC_RSA_CHALLENGE": 3,
"SSH_AGENT_RSA_RESPONSE": 4,
"SSH_AGENT_FAILURE": 5,
"SSH_AGENT_SUCCESS": 6,
"SSH_AGENTC_ADD_RSA_IDENTITY": 7,
"SSH_AGENTC_REMOVE_RSA_IDENTITY": 8,
"SSH_AGENTC_REMOVE_ALL_RSA_IDENTITIES": 9,
"SSH2_AGENTC_REQUEST_IDENTITIES": 11,
"SSH2_AGENT_IDENTITIES_ANSWER": 12,
"SSH2_AGENTC_SIGN_REQUEST": 13,
"SSH2_AGENT_SIGN_RESPONSE": 14,
"SSH2_AGENTC_ADD_IDENTITY": 17,
"SSH2_AGENTC_REMOVE_IDENTITY": 18,
"SSH2_AGENTC_REMOVE_ALL_IDENTITIES": 19,
"SSH_AGENTC_ADD_SMARTCARD_KEY": 20,
"SSH_AGENTC_REMOVE_SMARTCARD_KEY": 21,
"SSH_AGENTC_LOCK": 22,
"SSH_AGENTC_UNLOCK": 23,
"SSH_AGENTC_ADD_RSA_ID_CONSTRAINED": 24,
"SSH2_AGENTC_ADD_ID_CONSTRAINED": 25,
"SSH_AGENTC_ADD_SMARTCARD_KEY_CONSTRAINED": 26,
"SSH_AGENTC_EXTENSION": 27,
"SSH_AGENT_EXTENSION_FAILURE": 28,
}
def msg_code(name):
@@ -70,7 +72,7 @@ def _legacy_pubs(buf):
return util.frame(code, num)
class Handler(object):
class Handler:
"""ssh-agent protocol handler."""
def __init__(self, conn, debug=False):
@@ -86,6 +88,7 @@ class Handler(object):
msg_code('SSH_AGENTC_REQUEST_RSA_IDENTITIES'): _legacy_pubs,
msg_code('SSH2_AGENTC_REQUEST_IDENTITIES'): self.list_pubs,
msg_code('SSH2_AGENTC_SIGN_REQUEST'): self.sign_message,
msg_code('SSH_AGENTC_EXTENSION'): _unsupported_extension,
}
def handle(self, msg):
@@ -144,17 +147,26 @@ class Handler(object):
signature = self.conn.sign(blob=blob, identity=key['identity'])
except IOError:
return failure()
except Exception:
log.exception('signature with "%s" key failed', label)
raise
log.debug('signature: %r', signature)
try:
sig_bytes = key['verifier'](sig=signature, msg=blob)
log.info('signature status: OK')
except formats.ecdsa.BadSignatureError:
except formats.ecdsa.BadSignatureError as e:
log.exception('signature status: ERROR')
raise ValueError('invalid ECDSA signature')
raise ValueError('invalid ECDSA signature') from e
log.debug('signature size: %d bytes', len(sig_bytes))
data = util.frame(util.frame(key['type']), util.frame(sig_bytes))
code = util.pack('B', msg_code('SSH2_AGENT_SIGN_RESPONSE'))
return util.frame(code, data)
def _unsupported_extension(buf): # pylint: disable=unused-argument
code = util.pack('B', msg_code('SSH_AGENT_EXTENSION_FAILURE'))
return util.frame(code)

View File

@@ -17,12 +17,16 @@ PUBKEY_TEXT = ('ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzd'
class MockDevice(device.interface.Device): # pylint: disable=abstract-method
def connect(self): # pylint: disable=no-self-use
@classmethod
def package_name(cls):
return 'fake-device-agent'
def connect(self):
return mock.Mock()
def pubkey(self, identity, ecdh=False): # pylint: disable=unused-argument
assert self.conn
return PUBKEY
return formats.decompress_pubkey(pubkey=PUBKEY, curve_name=identity.curve_name)
def sign(self, identity, blob):
"""Sign given blob and return the signature (as bytes)."""
@@ -70,3 +74,52 @@ def test_ssh_agent():
c.device.sign = cancel_sign
with pytest.raises(IOError):
c.sign_ssh_challenge(blob=BLOB, identity=identity)
CHALLENGE_BLOB = (
b'\x00\x00\x00 \xe4\x08\x8e"J#\x83 \x05\x90\x1e\xa9\xf9C\xb1\xd2\x8f\xc3\x8c\xea\xd8\xf6E'
b'%q\xff\x07\xfa\xd8\x8b\xdf\xbd2\x00\x00\x00\x03git\x00\x00\x00\x0essh-connection\x00\x00'
b'\x00\tpublickey\x01\x00\x00\x00\x0bssh-ed25519\x00\x00\x003\x00\x00\x00\x0bssh-ed25519'
b'\x00\x00\x00 \xd1q\x1ab\xc6\xf0d\x19\xe2q<\x05\x0b\xdao\xa1\xcb\xae\xad\xc9\x0b\x16\xf3'
b'\xc2m\x84q8qU\xda\xb0'
)
def test_parse_ssh_challenge():
result = client.parse_ssh_blob(CHALLENGE_BLOB)
result['public_key'].pop('verifier')
assert result == {
'auth': b'publickey',
'conn': b'ssh-connection',
'key_type': b'ssh-ed25519',
'nonce': b'\xe4\x08\x8e"J#\x83 \x05\x90\x1e\xa9\xf9C\xb1\xd2\x8f\xc3\x8c\xea'
b'\xd8\xf6E%q\xff\x07\xfa\xd8\x8b\xdf\xbd',
'public_key': {'blob': b'\x00\x00\x00\x0bssh-ed25519\x00\x00\x00 \xd1'
b'q\x1ab\xc6\xf0d\x19\xe2q<\x05\x0b\xdao\xa1\xcb'
b'\xae\xad\xc9\x0b\x16\xf3\xc2m\x84q8qU\xda\xb0',
'curve': 'ed25519',
'fingerprint': '47:a3:26:af:0b:5d:a2:c3:91:ed:26:36:94:be:3a:d5',
'type': b'ssh-ed25519'},
'sshsig': False,
'user': b'git',
}
FILE_SIG_BLOB = (
b"SSHSIG\x00\x00\x00\x04file\x00\x00\x00\x00\x00\x00\x00\x06sha512\x00\x00\x00@r\xb7r\xfeM"
b"\xe5w\xf0#w\x1dbl\xca\to=\x90\xb69\xd1:u{\xe5\xe4\xf1\xb1\xa8C\xb8\xfcM\x91\x9f\x12\xa8"
b"\x1d`\x00\x848C<\x85\x8e\xf0o\xdab\xdcQ\xce\xf2\xda\xc3\xae\xa9\x1e%\x85\xcd\xe3'"
)
def test_parse_ssh_signature():
result = client.parse_ssh_blob(FILE_SIG_BLOB)
assert result == {
'hashalg': b'sha512',
'message': b'r\xb7r\xfeM\xe5w\xf0#w\x1dbl\xca\to=\x90\xb69\xd1:u{'
b'\xe5\xe4\xf1\xb1\xa8C\xb8\xfcM\x91\x9f\x12\xa8\x1d`\x00\x848C<'
b"\x85\x8e\xf0o\xdab\xdcQ\xce\xf2\xda\xc3\xae\xa9\x1e%\x85\xcd\xe3'",
'namespace': b'file',
'reserved': b'',
'sshsig': True,
}

View File

@@ -23,6 +23,77 @@ _public_key = (
'home\n'
)
_public_key_cert = (
'ecdsa-sha2-nistp256-cert-v01@openssh.com '
'AAAAKGVjZHNhLXNoYTItbmlzdHAyNTYtY2VydC12MDFAb3B'
'lbnNzaC5jb20AAAAgohlAP8H3LPYWz3+w/E+RGDxG6tNAEE'
'3Ao9Z6Pc66khEAAAAIbmlzdHAyNTYAAABBBGI2zqveJSB+g'
'eQEWG46OvGs2h3+0qu7tIdsH8WylrV19vttd7GR5rKvTWJt'
'8b9ErthmnFALelAFKOB/u50jsukAAAAAAAAAFQAAAAEAAAA'
'IdW5pdFRlc3QAAAAIAAAABHVzZXIAAAAAAAAAAP////////'
'//AAAAAAAAAIIAAAAVcGVybWl0LVgxMS1mb3J3YXJkaW5nA'
'AAAAAAAABdwZXJtaXQtYWdlbnQtZm9yd2FyZGluZwAAAAAA'
'AAAWcGVybWl0LXBvcnQtZm9yd2FyZGluZwAAAAAAAAAKcGV'
'ybWl0LXB0eQAAAAAAAAAOcGVybWl0LXVzZXItcmMAAAAAAA'
'AAAAAAADMAAAALc3NoLWVkMjU1MTkAAAAgf9gyPrF24CLZc'
'0rHoZuI1+yjBFWt66G8oUmm20yRO8IAAABTAAAAC3NzaC1l'
'ZDI1NTE5AAAAQCEgVgsR7fSgcTxuAWqMW4h42y7pt1BAKR4'
'HTRg178tl7Vx8WoRtQcNirX9eggBcTA+5ILWmeY3uDN+soW'
't7fwk= '
'home\n'
)
_public_key_ed25519_cert = (
'ssh-ed25519-cert-v01@openssh.com '
'AAAAIHNzaC1lZDI1NTE5LWNlcnQtdjAxQG9wZW5zc2guY29'
'tAAAAIK5TMdCnuxxy4rr0CTHLekAsnL4DAhFyksK5romkuw'
'xgAAAAIFBdF2tjfSO8nLIi736is+f0erq28RTc7CkM11NZt'
'TKRAAAAAAAAAAAAAAABAAAACXVuaXQtdGVzdAAAAA0AAAAJ'
'dW5pdC10ZXN0AAAAAAAAAAD//////////wAAAAAAAACCAAA'
'AFXBlcm1pdC1YMTEtZm9yd2FyZGluZwAAAAAAAAAXcGVybW'
'l0LWFnZW50LWZvcndhcmRpbmcAAAAAAAAAFnBlcm1pdC1wb'
'3J0LWZvcndhcmRpbmcAAAAAAAAACnBlcm1pdC1wdHkAAAAA'
'AAAADnBlcm1pdC11c2VyLXJjAAAAAAAAAAAAAABoAAAAE2V'
'jZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBC'
'HF5pUcZLVlTUBzos8ojyN34KrS7TnGAZINhRsCoNuRV4NFN'
'IlEYpEvSwlumQuDx6B1y4Va+3pYzBbZInm6vwgAAABjAAAA'
'E2VjZHNhLXNoYTItbmlzdHAyNTYAAABIAAAAICUMX1taTy6'
'y+1Aa1m7kXHI/Qv7ZZIeNp7ndmCRLFCSuAAAAIBaX43k0Ye'
'Bk8a5zp6FyFCBYVOtis/DUbGm07d7miPnE '
'hello\n'
)
_public_key_ed25519_cert_BLOB = (
b'\x00\x00\x00 ssh-ed25519-cert-v01@openssh.com'
b'\x00\x00\x00 \xaeS1\xd0\xa7\xbb\x1cr\xe2\xba'
b'\xf4\t1\xcbz@,\x9c\xbe\x03\x02\x11r\x92\xc2\xb9'
b'\xae\x89\xa4\xbb\x0c`\x00\x00\x00 P]\x17kc}#'
b'\xbc\x9c\xb2"\xef~\xa2\xb3\xe7\xf4z\xba\xb6\xf1'
b'\x14\xdc\xec)\x0c\xd7SY\xb52\x91\x00\x00\x00'
b'\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00'
b'\x00\tunit-test\x00\x00\x00\r\x00\x00\x00\tun'
b'it-test\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff'
b'\xff\xff\xff\xff\xff\xff\x00\x00\x00\x00\x00'
b'\x00\x00\x82\x00\x00\x00\x15permit-X11-forwar'
b'ding\x00\x00\x00\x00\x00\x00\x00\x17permit-ag'
b'ent-forwarding\x00\x00\x00\x00\x00\x00\x00\x16'
b'permit-port-forwarding\x00\x00\x00\x00\x00\x00'
b'\x00\npermit-pty\x00\x00\x00\x00\x00\x00\x00'
b'\x0epermit-user-rc\x00\x00\x00\x00\x00\x00\x00'
b'\x00\x00\x00\x00h\x00\x00\x00\x13ecdsa-sha2-n'
b'istp256\x00\x00\x00\x08nistp256\x00\x00\x00A'
b'\x04!\xc5\xe6\x95\x1cd\xb5eM@s\xa2\xcf(\x8f#w'
b'\xe0\xaa\xd2\xed9\xc6\x01\x92\r\x85\x1b\x02\xa0'
b'\xdb\x91W\x83E4\x89Db\x91/K\tn\x99\x0b\x83\xc7'
b'\xa0u\xcb\x85Z\xfbzX\xcc\x16\xd9"y\xba\xbf\x08'
b'\x00\x00\x00c\x00\x00\x00\x13ecdsa-sha2-nistp'
b'256\x00\x00\x00H\x00\x00\x00 %\x0c_[ZO.\xb2\xfb'
b'P\x1a\xd6n\xe4\\r?B\xfe\xd9d\x87\x8d\xa7\xb9'
b'\xdd\x98$K\x14$\xae\x00\x00\x00 \x16\x97\xe3y'
b'4a\xe0d\xf1\xaes\xa7\xa1r\x14 XT\xebb\xb3\xf0'
b'\xd4li\xb4\xed\xde\xe6\x88\xf9\xc4'
)
def test_parse_public_key():
key = formats.import_public_key(_public_key)
@@ -34,6 +105,16 @@ def test_parse_public_key():
assert key['type'] == b'ecdsa-sha2-nistp256'
def test_parse_public_key_cert():
key = formats.import_public_key(_public_key_cert)
assert key['name'] == b'home'
assert key['point'] == _point
assert key['curve'] == 'nist256p1'
assert key['fingerprint'] == 'ab:ab:5d:9f:f4:33:f4:d0:c3:68:65:3b:94:86:de:22' # nopep8
assert key['type'] == b'ecdsa-sha2-nistp256-cert-v01@openssh.com'
def test_decompress():
blob = '036236ceabde25207e81e404586e3a3af1acda1dfed2abbbb4876c1fc5b296b575'
vk = formats.decompress_pubkey(binascii.unhexlify(blob),
@@ -56,6 +137,16 @@ def test_parse_ed25519():
assert p['type'] == b'ssh-ed25519'
def test_parse_ed25519_cert():
p = formats.import_public_key(_public_key_ed25519_cert)
assert p['name'] == b'hello'
assert p['curve'] == 'ed25519'
assert p['blob'] == _public_key_ed25519_cert_BLOB
assert p['fingerprint'] == '86:b6:17:3e:e1:5c:ba:e0:dc:86:80:b2:47:b4:ad:50' # nopep8
assert p['type'] == b'ssh-ed25519-cert-v01@openssh.com'
def test_export_ed25519():
pub = (b'\x00P]\x17kc}#\xbc\x9c\xb2"\xef~\xa2\xb3\xe7\xf4'
b'z\xba\xb6\xf1\x14\xdc\xec)\x0c\xd7SY\xb52\x91')

View File

@@ -2,6 +2,6 @@ from ..device import interface
def test_unicode():
i = interface.Identity(u'ko\u017eu\u0161\u010dek@host', 'ed25519')
i = interface.Identity('ko\u017eu\u0161\u010dek@host', 'ed25519')
assert i.to_bytes() == b'kozuscek@host'
assert sorted(i.items()) == [('host', 'host'), ('user', 'kozuscek')]

View File

@@ -18,7 +18,7 @@ def test_socket():
assert not os.path.isfile(path)
class FakeSocket(object):
class FakeSocket:
def __init__(self, data=b''):
self.rx = io.BytesIO(data)
@@ -77,13 +77,13 @@ def test_server_thread():
connections = [sock]
quit_event = threading.Event()
class FakeServer(object):
def accept(self): # pylint: disable=no-self-use
class FakeServer:
def accept(self):
if not connections:
raise socket.timeout()
return connections.pop(), 'address'
def getsockname(self): # pylint: disable=no-self-use
def getsockname(self):
return 'fake_server'
def handle_conn(conn):
@@ -102,7 +102,7 @@ def test_spawn():
def thread(x):
obj.append(x)
with server.spawn(thread, dict(x=1)):
with server.spawn(thread, {'x': 1}):
pass
assert obj == [1]

View File

@@ -25,7 +25,7 @@ def test_frames():
assert util.read_frame(io.BytesIO(f)) == b''.join(msgs)
class FakeSocket(object):
class FakeSocket:
def __init__(self):
self.buf = io.BytesIO()
@@ -115,3 +115,32 @@ def test_memoize():
assert g(1) == g(1)
assert g(1) != g(2)
assert f.mock_calls == [mock.call(1), mock.call(2)]
def test_assuan_serialize():
assert util.assuan_serialize(b'') == b''
assert util.assuan_serialize(b'123\n456') == b'123%0A456'
assert util.assuan_serialize(b'\r\n') == b'%0D%0A'
def test_cache():
timer = mock.Mock(side_effect=range(7))
c = util.ExpiringCache(seconds=2, timer=timer) # t=0
assert c.get() is None # t=1
obj = 'foo'
c.set(obj) # t=2
assert c.get() is obj # t=3
assert c.get() is obj # t=4
assert c.get() is None # t=5
assert c.get() is None # t=6
def test_cache_inf():
timer = mock.Mock(side_effect=range(6))
c = util.ExpiringCache(seconds=float('inf'), timer=timer)
obj = 'foo'
c.set(obj)
assert c.get() is obj
assert c.get() is obj
assert c.get() is obj
assert c.get() is obj

View File

@@ -5,6 +5,7 @@ import functools
import io
import logging
import struct
import time
log = logging.getLogger(__name__)
@@ -145,7 +146,7 @@ def hexlify(blob):
return binascii.hexlify(blob).decode('ascii').upper()
class Reader(object):
class Reader:
"""Read basic type objects out of given stream."""
def __init__(self, stream):
@@ -215,6 +216,24 @@ def memoize(func):
return wrapper
def memoize_method(method):
"""Simple caching decorator."""
cache = {}
@functools.wraps(method)
def wrapper(self, *args, **kwargs):
"""Caching wrapper."""
key = (args, tuple(sorted(kwargs.items())))
if key in cache:
return cache[key]
else:
result = method(self, *args, **kwargs)
cache[key] = result
return result
return wrapper
@memoize
def which(cmd):
"""Return full path to specified command, or raise OSError if missing."""
@@ -223,9 +242,39 @@ def which(cmd):
from shutil import which as _which
except ImportError:
# For Python 2
from backports.shutil_which import which as _which # pylint: disable=relative-import
from backports.shutil_which import which as _which
full_path = _which(cmd)
if full_path is None:
raise OSError('Cannot find {!r} in $PATH'.format(cmd))
log.debug('which %r => %r', cmd, full_path)
return full_path
def assuan_serialize(data):
"""Serialize data according to ASSUAN protocol (for GPG daemon communication)."""
for c in [b'%', b'\n', b'\r']:
escaped = '%{:02X}'.format(ord(c)).encode('ascii')
data = data.replace(c, escaped)
return data
class ExpiringCache:
"""Simple cache with a deadline."""
def __init__(self, seconds, timer=time.time):
"""C-tor."""
self.duration = seconds
self.timer = timer
self.value = None
self.set(None)
def get(self):
"""Returns existing value, or None if deadline has expired."""
if self.timer() > self.deadline:
self.value = None
return self.value
def set(self, value):
"""Set new value and reset the deadline for expiration."""
self.deadline = self.timer() + self.duration
self.value = value

6
release.sh Executable file
View File

@@ -0,0 +1,6 @@
#!/bin/bash
set -eux
rm -rv dist/*
python3 setup.py sdist
gpg2 -v --detach-sign -a dist/*.tar.gz
twine upload dist/*

View File

@@ -3,23 +3,31 @@ from setuptools import setup
setup(
name='libagent',
version='0.9.8',
version='0.14.8',
description='Using hardware wallets as SSH/GPG agent',
author='Roman Zeyde',
author_email='roman.zeyde@gmail.com',
author_email='dev@romanzey.de',
url='http://github.com/romanz/trezor-agent',
packages=[
'libagent',
'libagent.age',
'libagent.device',
'libagent.gpg',
'libagent.ssh'
'libagent.signify',
'libagent.ssh',
],
install_requires=[
'bech32>=1.2.0',
'cryptography>=3.4.6',
'docutils>=0.14',
'python-daemon>=2.3.0',
'wheel>=0.32.3',
'backports.shutil_which>=3.5.1',
'ConfigArgParse>=0.12.0',
'ConfigArgParse>=0.12.1',
'python-daemon>=2.1.2',
'ecdsa>=0.13',
'ed25519>=1.4',
'pynacl>=1.4.0',
'mnemonic>=0.18',
'pymsgbox>=1.0.6',
'semver>=2.2',
'unidecode>=0.4.20',
@@ -27,16 +35,13 @@ setup(
platforms=['POSIX'],
classifiers=[
'Environment :: Console',
'Development Status :: 4 - Beta',
'Development Status :: 5 - Production/Stable',
'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',
'Programming Language :: Python :: 3 :: Only',
'Topic :: Software Development :: Libraries :: Python Modules',
'Topic :: System :: Networking',
'Topic :: Communications',

View File

@@ -1,5 +1,5 @@
[tox]
envlist = py27,py3
envlist = py3
[pycodestyle]
max-line-length = 100
[pep257]
@@ -16,9 +16,9 @@ deps=
isort
commands=
pycodestyle libagent
# isort --skip-glob .tox -c -r libagent
isort --skip-glob .tox -c libagent
pylint --reports=no --rcfile .pylintrc libagent
pydocstyle libagent
coverage run --source libagent -m py.test -v libagent
coverage run --source libagent -m pytest -v libagent
coverage report
coverage html