Compare commits
767 Commits
Author | SHA1 | Date |
---|---|---|
|
71f357c1bf | 6 years ago |
|
8f1d008eb2 | 6 years ago |
|
7a351acf15 | 6 years ago |
|
7f9aa2b147 | 6 years ago |
|
eed168341c | 6 years ago |
|
8b85090fba | 6 years ago |
|
8708b1e16d | 6 years ago |
|
03e7fc48e9 | 6 years ago |
|
4968ca7ff3 | 6 years ago |
|
6b6d9f5d20 | 6 years ago |
|
c22109df24 | 6 years ago |
|
47ce035e79 | 6 years ago |
|
36cbba6c57 | 6 years ago |
|
6afe20350b | 6 years ago |
|
fa171e8923 | 6 years ago |
|
f0bda9a3e6 | 6 years ago |
|
71b56e15d7 | 6 years ago |
|
3b9c00e02a | 6 years ago |
|
dcee59a19e | 6 years ago |
|
a274de30b8 | 6 years ago |
|
4fe9e437ad | 6 years ago |
|
d04527a8ed | 6 years ago |
|
3329c29cb4 | 6 years ago |
|
df2cb52f8d | 6 years ago |
|
f36ef4ffe0 | 6 years ago |
|
f74de828fc | 6 years ago |
|
912b1cde7a | 6 years ago |
|
b7a8c42893 | 6 years ago |
|
1e6c4e6930 | 6 years ago |
|
a8f19e4150 | 6 years ago |
|
6a9fdf75e2 | 6 years ago |
|
6bc5b6af5e | 6 years ago |
|
8672a6901a | 6 years ago |
|
672af98ad7 | 6 years ago |
|
ed531cfff8 | 6 years ago |
|
bd1ae0f091 | 6 years ago |
|
0c762e8998 | 6 years ago |
|
bd0df4f801 | 6 years ago |
|
3d1639d271 | 6 years ago |
|
bea899d1ef | 6 years ago |
|
ccc2174775 | 6 years ago |
|
afa3fdb89c | 6 years ago |
|
2ca3941cfa | 6 years ago |
|
b1bd6cb690 | 6 years ago |
|
766536d2c4 | 6 years ago |
|
91f70e7a96 | 6 years ago |
|
cf5bfd960a | 6 years ago |
|
4bd769f138 | 6 years ago |
|
91b850f184 | 6 years ago |
|
c6bb090dfc | 6 years ago |
|
fef4fd06c9 | 6 years ago |
|
bc691ae795 | 6 years ago |
|
61e516e200 | 6 years ago |
|
543ff7021d | 6 years ago |
|
2e0cfc8088 | 6 years ago |
|
18f33f8a08 | 6 years ago |
|
2973413995 | 6 years ago |
|
2360693dc5 | 6 years ago |
|
7443fc6512 | 6 years ago |
|
5efb752979 | 6 years ago |
|
4546cd674b | 6 years ago |
|
5dba12f144 | 6 years ago |
|
887561de9f | 6 years ago |
|
6d730e0a5b | 6 years ago |
|
d0732d16e8 | 6 years ago |
|
dafb80ad7a | 6 years ago |
|
df6249b071 | 6 years ago |
|
942f01418b | 6 years ago |
|
93b548b737 | 6 years ago |
|
329f07249a | 6 years ago |
|
a1f7088d33 | 6 years ago |
|
25f066e113 | 6 years ago |
|
0699273d49 | 6 years ago |
|
92c352e860 | 6 years ago |
|
34c03a462c | 6 years ago |
|
51dbecd4c2 | 6 years ago |
|
ceae65aa5a | 6 years ago |
|
d0497b0137 | 6 years ago |
|
870152a7af | 6 years ago |
|
cbdc52c0a4 | 6 years ago |
|
0c9fc33757 | 6 years ago |
|
17ea941add | 6 years ago |
|
64064b5ecc | 6 years ago |
|
601a2b1336 | 6 years ago |
|
2e688ccac9 | 6 years ago |
|
b6181bb5b5 | 6 years ago |
|
b6da299cb0 | 6 years ago |
|
04627f0899 | 6 years ago |
|
54ce6f2cec | 6 years ago |
|
a1047ba7b1 | 6 years ago |
|
e90bd0cd81 | 6 years ago |
|
66e3e60370 | 6 years ago |
|
3f1604d609 | 6 years ago |
|
d0f4cccfd2 | 6 years ago |
|
08d81c992c | 6 years ago |
|
55a899f929 | 6 years ago |
|
e7604dff68 | 6 years ago |
|
8849545700 | 6 years ago |
|
d109cd73b5 | 6 years ago |
|
95e98d6eda | 6 years ago |
|
9e78d52721 | 6 years ago |
|
2a76ef6819 | 6 years ago |
|
654a3c465a | 6 years ago |
|
2168115b06 | 6 years ago |
|
4a9140c42d | 6 years ago |
|
b20d98bf57 | 6 years ago |
|
199fb299c3 | 6 years ago |
|
06e169f141 | 6 years ago |
|
131111bc0e | 6 years ago |
|
f4208009e0 | 6 years ago |
|
73d60dbec0 | 6 years ago |
|
34ea224290 | 6 years ago |
|
7803026f61 | 6 years ago |
|
34ce1005fd | 6 years ago |
|
8677c8ebaa | 6 years ago |
|
6363eb0d4a | 6 years ago |
|
a32bfc749b | 6 years ago |
|
75d117ad0d | 6 years ago |
|
cefc5f180a | 6 years ago |
|
0f5c71b748 | 6 years ago |
|
d5f97b7efa | 6 years ago |
|
4a12bfa0b7 | 6 years ago |
|
cac889ff7d | 6 years ago |
|
92c6e680ed | 6 years ago |
|
bf294beb56 | 6 years ago |
|
713345918e | 7 years ago |
|
eb60c2f475 | 7 years ago |
|
6d8d43db9b | 7 years ago |
|
3e67bc9f0e | 7 years ago |
|
38b50485de | 7 years ago |
|
9cba27b31a | 7 years ago |
|
00a65a9820 | 7 years ago |
|
52ad601e66 | 7 years ago |
|
d96a2820ff | 7 years ago |
|
29aaf777ad | 7 years ago |
|
385fc9457b | 7 years ago |
|
9cf73f677a | 7 years ago |
|
ec97cd0c44 | 7 years ago |
|
4cd7dc02eb | 7 years ago |
|
8fe9460ed6 | 7 years ago |
|
db16aa3d1c | 7 years ago |
|
41ccd2f332 | 7 years ago |
|
cb14d1e00b | 7 years ago |
|
cc6ee31deb | 7 years ago |
|
b1f302151b | 7 years ago |
|
fde50f04ab | 7 years ago |
|
7e42e455a1 | 7 years ago |
|
13cd6be2d1 | 7 years ago |
|
40469c4100 | 7 years ago |
|
0d059587a7 | 7 years ago |
|
283cb3d7e8 | 7 years ago |
|
51cc716e3f | 7 years ago |
|
8b4850b0ce | 7 years ago |
|
f22c07e970 | 7 years ago |
|
29c7234ef4 | 7 years ago |
|
1942e3999b | 7 years ago |
|
f2e52a88be | 7 years ago |
|
b26a4cc7b0 | 7 years ago |
|
c4dfca04f2 | 7 years ago |
|
a1ecbf447e | 7 years ago |
|
1f9d457e92 | 7 years ago |
|
cb3477fc69 | 7 years ago |
|
9bbc66cc16 | 7 years ago |
|
06afc971db | 7 years ago |
|
2b51a85c26 | 7 years ago |
|
1906e6d9b0 | 7 years ago |
|
b3f6e39b48 | 7 years ago |
|
8b03b649d5 | 7 years ago |
|
90cbc41b17 | 7 years ago |
|
4926d4f4d3 | 7 years ago |
|
d52f295326 | 7 years ago |
|
47a8a53247 | 7 years ago |
|
9530c4d7db | 7 years ago |
|
a2d0c1067d | 7 years ago |
|
3d5717dca1 | 7 years ago |
|
08fef24e39 | 7 years ago |
|
bab46dae5c | 7 years ago |
|
e2625cc521 | 7 years ago |
|
7ed76fe472 | 7 years ago |
|
a5929eed62 | 7 years ago |
|
5f722f8ae1 | 7 years ago |
|
7212b2fa37 | 7 years ago |
|
55e1c614a7 | 7 years ago |
|
8cf1f0463a | 7 years ago |
|
f177b0b55a | 7 years ago |
|
b2450d448c | 7 years ago |
|
93e5f0cd8b | 7 years ago |
|
9998456fe0 | 7 years ago |
|
0f85ae6e2c | 7 years ago |
|
44cdeed024 | 7 years ago |
|
867e2cfd1b | 7 years ago |
|
df6ddab2cf | 7 years ago |
|
5b9f03d198 | 7 years ago |
|
06ea890095 | 7 years ago |
|
0999a85529 | 7 years ago |
|
835f283ccf | 7 years ago |
|
f57dbb553f | 7 years ago |
|
a890dcc085 | 7 years ago |
|
c8ed4a223a | 7 years ago |
|
1ef96bed03 | 7 years ago |
|
e4fdca08e5 | 7 years ago |
|
51b297e93b | 7 years ago |
|
c22c959cf9 | 7 years ago |
|
3199cb964a | 7 years ago |
|
c5f245957d | 7 years ago |
|
fbb3059a0b | 7 years ago |
|
e922f45871 | 7 years ago |
|
377af1466c | 7 years ago |
|
b7743e12a5 | 7 years ago |
|
48d5630561 | 7 years ago |
|
b88dff8430 | 7 years ago |
|
2af1086ed8 | 7 years ago |
|
7e95179128 | 7 years ago |
|
ac8898a434 | 7 years ago |
|
0b829636e1 | 7 years ago |
|
7598f6cdbf | 7 years ago |
|
c8bf57cbcc | 7 years ago |
|
62af49236c | 7 years ago |
|
af3f669780 | 7 years ago |
|
1520dbd8b9 | 7 years ago |
|
4f05d51e9b | 7 years ago |
|
9d38c26a0f | 7 years ago |
|
3a9330b995 | 7 years ago |
|
f904aac92e | 7 years ago |
|
ca67923fe8 | 7 years ago |
|
ce90e61eb2 | 7 years ago |
|
90dc124e8d | 7 years ago |
|
442bf725ef | 7 years ago |
|
5820480052 | 7 years ago |
|
ae2a84e168 | 7 years ago |
|
f6911a0016 | 7 years ago |
|
69c54eb425 | 7 years ago |
|
931573f32b | 7 years ago |
|
1e8363d4fc | 7 years ago |
|
b7113083b4 | 7 years ago |
|
b5c4eca0d2 | 7 years ago |
|
8aa08d0862 | 7 years ago |
|
b452b49f4c | 7 years ago |
|
639c4efb6d | 7 years ago |
|
6f1686c614 | 7 years ago |
|
300d9a7140 | 7 years ago |
|
b143bafc70 | 7 years ago |
|
f2c6b6b9c1 | 7 years ago |
|
e0507b1508 | 7 years ago |
|
85274d8374 | 7 years ago |
|
f358ca29d4 | 7 years ago |
|
53d43cba29 | 7 years ago |
|
214b556f83 | 7 years ago |
|
051a3fd4ab | 7 years ago |
|
91050ee64a | 7 years ago |
|
257992d04c | 7 years ago |
|
6c2273387d | 7 years ago |
|
b6ad8207ba | 7 years ago |
|
3a93fc859d | 7 years ago |
|
7d9b3ff1d0 | 7 years ago |
|
4af881b3cb | 7 years ago |
|
eb525e1b62 | 7 years ago |
|
02c8e729b7 | 7 years ago |
|
12359938ad | 7 years ago |
|
93cd3e688b | 7 years ago |
|
26d7dd3124 | 7 years ago |
|
0d5c3a9ca7 | 7 years ago |
|
97ec6b2719 | 7 years ago |
|
8ba9be1780 | 7 years ago |
|
b2bc87c0c7 | 7 years ago |
|
d522d148ef | 7 years ago |
|
c796a3b01d | 7 years ago |
|
a3362bbf3e | 7 years ago |
|
9a271d115b | 7 years ago |
|
6a7165298f | 7 years ago |
|
c4f3fa6e04 | 7 years ago |
|
8a77fa519f | 7 years ago |
|
59560ec0b0 | 7 years ago |
|
7a91196dd5 | 7 years ago |
|
43c424a402 | 8 years ago |
|
6672ea9bc4 | 8 years ago |
|
002dc2a0e0 | 8 years ago |
|
61ced2808f | 8 years ago |
|
71a8930021 | 8 years ago |
|
74e8f21a22 | 8 years ago |
|
897236d556 | 8 years ago |
|
5bec0e8382 | 8 years ago |
|
3cb7f6fd21 | 8 years ago |
|
cad2ec1239 | 8 years ago |
|
604b2b7e99 | 8 years ago |
|
159bd79b5f | 8 years ago |
|
dde0b60e83 | 8 years ago |
|
109bb3b47f | 8 years ago |
|
0f20bfa239 | 8 years ago |
|
798597c436 | 8 years ago |
|
a13b1103f7 | 8 years ago |
|
9fe1a235c1 | 8 years ago |
|
f86aae9a40 | 8 years ago |
|
fc070e3ca0 | 8 years ago |
|
05fac995eb | 8 years ago |
|
188b74b327 | 8 years ago |
|
fc31847f8e | 8 years ago |
|
0faf21a102 | 8 years ago |
|
6b82f8b9b7 | 8 years ago |
|
fabfcaaae2 | 8 years ago |
|
f0f89310ac | 8 years ago |
|
47ff7c5cb3 | 8 years ago |
|
0440025083 | 8 years ago |
|
c49fe97f63 | 8 years ago |
|
7f8abcb5c5 | 8 years ago |
|
e13039e52d | 8 years ago |
|
c420571eb8 | 8 years ago |
|
827119a18d | 8 years ago |
|
9be6504658 | 8 years ago |
|
07cbe65875 | 8 years ago |
|
180120e787 | 8 years ago |
|
f4ce81fa94 | 8 years ago |
|
176bf4ef7c | 8 years ago |
|
d22cd7512d | 8 years ago |
|
83f17704cb | 8 years ago |
|
92f6751ccb | 8 years ago |
|
abe80533eb | 8 years ago |
|
de51665c71 | 8 years ago |
|
c30e5f5a67 | 8 years ago |
|
2eab2a152c | 8 years ago |
|
5e93d97be3 | 8 years ago |
|
4c8fcd6714 | 8 years ago |
|
ee593bc66e | 8 years ago |
|
dbed773e54 | 8 years ago |
|
ac4a86d312 | 8 years ago |
|
021831073e | 8 years ago |
|
6a5acba0b0 | 8 years ago |
|
9123cef810 | 8 years ago |
|
6f6e7c0bcc | 8 years ago |
|
47ff081525 | 8 years ago |
|
6d53baafe2 | 8 years ago |
|
317b672add | 8 years ago |
|
9964c200ff | 8 years ago |
|
75405b4944 | 8 years ago |
|
e74b9c77af | 8 years ago |
|
c2158947c8 | 8 years ago |
|
e39d5025d5 | 8 years ago |
|
efdb9fcfb5 | 8 years ago |
|
a20b1ed2a8 | 8 years ago |
|
ca507126d6 | 8 years ago |
|
0f79b5ff2e | 8 years ago |
|
946ab633d4 | 8 years ago |
|
4108c9287f | 8 years ago |
|
d9cb75e95d | 8 years ago |
|
2cecd2ed08 | 8 years ago |
|
05f40085b2 | 8 years ago |
|
c7346d621d | 8 years ago |
|
0342b39465 | 8 years ago |
|
fa6d8564b9 | 8 years ago |
|
e09712c793 | 8 years ago |
|
0cbb3bb9fa | 8 years ago |
|
d7a6641ffa | 8 years ago |
|
6fe89241c4 | 8 years ago |
|
c5262d075b | 8 years ago |
|
683d24f4eb | 8 years ago |
|
921e2954c1 | 8 years ago |
|
3f784289d8 | 8 years ago |
|
04d790767d | 8 years ago |
|
97efdf4a45 | 8 years ago |
|
ee2f6b75dc | 8 years ago |
|
a26f0ea034 | 8 years ago |
|
a68f1e5c26 | 8 years ago |
|
93e3c66a15 | 8 years ago |
|
44eaaa6b9c | 8 years ago |
|
b83d4960e7 | 8 years ago |
|
75fe7b4e05 | 8 years ago |
|
742136b22d | 8 years ago |
|
513e99dd57 | 8 years ago |
|
1bd6775c35 | 8 years ago |
|
aaade1737f | 8 years ago |
|
fe185c190e | 8 years ago |
|
1bc0165368 | 8 years ago |
|
0f841ffbc4 | 8 years ago |
|
b2942035a3 | 8 years ago |
|
215b64f253 | 8 years ago |
|
79e68b29c2 | 8 years ago |
|
8265515641 | 8 years ago |
|
749799845d | 8 years ago |
|
eaea35003e | 8 years ago |
|
eefb38ce83 | 8 years ago |
|
0730eb7223 | 8 years ago |
|
5b61702205 | 8 years ago |
|
0ad0ca3b9a | 8 years ago |
|
2843cdcf41 | 8 years ago |
|
c7bc78ebe7 | 8 years ago |
|
a6d9edcb0b | 8 years ago |
|
bc64205a85 | 8 years ago |
|
34dc803856 | 8 years ago |
|
f7ebb02799 | 8 years ago |
|
0ba33a5bc4 | 8 years ago |
|
13752ddcd5 | 8 years ago |
|
487a8e56c4 | 8 years ago |
|
ef56ee4602 | 8 years ago |
|
ae381a38e5 | 8 years ago |
|
446ec99bf4 | 8 years ago |
|
80c6f10533 | 8 years ago |
|
ff984c60e4 | 8 years ago |
|
c9bc079dc9 | 8 years ago |
|
65d2c04478 | 8 years ago |
|
2d57bf4453 | 8 years ago |
|
79b6d31dfe | 8 years ago |
|
7de88a3980 | 8 years ago |
|
6f8d0df116 | 8 years ago |
|
b4a382d22e | 8 years ago |
|
d236f4667e | 8 years ago |
|
42813ddbb4 | 8 years ago |
|
8f19690943 | 8 years ago |
|
5047805385 | 8 years ago |
|
915b326da7 | 8 years ago |
|
e7b8379a97 | 8 years ago |
|
26435130d7 | 8 years ago |
|
dfde6dbee4 | 8 years ago |
|
085a3e81c7 | 8 years ago |
|
3082d61deb | 8 years ago |
|
e3286a4510 | 8 years ago |
|
fcd5671626 | 8 years ago |
|
1454d2f4d7 | 8 years ago |
|
9b395363a3 | 8 years ago |
|
5bb9dd7770 | 8 years ago |
|
51df023a23 | 8 years ago |
|
d74f375637 | 8 years ago |
|
1fd0659051 | 8 years ago |
|
18be290bd6 | 8 years ago |
|
a1ab496bf4 | 8 years ago |
|
784e14647a | 8 years ago |
|
7d2c649e83 | 8 years ago |
|
cf27b345f6 | 8 years ago |
|
386ed5a81f | 8 years ago |
|
5a64954324 | 8 years ago |
|
3aebd137b0 | 8 years ago |
|
1fa35e7f1a | 8 years ago |
|
aeda85275d | 8 years ago |
|
e41206b350 | 8 years ago |
|
03650550dd | 8 years ago |
|
f7b07070da | 8 years ago |
|
96eede9c83 | 8 years ago |
|
91146303a3 | 8 years ago |
|
bf598435fb | 8 years ago |
|
459b882b89 | 8 years ago |
|
998c9ee958 | 8 years ago |
|
d408a592aa | 8 years ago |
|
282e91ace3 | 8 years ago |
|
23c37cf1e3 | 8 years ago |
|
5c5c6f9cbb | 8 years ago |
|
17c8bd0e92 | 8 years ago |
|
016e864503 | 8 years ago |
|
57e09248db | 8 years ago |
|
0c4e67c837 | 8 years ago |
|
adcbe6e7b2 | 8 years ago |
|
73bdf417e4 | 8 years ago |
|
ee347252b4 | 8 years ago |
|
d63f048b78 | 8 years ago |
|
05fada91d2 | 8 years ago |
|
27a3fddfa2 | 8 years ago |
|
030ae4c3f6 | 8 years ago |
|
4897b70888 | 8 years ago |
|
f4ecd47ed6 | 8 years ago |
|
c4bbac0e77 | 8 years ago |
|
5d0b0f65d3 | 8 years ago |
|
33747592ca | 8 years ago |
|
adb09cd8ca | 8 years ago |
|
45f6f1a3d8 | 8 years ago |
|
c4c56b9faf | 8 years ago |
|
bc1d7a5448 | 8 years ago |
|
8fe16d24c2 | 8 years ago |
|
1704ae7683 | 8 years ago |
|
a7190223fd | 8 years ago |
|
220735c6ad | 8 years ago |
|
82e08d073b | 8 years ago |
|
8ab0908388 | 8 years ago |
|
fd3183d71c | 8 years ago |
|
295d52ef10 | 8 years ago |
|
8a51099488 | 8 years ago |
|
f4dd1eacdd | 8 years ago |
|
024b5f131f | 8 years ago |
|
b9b7b8dafd | 8 years ago |
|
744696fdee | 8 years ago |
|
ccdbc7abfc | 8 years ago |
|
e70f0ec681 | 8 years ago |
|
aeaf978d8e | 8 years ago |
|
d60fff202a | 8 years ago |
|
9171dd08c8 | 8 years ago |
|
4c5004d838 | 8 years ago |
|
a2e46048a1 | 8 years ago |
|
e66b0f47ed | 8 years ago |
|
db874ad98f | 8 years ago |
|
ed2d71cc08 | 8 years ago |
|
59b39ce81f | 8 years ago |
|
75f879edbb | 8 years ago |
|
45a85a317b | 8 years ago |
|
7b3874e6f7 | 8 years ago |
|
6c96cc37b9 | 8 years ago |
|
c98cb22ba4 | 8 years ago |
|
d9fbfccd35 | 8 years ago |
|
fe4d9ed3c8 | 8 years ago |
|
092445af71 | 8 years ago |
|
602e867c7d | 8 years ago |
|
16de8cdabc | 8 years ago |
|
7bbf11b631 | 8 years ago |
|
3e41fddcef | 8 years ago |
|
8108e5400d | 8 years ago |
|
a1659e0f0d | 8 years ago |
|
3b139314b6 | 8 years ago |
|
a05cff5079 | 8 years ago |
|
694cee17ac | 8 years ago |
|
bc281d4411 | 8 years ago |
|
04af6b737b | 8 years ago |
|
171c746c7e | 8 years ago |
|
8b5ac14150 | 8 years ago |
|
16090cebed | 8 years ago |
|
d2167cd4ff | 8 years ago |
|
10cbe67c9a | 8 years ago |
|
29a984eebb | 8 years ago |
|
a6660fd5c5 | 8 years ago |
|
2acd0bf3b7 | 8 years ago |
|
e9f7894d62 | 8 years ago |
|
56e9d7c776 | 8 years ago |
|
e7bacf829c | 8 years ago |
|
c1c679b541 | 8 years ago |
|
49c343df94 | 8 years ago |
|
7da7f5c256 | 8 years ago |
|
39cb5565bf | 8 years ago |
|
f89c5bb125 | 8 years ago |
|
92649b290f | 8 years ago |
|
d9b07e2ac6 | 8 years ago |
|
6975671cc1 | 8 years ago |
|
f0ea568bb8 | 8 years ago |
|
34c614db6e | 8 years ago |
|
2bbd335f7e | 8 years ago |
|
af8ad99c7a | 8 years ago |
|
313271ac06 | 8 years ago |
|
969e08140b | 8 years ago |
|
39f00af65d | 8 years ago |
|
272759e907 | 8 years ago |
|
4be55156ed | 8 years ago |
|
80a5ea0f2a | 8 years ago |
|
87e50449e5 | 8 years ago |
|
dcf35c4267 | 8 years ago |
|
7570861765 | 8 years ago |
|
339f61c071 | 8 years ago |
|
3c4fb7a17b | 8 years ago |
|
a6a0c05f57 | 8 years ago |
|
4c036d2ce7 | 8 years ago |
|
eaa91cfdbd | 8 years ago |
|
fd61941d0f | 8 years ago |
|
decd3ddf75 | 8 years ago |
|
4c07b360cd | 8 years ago |
|
0b0f60dd89 | 8 years ago |
|
db6903eab7 | 8 years ago |
|
171a0c2f6a | 8 years ago |
|
a535b31a1b | 8 years ago |
|
ee4bcddd22 | 8 years ago |
|
f626d34e21 | 8 years ago |
|
2cf081420f | 8 years ago |
|
0e72e3b7ff | 8 years ago |
|
ce61c8b2ae | 8 years ago |
|
3192e570ed | 8 years ago |
|
bf8f516ef4 | 8 years ago |
|
51f7d6120b | 8 years ago |
|
0cb7cf0746 | 8 years ago |
|
b4ff31f816 | 8 years ago |
|
6e9d6d6430 | 8 years ago |
|
fa9391ede6 | 8 years ago |
|
ad8eafe6f8 | 8 years ago |
|
695079e4b9 | 8 years ago |
|
9888ef971a | 8 years ago |
|
04a878374f | 8 years ago |
|
4270d8464f | 8 years ago |
|
25a427081c | 8 years ago |
|
939fdbe829 | 8 years ago |
|
1f126f3002 | 8 years ago |
|
78526d1379 | 8 years ago |
|
7e3c3b4f77 | 8 years ago |
|
513c19bf1f | 8 years ago |
|
f1e75783c4 | 8 years ago |
|
68637525ea | 8 years ago |
|
fce45832c2 | 8 years ago |
|
df001c4100 | 8 years ago |
|
1a228a1af6 | 8 years ago |
|
7a7c9efc47 | 8 years ago |
|
859cee9757 | 8 years ago |
|
2846c0bf1a | 8 years ago |
|
b2147a8418 | 8 years ago |
|
4cbf8a9f0a | 8 years ago |
|
d9c4e930f3 | 8 years ago |
|
6fd6fe6520 | 8 years ago |
|
4a7fef3011 | 8 years ago |
|
a0e476ea19 | 8 years ago |
|
683aae7aa4 | 8 years ago |
|
d369638c7b | 8 years ago |
|
07c4100618 | 8 years ago |
|
b9f139b74a | 8 years ago |
|
3bf926620b | 8 years ago |
|
ab192619f4 | 8 years ago |
|
f982d785bd | 8 years ago |
|
38c1acf4db | 8 years ago |
|
31c3686fa4 | 8 years ago |
|
87ca33c104 | 8 years ago |
|
c3d23ea7f5 | 8 years ago |
|
5c04d17c43 | 8 years ago |
|
2d2d6efa93 | 8 years ago |
|
131c30acca | 8 years ago |
|
a7ef263954 | 8 years ago |
|
d486c1ee7b | 8 years ago |
|
f35b5be3ac | 8 years ago |
|
9ed9781496 | 8 years ago |
|
5d007260e1 | 8 years ago |
|
7dfa3ab255 | 8 years ago |
|
b8eba72d0b | 8 years ago |
|
492285de1b | 8 years ago |
|
cc326b1f7d | 8 years ago |
|
169ff39b1a | 8 years ago |
|
dcc7ef2600 | 8 years ago |
|
ac2d12b354 | 8 years ago |
|
f3b49ff553 | 8 years ago |
|
12d640c66b | 8 years ago |
|
32984d2d3f | 8 years ago |
|
a45c6c1300 | 8 years ago |
|
1d3ba7e9b7 | 8 years ago |
|
673b1df648 | 8 years ago |
|
e63f03354e | 8 years ago |
|
3c9c1b4e14 | 8 years ago |
|
5caf4728ee | 8 years ago |
|
dde6dcdaeb | 8 years ago |
|
1f3c989884 | 8 years ago |
|
55dea41959 | 8 years ago |
|
ed01c00d0c | 8 years ago |
|
e09571151c | 8 years ago |
|
340aae4fb8 | 8 years ago |
|
9875c9927e | 8 years ago |
|
d9862ae0e1 | 8 years ago |
|
5fb8b0e047 | 8 years ago |
|
324fc21a5c | 8 years ago |
|
e2f5ccafdf | 8 years ago |
|
a0b4776374 | 8 years ago |
|
5abc3dc41b | 8 years ago |
|
3c2eb64e0d | 8 years ago |
|
67d58a5ae0 | 8 years ago |
|
9a435ae23e | 8 years ago |
|
d7913a84d5 | 8 years ago |
|
a114242243 | 8 years ago |
|
b6dbc4aa81 | 8 years ago |
|
6cc3a629a8 | 8 years ago |
|
0c94363595 | 8 years ago |
|
40377fc66b | 8 years ago |
|
489c8fe357 | 8 years ago |
|
6f4f33bfa5 | 8 years ago |
|
76ce25fab1 | 8 years ago |
|
5506310239 | 8 years ago |
|
9dc955aae8 | 8 years ago |
|
80f29469d0 | 8 years ago |
|
fb368d24eb | 8 years ago |
|
8c0848b459 | 8 years ago |
|
276dec5728 | 8 years ago |
|
74f7ebf228 | 8 years ago |
|
7ef0958c33 | 8 years ago |
|
1402918bb3 | 8 years ago |
|
b6cfa0c03f | 8 years ago |
|
33ff9ba667 | 8 years ago |
|
ab64505cdb | 8 years ago |
|
5651452c0d | 8 years ago |
|
af6d0caf33 | 8 years ago |
|
96592269b6 | 8 years ago |
|
b2d078eec6 | 8 years ago |
|
01dafb0ebd | 8 years ago |
|
447faf973c | 8 years ago |
|
add90e3c51 | 8 years ago |
|
34670c601d | 8 years ago |
|
b9ba4a3082 | 8 years ago |
|
4335740abe | 8 years ago |
|
861401e89a | 8 years ago |
|
335d050212 | 8 years ago |
|
6e1b08c27a | 8 years ago |
|
b3a6c76631 | 8 years ago |
|
f056f1fac5 | 8 years ago |
|
716dc82312 | 8 years ago |
|
0e2a19f7ce | 8 years ago |
|
2cdbc89d28 | 8 years ago |
|
1022e54d6a | 8 years ago |
|
ea88f425f5 | 8 years ago |
|
000860feaf | 8 years ago |
|
2a5196003e | 8 years ago |
|
e10b42bbb5 | 8 years ago |
|
b07d7e6535 | 8 years ago |
|
4838030be5 | 8 years ago |
|
c9f341a42b | 8 years ago |
|
bdd2568b2c | 8 years ago |
|
ae20ae4a04 | 8 years ago |
|
f15c2c7236 | 8 years ago |
|
e6ccc324a0 | 8 years ago |
|
7c102e435e | 8 years ago |
|
7f6bb12b24 | 8 years ago |
|
98e875562e | 8 years ago |
|
4384b93c19 | 8 years ago |
|
8a90a8cd84 | 8 years ago |
|
1e86983782 | 8 years ago |
|
c63201c90c | 8 years ago |
|
19b00dc427 | 8 years ago |
|
aa35981980 | 8 years ago |
|
8909b38107 | 8 years ago |
|
6d9aa9cb8a | 8 years ago |
|
d6532311b9 | 8 years ago |
|
41b30b42b5 | 8 years ago |
|
5b0e56697f | 8 years ago |
|
0e6d998b4c | 8 years ago |
|
2c7fabfa35 | 8 years ago |
|
1adccdbfe6 | 8 years ago |
|
04f4bbf2ac | 8 years ago |
|
bbe963d0ff | 8 years ago |
|
c49514754b | 8 years ago |
|
2ebefff909 | 8 years ago |
|
21e89014c9 | 8 years ago |
|
566e4310e1 | 8 years ago |
|
e1441518d4 | 8 years ago |
|
5cb12a43de | 8 years ago |
|
df607f3665 | 8 years ago |
|
d712509a4e | 8 years ago |
|
40e2d9fb2c | 8 years ago |
|
cd4cc059d6 | 8 years ago |
|
2b047f0525 | 8 years ago |
|
64776fd294 | 8 years ago |
|
231995bd1a | 8 years ago |
|
ff76f17c02 | 8 years ago |
|
963e80b49b | 9 years ago |
|
dee13b75ea | 9 years ago |
|
be86507e00 | 9 years ago |
|
2f2663ef94 | 9 years ago |
|
cafa218e19 | 9 years ago |
|
50b627ed45 | 9 years ago |
|
7f36097c15 | 9 years ago |
|
a4b905cd6f | 9 years ago |
|
2eff21f96c | 9 years ago |
|
9afd07e867 | 9 years ago |
|
b101281a5b | 9 years ago |
|
8c6ac43cf4 | 9 years ago |
|
5932a89dc5 | 9 years ago |
|
2009160ff2 | 9 years ago |
|
3d8072522c | 9 years ago |
|
0c63aef719 | 9 years ago |
|
c454114c4e | 9 years ago |
|
f9133f7e05 | 9 years ago |
|
33a6951a96 | 9 years ago |
|
fb0d0a5f61 | 9 years ago |
|
7ea20c7009 | 9 years ago |
|
4247558166 | 9 years ago |
|
fe1e1d2bb9 | 9 years ago |
|
1a5b8118ad | 9 years ago |
|
3a806c6d77 | 9 years ago |
|
3b61f86c25 | 9 years ago |
|
06d84c387c | 9 years ago |
|
8347142a99 | 9 years ago |
|
7dabe2c555 | 9 years ago |
|
d6ee3d8995 | 9 years ago |
|
c3fa79e450 | 9 years ago |
|
15b10c9a7e | 9 years ago |
|
e19d76398e | 9 years ago |
|
535b4d50c7 | 9 years ago |
|
461f38d599 | 9 years ago |
|
60571e65dd | 9 years ago |
|
34cecb276a | 9 years ago |
|
903ba919b3 | 9 years ago |
|
3184d34440 | 9 years ago |
|
d7099cb863 | 9 years ago |
|
e3f04f3389 | 9 years ago |
|
e59404737d | 9 years ago |
|
ca30707789 | 9 years ago |
|
5449411d09 | 9 years ago |
@ -0,0 +1,7 @@
|
||||
[bumpversion]
|
||||
commit = True
|
||||
tag = True
|
||||
current_version = 0.12.0
|
||||
|
||||
[bumpversion:file:setup.py]
|
||||
|
@ -1,2 +1,5 @@
|
||||
[MESSAGES CONTROL]
|
||||
disable=invalid-name, missing-docstring, locally-disabled
|
||||
disable=invalid-name, missing-docstring, locally-disabled, unbalanced-tuple-unpacking,no-else-return,fixme,duplicate-code
|
||||
|
||||
[SIMILARITIES]
|
||||
min-similarity-lines=5
|
||||
|
@ -0,0 +1,7 @@
|
||||
import libagent.gpg
|
||||
import libagent.ssh
|
||||
from libagent.device.fake_device import FakeDevice as DeviceType
|
||||
|
||||
ssh_agent = lambda: libagent.ssh.main(DeviceType)
|
||||
gpg_tool = lambda: libagent.gpg.main(DeviceType)
|
||||
gpg_agent = lambda: libagent.gpg.run_agent(DeviceType)
|
@ -0,0 +1,42 @@
|
||||
#!/usr/bin/env python
|
||||
from setuptools import setup
|
||||
|
||||
print('NEVER USE THIS CODE FOR REAL-LIFE USE-CASES!!!')
|
||||
print('ONLY FOR DEBUGGING AND TESTING!!!')
|
||||
|
||||
setup(
|
||||
name='fake_device_agent',
|
||||
version='0.9.0',
|
||||
description='Testing trezor_agent with a fake device - NOT SAFE!!!',
|
||||
author='Roman Zeyde',
|
||||
author_email='roman.zeyde@gmail.com',
|
||||
url='http://github.com/romanz/trezor-agent',
|
||||
scripts=['fake_device_agent.py'],
|
||||
install_requires=[
|
||||
'libagent>=0.9.0',
|
||||
],
|
||||
platforms=['POSIX'],
|
||||
classifiers=[
|
||||
'Environment :: Console',
|
||||
'Development Status :: 4 - Beta',
|
||||
'Intended Audience :: Developers',
|
||||
'Intended Audience :: Information Technology',
|
||||
'Intended Audience :: System Administrators',
|
||||
'License :: OSI Approved :: GNU Lesser General Public License v3 (LGPLv3)',
|
||||
'Operating System :: POSIX',
|
||||
'Programming Language :: Python :: 2.7',
|
||||
'Programming Language :: Python :: 3.4',
|
||||
'Programming Language :: Python :: 3.5',
|
||||
'Programming Language :: Python :: 3.6',
|
||||
'Topic :: Software Development :: Libraries :: Python Modules',
|
||||
'Topic :: System :: Networking',
|
||||
'Topic :: Communications',
|
||||
'Topic :: Security',
|
||||
'Topic :: Utilities',
|
||||
],
|
||||
entry_points={'console_scripts': [
|
||||
'fake-device-agent = fake_device_agent:ssh_agent',
|
||||
'fake-device-gpg = fake_device_agent:gpg_tool',
|
||||
'fake-device-gpg-agent = fake_device_agent:gpg_agent',
|
||||
]},
|
||||
)
|
@ -0,0 +1,5 @@
|
||||
import libagent.gpg
|
||||
import libagent.ssh
|
||||
from libagent.device import keepkey
|
||||
|
||||
ssh_agent = lambda: libagent.ssh.main(keepkey.KeepKey)
|
@ -0,0 +1,38 @@
|
||||
#!/usr/bin/env python
|
||||
from setuptools import setup
|
||||
|
||||
setup(
|
||||
name='keepkey_agent',
|
||||
version='0.9.0',
|
||||
description='Using KeepKey as hardware SSH/GPG agent',
|
||||
author='Roman Zeyde',
|
||||
author_email='roman.zeyde@gmail.com',
|
||||
url='http://github.com/romanz/trezor-agent',
|
||||
scripts=['keepkey_agent.py'],
|
||||
install_requires=[
|
||||
'libagent>=0.9.0',
|
||||
'keepkey>=0.7.3'
|
||||
],
|
||||
platforms=['POSIX'],
|
||||
classifiers=[
|
||||
'Environment :: Console',
|
||||
'Development Status :: 4 - Beta',
|
||||
'Intended Audience :: Developers',
|
||||
'Intended Audience :: Information Technology',
|
||||
'Intended Audience :: System Administrators',
|
||||
'License :: OSI Approved :: GNU Lesser General Public License v3 (LGPLv3)',
|
||||
'Operating System :: POSIX',
|
||||
'Programming Language :: Python :: 2.7',
|
||||
'Programming Language :: Python :: 3.4',
|
||||
'Programming Language :: Python :: 3.5',
|
||||
'Programming Language :: Python :: 3.6',
|
||||
'Topic :: Software Development :: Libraries :: Python Modules',
|
||||
'Topic :: System :: Networking',
|
||||
'Topic :: Communications',
|
||||
'Topic :: Security',
|
||||
'Topic :: Utilities',
|
||||
],
|
||||
entry_points={'console_scripts': [
|
||||
'keepkey-agent = keepkey_agent:ssh_agent',
|
||||
]},
|
||||
)
|
@ -0,0 +1,7 @@
|
||||
import libagent.gpg
|
||||
import libagent.ssh
|
||||
from libagent.device.ledger import LedgerNanoS as DeviceType
|
||||
|
||||
ssh_agent = lambda: libagent.ssh.main(DeviceType)
|
||||
gpg_tool = lambda: libagent.gpg.main(DeviceType)
|
||||
gpg_agent = lambda: libagent.gpg.run_agent(DeviceType)
|
@ -0,0 +1,40 @@
|
||||
#!/usr/bin/env python
|
||||
from setuptools import setup
|
||||
|
||||
setup(
|
||||
name='ledger_agent',
|
||||
version='0.9.0',
|
||||
description='Using Ledger as hardware SSH/GPG agent',
|
||||
author='Roman Zeyde',
|
||||
author_email='roman.zeyde@gmail.com',
|
||||
url='http://github.com/romanz/trezor-agent',
|
||||
scripts=['ledger_agent.py'],
|
||||
install_requires=[
|
||||
'libagent>=0.9.0',
|
||||
'ledgerblue>=0.1.8'
|
||||
],
|
||||
platforms=['POSIX'],
|
||||
classifiers=[
|
||||
'Environment :: Console',
|
||||
'Development Status :: 4 - Beta',
|
||||
'Intended Audience :: Developers',
|
||||
'Intended Audience :: Information Technology',
|
||||
'Intended Audience :: System Administrators',
|
||||
'License :: OSI Approved :: GNU Lesser General Public License v3 (LGPLv3)',
|
||||
'Operating System :: POSIX',
|
||||
'Programming Language :: Python :: 2.7',
|
||||
'Programming Language :: Python :: 3.4',
|
||||
'Programming Language :: Python :: 3.5',
|
||||
'Programming Language :: Python :: 3.6',
|
||||
'Topic :: Software Development :: Libraries :: Python Modules',
|
||||
'Topic :: System :: Networking',
|
||||
'Topic :: Communications',
|
||||
'Topic :: Security',
|
||||
'Topic :: Utilities',
|
||||
],
|
||||
entry_points={'console_scripts': [
|
||||
'ledger-agent = ledger_agent:ssh_agent',
|
||||
'ledger-gpg = ledger_agent:gpg_tool',
|
||||
'ledger-gpg-agent = ledger_agent:gpg_agent',
|
||||
]},
|
||||
)
|
@ -0,0 +1,40 @@
|
||||
#!/usr/bin/env python
|
||||
from setuptools import setup
|
||||
|
||||
setup(
|
||||
name='trezor_agent',
|
||||
version='0.9.3',
|
||||
description='Using Trezor as hardware SSH/GPG agent',
|
||||
author='Roman Zeyde',
|
||||
author_email='roman.zeyde@gmail.com',
|
||||
url='http://github.com/romanz/trezor-agent',
|
||||
scripts=['trezor_agent.py'],
|
||||
install_requires=[
|
||||
'libagent>=0.11.2',
|
||||
'trezor[hidapi]>=0.9.0'
|
||||
],
|
||||
platforms=['POSIX'],
|
||||
classifiers=[
|
||||
'Environment :: Console',
|
||||
'Development Status :: 4 - Beta',
|
||||
'Intended Audience :: Developers',
|
||||
'Intended Audience :: Information Technology',
|
||||
'Intended Audience :: System Administrators',
|
||||
'License :: OSI Approved :: GNU Lesser General Public License v3 (LGPLv3)',
|
||||
'Operating System :: POSIX',
|
||||
'Programming Language :: Python :: 2.7',
|
||||
'Programming Language :: Python :: 3.4',
|
||||
'Programming Language :: Python :: 3.5',
|
||||
'Programming Language :: Python :: 3.6',
|
||||
'Topic :: Software Development :: Libraries :: Python Modules',
|
||||
'Topic :: System :: Networking',
|
||||
'Topic :: Communications',
|
||||
'Topic :: Security',
|
||||
'Topic :: Utilities',
|
||||
],
|
||||
entry_points={'console_scripts': [
|
||||
'trezor-agent = trezor_agent:ssh_agent',
|
||||
'trezor-gpg = trezor_agent:gpg_tool',
|
||||
'trezor-gpg-agent = trezor_agent:gpg_agent',
|
||||
]},
|
||||
)
|
@ -0,0 +1,7 @@
|
||||
import libagent.gpg
|
||||
import libagent.ssh
|
||||
from libagent.device.trezor import Trezor as DeviceType
|
||||
|
||||
ssh_agent = lambda: libagent.ssh.main(DeviceType)
|
||||
gpg_tool = lambda: libagent.gpg.main(DeviceType)
|
||||
gpg_agent = lambda: libagent.gpg.run_agent(DeviceType)
|
@ -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:])
|
@ -0,0 +1,51 @@
|
||||
# Design
|
||||
|
||||
Most cryptographic tools (such as gpg, ssh and openssl) allow the offloading of some key cryptographic steps to *engines* or *agents*. This is to allow sensitive operations, such as asking for a password or doing the actual encryption step, to be kept separate from the larger body of code. This makes it easier to secure those steps, move them onto hardware or easier to audit.
|
||||
|
||||
SSH and GPG do this by means of a simple interprocess communication protocol (usually a unix domain socket) and an agent (`ssh-agent`) or GPG key daemon (`gpg-agent`). The `trezor-agent` mimics these two protocols.
|
||||
|
||||
These two agents make the connection between the front end (e.g. a `gpg --sign` command, or an `ssh user@fqdn`). And then they wait for a request from the 'front end', and then do the actual asking for a password and subsequent using the private key to sign or decrypt something.
|
||||
|
||||
The various hardware wallets (Trezor, KeepKey and Ledger) each have the ability (as of Firmware 1.3.4) to use the NIST P-256 elliptic curve to sign, encrypt or decrypt. This curve can be used with S/MIME, GPG and SSH.
|
||||
|
||||
So when you `ssh` to a machine - rather than consult the normal ssh-agent (which in turn will use your private SSH key in files such as `~/.ssh/id_rsa`) -- the trezor-agent will aks your hardware wallet to use its private key to sign the challenge.
|
||||
|
||||
## Key Naming
|
||||
|
||||
`trezor-agent` goes to great length to avoid using the valuable parent key.
|
||||
|
||||
The rationale behind this is that `trezor-agent` is to some extent condemned to *blindly* signing any NONCE given to it (e.g. as part of a challenge respone, or as the hash/hmac of someting to sign).
|
||||
|
||||
And doing so with the master private key is risky - as rogue (ssh) server could possibly provide a doctored NONCE that happens to be tied to a transaction or something else.
|
||||
|
||||
It therefore uses only derived child keys pairs instead (according to the [BIP-0032: Hierarchical Deterministic Wallets][1] system) - and ones on different leafs. So the parent key is only used within the device for creating the child keys - and not exposed in any way to `trezor-agent`.
|
||||
|
||||
### SSH
|
||||
|
||||
It is common for SSH users to use one (or a few) private keys with SSH on all servers they log into. The `trezor-agent` is slightly more cautious and derives a child key that is *unique* to the server and username you are logging into from your master private key on the device.
|
||||
|
||||
So taking a commmand such as:
|
||||
|
||||
$ trezor-agent -c user@fqdn.com
|
||||
|
||||
The `trezor-agent` will take the `user`@`fqdn.com`; canonicalise it (e.g. to add the ssh default port number if none was specified) and then apply some simple hashing (See [SLIP-0013 : Authentication using deterministic hierarchy][2]). The resulting 128bit hash is then used to construct a lead 'HD node' that contains an extened public private *child* key.
|
||||
|
||||
This way they keypair is specific to the server/hostname/port and protocol combination used. And it is this private key that is used to sign the nonce passed by the SSH server (as opposed to the master key).
|
||||
|
||||
The `trezor-agent` then instructs SSH to connect to the server. It will then engage in the normal challenge response process, ask the hardware wallet to blindly sign any nonce flashed by the server with the derived child private key and return this to the server. It then hands over to normal SSH for the rest of the logged in session.
|
||||
|
||||
### GPG
|
||||
|
||||
GPG uses much the same approach as SSH, except in this case it relies on [SLIP-0017 : ECDH using deterministic hierarchy][3] for the mapping to an ECDH key and it maps these to the normal GPG child key infrastructure.
|
||||
|
||||
Note: Keepkey does not support en-/de-cryption at this time.
|
||||
|
||||
### Index
|
||||
|
||||
The canonicalisation process ([SLIP-0013][2] and [SLIP-0017][3]) of an email address or ssh address allows for the mixing in of an extra 'index' - a unsigned 32 bit number. This allows one to have multiple, different keys, for the same address.
|
||||
|
||||
This feature is currently not used -- it is set to '0'. This may change in the future.
|
||||
|
||||
[1]: https://github.com/bitcoin/bips/blob/master/bip-0032.mediawiki
|
||||
[2]: https://github.com/satoshilabs/slips/blob/master/slip-0013.md
|
||||
[3]: https://github.com/satoshilabs/slips/blob/master/slip-0017.md
|
@ -0,0 +1,141 @@
|
||||
# Installation
|
||||
|
||||
## 1. Prerequisites
|
||||
|
||||
Install the following packages (depending on your distribution):
|
||||
|
||||
### OS dependencies
|
||||
|
||||
This software needs Python, libusb, and libudev along with development files.
|
||||
|
||||
You can install them on these distributions as follows:
|
||||
|
||||
##### Debian
|
||||
|
||||
$ apt-get install python3-pip python3-dev python3-tk libusb-1.0-0-dev libudev-dev
|
||||
|
||||
##### RedHat
|
||||
|
||||
$ yum install python3-pip python3-devel python3-tk libusb-devel libudev-devel \
|
||||
gcc redhat-rpm-config
|
||||
|
||||
##### Fedora
|
||||
|
||||
$ dnf install python3-pip python3-devel python3-tkinter libusb-devel libudev-devel \
|
||||
gcc redhat-rpm-config
|
||||
|
||||
##### OpenSUSE
|
||||
|
||||
$ zypper install python-pip python-devel python-tk libusb-1_0-devel libudev-devel
|
||||
|
||||
If you are using python3 or your system `pip` command points to `pip3.x`
|
||||
(`/etc/alternatives/pip -> /usr/bin/pip3.6`) you will need to install these
|
||||
dependencies instead:
|
||||
|
||||
$ zypper install python3-pip python3-devel python3-tk libusb-1_0-devel libudev-devel
|
||||
|
||||
##### macOS
|
||||
|
||||
There are many different options to install python environment on macOS ([official](https://www.python.org/downloads/mac-osx/), [anaconda](https://conda.io/docs/user-guide/install/macos.html), ..). Most importantly you need `libusb`. Probably the easiest way is via [homebrew](https://brew.sh/)
|
||||
|
||||
$ brew install libusb
|
||||
|
||||
### GPG
|
||||
|
||||
If you intend to use GPG make sure you have GPG installed and up to date. This software requires a GPG version >= 2.1.11.
|
||||
|
||||
You can verify your installed version by running:
|
||||
```
|
||||
$ gpg2 --version | head -n1
|
||||
gpg (GnuPG) 2.1.15
|
||||
```
|
||||
|
||||
* Follow this installation guide for [Debian](https://gist.github.com/vt0r/a2f8c0bcb1400131ff51)
|
||||
* Install GPG for [macOS](https://sourceforge.net/p/gpgosx/docu/Download/)
|
||||
* Install packages for Ubuntu 16.04 [here](https://launchpad.net/ubuntu/+source/gnupg2)
|
||||
* Install packages for Linux Mint 18 [here](https://community.linuxmint.com/software/view/gnupg2)
|
||||
|
||||
# 2. Install the TREZOR agent
|
||||
|
||||
1. Make sure you are running the latest firmware version on your Trezor:
|
||||
|
||||
* [TREZOR firmware releases](https://wallet.trezor.io/data/firmware/releases.json): `1.4.2+`
|
||||
|
||||
2. Make sure that your `udev` rules are configured [correctly](https://doc.satoshilabs.com/trezor-user/settingupchromeonlinux.html#manual-configuration-of-udev-rules).
|
||||
|
||||
3. Then, install the latest [trezor_agent](https://pypi.python.org/pypi/trezor_agent) package:
|
||||
|
||||
```
|
||||
$ pip3 install Cython hidapi
|
||||
$ pip3 install trezor_agent
|
||||
```
|
||||
|
||||
Or, directly from the latest source code:
|
||||
|
||||
```
|
||||
$ git clone https://github.com/romanz/trezor-agent
|
||||
$ pip3 install --user -e trezor-agent/agents/trezor
|
||||
```
|
||||
|
||||
Or, through Homebrew on macOS:
|
||||
|
||||
```
|
||||
$ brew install trezor-agent
|
||||
```
|
||||
|
||||
# 3. Install the KeepKey agent
|
||||
|
||||
1. Make sure you are running the latest firmware version on your KeepKey:
|
||||
|
||||
* [KeepKey firmware releases](https://github.com/keepkey/keepkey-firmware/releases): `3.0.17+`
|
||||
|
||||
2. Make sure that your `udev` rules are configured [correctly](https://support.keepkey.com/support/solutions/articles/6000037796-keepkey-wallet-is-not-being-recognized-by-linux).
|
||||
Then, install the latest [keepkey_agent](https://pypi.python.org/pypi/keepkey_agent) package:
|
||||
|
||||
```
|
||||
$ pip3 install keepkey_agent
|
||||
```
|
||||
|
||||
Or, on Mac using Homebrew:
|
||||
|
||||
```
|
||||
$ homebrew install keepkey-agent
|
||||
```
|
||||
|
||||
Or, directly from the latest source code:
|
||||
|
||||
```
|
||||
$ git clone https://github.com/romanz/trezor-agent
|
||||
$ pip3 install --user -e trezor-agent/agents/keepkey
|
||||
```
|
||||
|
||||
# 4. Install the Ledger Nano S agent
|
||||
|
||||
1. Make sure you are running the latest firmware version on your Ledger Nano S:
|
||||
|
||||
* [Ledger Nano S firmware releases](https://github.com/LedgerHQ/blue-app-ssh-agent): `0.0.3+` (install [SSH/PGP Agent](https://www.ledgerwallet.com/images/apps/chrome-mngr-apps.png) app)
|
||||
|
||||
2. Make sure that your `udev` rules are configured [correctly](https://ledger.zendesk.com/hc/en-us/articles/115005165269-What-if-Ledger-Wallet-is-not-recognized-on-Linux-).
|
||||
3. Then, install the latest [ledger_agent](https://pypi.python.org/pypi/ledger_agent) package:
|
||||
|
||||
```
|
||||
$ pip3 install ledger_agent
|
||||
```
|
||||
|
||||
Or, directly from the latest source code:
|
||||
|
||||
```
|
||||
$ git clone https://github.com/romanz/trezor-agent
|
||||
$ pip3 install --user -e trezor-agent/agents/ledger
|
||||
```
|
||||
|
||||
# 5. Installation Troubleshooting
|
||||
|
||||
If there is an import problem with the installed `protobuf` package,
|
||||
see [this issue](https://github.com/romanz/trezor-agent/issues/28) for fixing it.
|
||||
|
||||
If you can't find the command-line utilities (after running `pip install --user`),
|
||||
please make sure that `~/.local/bin` is on your `PATH` variable
|
||||
(see a [relevant](https://github.com/pypa/pip/issues/3813) issue).
|
||||
|
||||
If you can't find command-line utilities and are on macOS/OSX check `~/Library/Python/2.7/bin` and add to `PATH` if necessary (see a [relevant](https://github.com/romanz/trezor-agent/issues/155) issue).
|
@ -0,0 +1,251 @@
|
||||
# GPG Agent
|
||||
|
||||
Note: the GPG-related code is still under development, so please try the current implementation
|
||||
and please let me [know](https://github.com/romanz/trezor-agent/issues/new) if something doesn't
|
||||
work well for you. If possible:
|
||||
|
||||
* record the session (e.g. using [asciinema](https://asciinema.org))
|
||||
* attach the GPG agent log from `~/.gnupg/{trezor,ledger}/gpg-agent.log` (can be [encrypted](https://keybase.io/romanz))
|
||||
|
||||
Thanks!
|
||||
|
||||
## 1. Configuration
|
||||
|
||||
1. Initialize the agent GPG directory.
|
||||
|
||||
[![asciicast](https://asciinema.org/a/3iNw2L9QWB8R3EVdYdAxMOLK8.png)](https://asciinema.org/a/3iNw2L9QWB8R3EVdYdAxMOLK8)
|
||||
|
||||
Run
|
||||
|
||||
```
|
||||
$ (trezor|keepkey|ledger)-gpg init "Roman Zeyde <roman.zeyde@gmail.com>"
|
||||
```
|
||||
|
||||
Follow the instructions provided to complete the setup. Keep note of the timestamp value which you'll need if you want to regenerate the key later.
|
||||
|
||||
If you'd like a Trezor-style PIN entry program, follow [these instructions](README-PINENTRY.md).
|
||||
|
||||
2. Add `export GNUPGHOME=~/.gnupg/(trezor|keepkey|ledger)` to your `.bashrc` or other environment file.
|
||||
|
||||
This `GNUPGHOME` contains your hardware keyring and agent settings. This agent software assumes all keys are backed by hardware devices so you can't use standard GPG keys in `GNUPGHOME` (if you do mix keys you'll receive an error when you attempt to use them).
|
||||
|
||||
If you wish to switch back to your software keys unset `GNUPGHOME`.
|
||||
|
||||
3. Log out and back into your session to ensure your environment is updated everywhere.
|
||||
|
||||
## 2. Usage
|
||||
|
||||
You can use any GPG commands or software that uses GPG as usual and will be prompted to interact with your hardware device as necessary. The agent is automatically started if it isn't running when you run any `gpg` command.
|
||||
|
||||
##### Restarting the agent
|
||||
|
||||
If you change settings or need to restart the agent for some other reason, simply kill it. It will restart the next time GPG is invoked.
|
||||
|
||||
## 3. Common Use Cases
|
||||
|
||||
### Sign and decrypt files
|
||||
|
||||
[![asciicast](https://asciinema.org/a/120441.png)](https://asciinema.org/a/120441)
|
||||
|
||||
### Inspect GPG keys
|
||||
You can use GNU Privacy Assistant (GPA) in order to inspect the created keys and perform signature and decryption operations as usual:
|
||||
|
||||
```
|
||||
$ sudo apt install gpa
|
||||
$ gpa
|
||||
```
|
||||
|
||||
[![GPA](https://cloud.githubusercontent.com/assets/9900/20224804/053d7474-a849-11e6-87f3-ab07dc536158.png)](https://www.gnupg.org/related_software/swlist.html#gpa)
|
||||
|
||||
### Sign Git commits and tags
|
||||
|
||||
Git can use GPG to sign and verify commits and tags (see [here](https://git-scm.com/book/en/v2/Git-Tools-Signing-Your-Work)):
|
||||
|
||||
```
|
||||
$ git config --local commit.gpgsign 1
|
||||
$ git config --local gpg.program $(which gpg2)
|
||||
$ git commit --gpg-sign # create GPG-signed commit
|
||||
$ git log --show-signature -1 # verify commit signature
|
||||
$ git tag v1.2.3 --sign # create GPG-signed tag
|
||||
$ git tag v1.2.3 --verify # verify tag signature
|
||||
```
|
||||
|
||||
Note that your git email has to correlate to your gpg key email. If you use a different email for git, you'll need to either generate a new gpg key for that email or set your git email using the command:
|
||||
|
||||
````
|
||||
$ git config user.email foo@example.com
|
||||
````
|
||||
|
||||
If your git email is configured incorrectly, you will receive the error:
|
||||
|
||||
````
|
||||
error: gpg failed to sign the data
|
||||
fatal: failed to write commit object
|
||||
````
|
||||
|
||||
when committing to git.
|
||||
|
||||
### Manage passwords
|
||||
|
||||
Password managers such as [pass](https://www.passwordstore.org/) and [gopass](https://www.justwatch.com/gopass/) rely on GPG for encryption so you can use your device with them too.
|
||||
|
||||
##### With `pass`:
|
||||
|
||||
First install `pass` from [passwordstore.org] and initialize it to use your TREZOR-based GPG identity:
|
||||
```
|
||||
$ pass init "Roman Zeyde <roman.zeyde@gmail.com>"
|
||||
Password store initialized for Roman Zeyde <roman.zeyde@gmail.com>
|
||||
```
|
||||
Then, you can generate truly random passwords and save them encrypted using your public key (as separate `.gpg` files under `~/.password-store/`):
|
||||
```
|
||||
$ pass generate Dev/github 32
|
||||
$ pass generate Social/hackernews 32
|
||||
$ pass generate Social/twitter 32
|
||||
$ pass generate VPS/linode 32
|
||||
$ pass
|
||||
Password Store
|
||||
├── Dev
|
||||
│ └── github
|
||||
├── Social
|
||||
│ ├── hackernews
|
||||
│ └── twitter
|
||||
└── VPS
|
||||
└── linode
|
||||
```
|
||||
In order to paste them into the browser, you'd need to decrypt the password using your hardware device:
|
||||
```
|
||||
$ pass --clip VPS/linode
|
||||
Copied VPS/linode to clipboard. Will clear in 45 seconds.
|
||||
```
|
||||
|
||||
You can also use the following [Qt-based UI](https://qtpass.org/) for `pass`:
|
||||
```
|
||||
$ sudo apt install qtpass
|
||||
```
|
||||
|
||||
### Re-generate a GPG identity
|
||||
[![asciicast](https://asciinema.org/a/5tIQa5qt5bV134oeOqFyKEU29.png)](https://asciinema.org/a/5tIQa5qt5bV134oeOqFyKEU29)
|
||||
|
||||
If you've forgotten the timestamp value, but still have access to the public key, then you can
|
||||
retrieve the timestamp with the following command (substitute "john@doe.bit" for the key's address or id):
|
||||
|
||||
```
|
||||
$ gpg2 --export 'john@doe.bit' | gpg2 --list-packets | grep created | head -n1
|
||||
```
|
||||
|
||||
### Add new UIDs to your identity
|
||||
|
||||
After your main identity is created, you can add new user IDs using the regular GnuPG commands:
|
||||
```
|
||||
$ trezor-gpg init "Foobar" -vv
|
||||
$ export GNUPGHOME=${HOME}/.gnupg/trezor
|
||||
$ gpg2 -K
|
||||
------------------------------------------
|
||||
sec nistp256/6275E7DA 2017-12-05 [SC]
|
||||
uid [ultimate] Foobar
|
||||
ssb nistp256/35F58F26 2017-12-05 [E]
|
||||
|
||||
$ gpg2 --edit Foobar
|
||||
gpg> adduid
|
||||
Real name: Xyzzy
|
||||
Email address:
|
||||
Comment:
|
||||
You selected this USER-ID:
|
||||
"Xyzzy"
|
||||
|
||||
Change (N)ame, (C)omment, (E)mail or (O)kay/(Q)uit? o
|
||||
|
||||
gpg> save
|
||||
|
||||
$ gpg2 -K
|
||||
------------------------------------------
|
||||
sec nistp256/6275E7DA 2017-12-05 [SC]
|
||||
uid [ultimate] Xyzzy
|
||||
uid [ultimate] Foobar
|
||||
ssb nistp256/35F58F26 2017-12-05 [E]
|
||||
```
|
||||
|
||||
### Generate GnuPG subkeys
|
||||
In order to add TREZOR-based subkey to an existing GnuPG identity, use the `--subkey` flag:
|
||||
```
|
||||
$ gpg2 -k foobar
|
||||
pub rsa2048/90C4064B 2017-10-10 [SC]
|
||||
uid [ultimate] foobar
|
||||
sub rsa2048/4DD05FF0 2017-10-10 [E]
|
||||
|
||||
$ trezor-gpg init "foobar" --subkey
|
||||
```
|
||||
|
||||
[![asciicast](https://asciinema.org/a/Ick5G724zrZRFsGY7ZUdFSnV1.png)](https://asciinema.org/a/Ick5G724zrZRFsGY7ZUdFSnV1)
|
||||
|
||||
In order to enter existing GPG passphrase, I recommend installing and using a graphical Pinentry:
|
||||
```
|
||||
$ sudo apt install pinentry-gnome3
|
||||
$ sudo update-alternatives --config pinentry
|
||||
There are 4 choices for the alternative pinentry (providing /usr/bin/pinentry).
|
||||
|
||||
Selection Path Priority Status
|
||||
------------------------------------------------------------
|
||||
* 0 /usr/bin/pinentry-gnome3 90 auto mode
|
||||
1 /usr/bin/pinentry-curses 50 manual mode
|
||||
2 /usr/bin/pinentry-gnome3 90 manual mode
|
||||
3 /usr/bin/pinentry-qt 80 manual mode
|
||||
4 /usr/bin/pinentry-tty 30 manual mode
|
||||
|
||||
Press <enter> to keep the current choice[*], or type selection number: 0
|
||||
```
|
||||
|
||||
### Sign and decrypt email
|
||||
|
||||
Follow [these instructions](enigmail.md) to set up Enigmail in Thunderbird.
|
||||
|
||||
### Start the agent as a systemd unit
|
||||
|
||||
##### 1. Create these files in `~/.config/systemd/user`
|
||||
|
||||
Replace `trezor` with `keepkey` or `ledger` as required.
|
||||
|
||||
###### `trezor-gpg-agent.service`
|
||||
|
||||
````
|
||||
[Unit]
|
||||
Description=trezor-gpg-agent
|
||||
Requires=trezor-gpg-agent.socket
|
||||
|
||||
[Service]
|
||||
Type=Simple
|
||||
Environment="GNUPGHOME=%h/.gnupg/trezor"
|
||||
Environment="PATH=/bin:/usr/bin:/usr/local/bin:%h/.local/bin"
|
||||
ExecStart=/usr/bin/trezor-gpg-agent -vv
|
||||
````
|
||||
|
||||
If you've installed `trezor-agent` locally you may have to change the path in `ExecStart=`.
|
||||
|
||||
###### `trezor-gpg-agent.socket`
|
||||
|
||||
````
|
||||
[Unit]
|
||||
Description=trezor-gpg-agent socket
|
||||
|
||||
[Socket]
|
||||
ListenStream=%t/gnupg/S.gpg-agent
|
||||
FileDescriptorName=std
|
||||
SocketMode=0600
|
||||
DirectoryMode=0700
|
||||
|
||||
[Install]
|
||||
WantedBy=sockets.target
|
||||
````
|
||||
|
||||
##### 2. Stop trezor-gpg-agent if it's already running
|
||||
|
||||
```
|
||||
killall trezor-gpg-agent
|
||||
```
|
||||
|
||||
##### 3. Run
|
||||
|
||||
```
|
||||
systemctl --user start trezor-gpg-agent.service trezor-gpg-agent.socket
|
||||
systemctl --user enable trezor-gpg-agent.socket
|
||||
```
|
@ -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]
|
||||
```
|
@ -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)
|
||||
```
|
@ -0,0 +1,209 @@
|
||||
# SSH Agent
|
||||
|
||||
## 1. Configuration
|
||||
|
||||
SSH requires no configuration, but you may put common command line options in `~/.ssh/agent.conf` to avoid repeating them in every invocation.
|
||||
|
||||
See `(trezor|keepkey|ledger)-agent -h` for details on supported options and the configuration file format.
|
||||
|
||||
If you'd like a Trezor-style PIN entry program, follow [these instructions](README-PINENTRY.md).
|
||||
|
||||
## 2. Usage
|
||||
|
||||
Use the `(trezor|keepkey|ledger)-agent` program to work with SSH. It has three main modes of operation:
|
||||
|
||||
##### 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)-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)-agent identity@myhost -- COMMAND --WITH --ARGUMENTS
|
||||
```
|
||||
|
||||
to start the agent in the background and execute the command with environment variables set up to use the SSH agent. The specified identity is used for all SSH connections. The agent will exit after the command completes.
|
||||
Note the `--` separator, which is used to separate `trezor-agent`'s arguments from the SSH command arguments.
|
||||
|
||||
As a shortcut you can run
|
||||
|
||||
```
|
||||
$ (trezor|keepkey|ledger)-agent identity@myhost -s
|
||||
```
|
||||
|
||||
to start a shell with the proper environment.
|
||||
|
||||
##### 2. Connect to a server directly via `(trezor|keepkey|ledger)-agent`
|
||||
|
||||
If you just want to connect to a server this is the simplest way to do it:
|
||||
|
||||
```
|
||||
$ (trezor|keepkey|ledger)-agent user@remotehost -c
|
||||
```
|
||||
|
||||
The identity `user@remotehost` is used as both the destination user and host as well as for key derivation, so you must generate a separate key for each host you connect to.
|
||||
|
||||
## 3. Common Use Cases
|
||||
|
||||
### Start a single SSH session
|
||||
[![Demo](https://asciinema.org/a/22959.png)](https://asciinema.org/a/22959)
|
||||
|
||||
### Start multiple SSH sessions from a sub-shell
|
||||
|
||||
This feature allows using regular SSH-related commands within a subprocess running user's shell.
|
||||
`SSH_AUTH_SOCK` environment variable is defined for the subprocess (pointing to the SSH agent, running as a parent process).
|
||||
This way the user can use SSH-related commands (e.g. `ssh`, `ssh-add`, `sshfs`, `git`, `hg`), while authenticating via the hardware device.
|
||||
|
||||
[![Subshell](https://asciinema.org/a/33240.png)](https://asciinema.org/a/33240)
|
||||
|
||||
### Load different SSH identities from configuration file
|
||||
|
||||
[![Config](https://asciinema.org/a/bdxxtgctk5syu56yfz8lcp7ny.png)](https://asciinema.org/a/bdxxtgctk5syu56yfz8lcp7ny)
|
||||
|
||||
### Implement passwordless login
|
||||
|
||||
Run:
|
||||
|
||||
/tmp $ trezor-agent user@ssh.hostname.com -v > hostname.pub
|
||||
2015-09-02 15:03:18,929 INFO getting "ssh://user@ssh.hostname.com" public key from Trezor...
|
||||
2015-09-02 15:03:23,342 INFO disconnected from Trezor
|
||||
/tmp $ cat hostname.pub
|
||||
ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBGSevcDwmT+QaZPUEWUUjTeZRBICChxMKuJ7dRpBSF8+qt+8S1GBK5Zj8Xicc8SHG/SE/EXKUL2UU3kcUzE7ADQ= ssh://user@ssh.hostname.com
|
||||
|
||||
Append `hostname.pub` contents to `/home/user/.ssh/authorized_keys`
|
||||
configuration file at `ssh.hostname.com`, so the remote server
|
||||
would allow you to login using the corresponding private key signature.
|
||||
|
||||
### Access remote Git/Mercurial repositories
|
||||
|
||||
Export your public key and register it in your repository web interface
|
||||
(e.g. [GitHub](https://help.github.com/articles/adding-a-new-ssh-key-to-your-github-account/)):
|
||||
|
||||
$ trezor-agent -v -e ed25519 git@github.com > ~/.ssh/github.pub
|
||||
|
||||
Add the following configuration to your `~/.ssh/config` file:
|
||||
|
||||
Host github.com
|
||||
IdentityFile ~/.ssh/github.pub
|
||||
|
||||
Use the following Bash alias for convenient Git operations:
|
||||
|
||||
$ alias ssh-shell='trezor-agent ~/.ssh/github.pub -v --shell'
|
||||
|
||||
Now, you can use regular Git commands under the "SSH-enabled" sub-shell:
|
||||
|
||||
$ ssh-shell
|
||||
$ git push origin master
|
||||
|
||||
The same works for Mercurial (e.g. on [BitBucket](https://confluence.atlassian.com/bitbucket/set-up-ssh-for-mercurial-728138122.html)):
|
||||
|
||||
$ ssh-shell
|
||||
$ hg push
|
||||
|
||||
### Start the agent as a systemd unit
|
||||
|
||||
##### 1. Create these files in `~/.config/systemd/user`
|
||||
|
||||
Replace `trezor` with `keepkey` or `ledger` as required.
|
||||
|
||||
###### `trezor-ssh-agent.service`
|
||||
|
||||
````
|
||||
[Unit]
|
||||
Description=trezor-agent SSH agent
|
||||
Requires=trezor-ssh-agent.socket
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
Environment="DISPLAY=:0"
|
||||
Environment="PATH=/bin:/usr/bin:/usr/local/bin:%h/.local/bin"
|
||||
ExecStart=/usr/bin/trezor-agent --foreground --sock-path %t/trezor-agent/S.ssh IDENTITY
|
||||
````
|
||||
|
||||
If you've installed `trezor-agent` locally you may have to change the path in `ExecStart=`.
|
||||
|
||||
Replace `IDENTITY` with the identity you used when exporting the public key.
|
||||
|
||||
If you have multiple Trezors connected, you can select which one to use via a `TREZOR_PATH`
|
||||
environment variable. Use `trezorctl list` to find the correct path. Then add it
|
||||
to the agent with the following line:
|
||||
````
|
||||
Environment="TREZOR_PATH=<your path here>"
|
||||
````
|
||||
Note that USB paths depend on the _USB port_ which you use.
|
||||
|
||||
###### `trezor-ssh-agent.socket`
|
||||
|
||||
````
|
||||
[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.*")
|
||||
```
|
||||
|
||||
##### 4. SSH will now automatically use your device key in all terminals.
|
||||
|
||||
## 4. Troubleshooting
|
||||
|
||||
If SSH connection fails to work, please open an [issue](https://github.com/romanz/trezor-agent/issues)
|
||||
with a verbose log attached (by running `trezor-agent -vv`) .
|
||||
|
||||
##### `IdentitiesOnly` SSH option
|
||||
|
||||
Note that your local SSH configuration may ignore `trezor-agent`, if it has `IdentitiesOnly` option set to `yes`.
|
||||
|
||||
IdentitiesOnly
|
||||
Specifies that ssh(1) should only use the authentication identity files configured in
|
||||
the ssh_config files, even if ssh-agent(1) or a PKCS11Provider offers more identities.
|
||||
The argument to this keyword must be “yes” or “no”.
|
||||
This option is intended for situations where ssh-agent offers many different identities.
|
||||
The default is “no”.
|
||||
|
||||
If you are failing to connect, save your public key using:
|
||||
|
||||
$ trezor-agent -vv foobar@hostname.com > ~/.ssh/hostname.pub
|
||||
|
||||
And add the following lines to `~/.ssh/config` (providing the public key explicitly to SSH):
|
||||
|
||||
Host hostname.com
|
||||
User foobar
|
||||
IdentityFile ~/.ssh/hostname.pub
|
||||
|
||||
Then, the following commands should successfully command to the remote host:
|
||||
|
||||
$ trezor-agent -v foobar@hostname.com -s
|
||||
$ ssh foobar@hostname.com
|
||||
|
||||
or,
|
||||
|
||||
$ trezor-agent -v foobar@hostname.com -c
|
@ -0,0 +1,26 @@
|
||||
# Tutorial
|
||||
|
||||
First, install [Thunderbird](https://www.mozilla.org/en-US/thunderbird/) and
|
||||
the [Enigmail](https://www.enigmail.net/index.php/en/) add-on.
|
||||
|
||||
Make sure to use the correct GNUPGHOME path before starting Thunderbird:
|
||||
```bash
|
||||
$ export GNUPGHOME=${HOME}/.gnupg/trezor
|
||||
$ thunderbird
|
||||
```
|
||||
Run the Enigmail's setup wizard and choose your GPG identity:
|
||||
![01](https://user-images.githubusercontent.com/9900/31327339-47a5f69a-acd7-11e7-997c-7b5a286fe5bc.png)
|
||||
![02](https://user-images.githubusercontent.com/9900/31327344-51dcd246-acd7-11e7-8cdc-dd305a512dbb.png)
|
||||
![03](https://user-images.githubusercontent.com/9900/31327346-546862a0-acd7-11e7-8e00-b40994bd6f17.png)
|
||||
|
||||
Then, you can compose encrypted (and signed) messages using the regular UI:
|
||||
|
||||
NOTES:
|
||||
- The email's title is **public** - only the body is encrypted.
|
||||
- You will be asked to confirm the signature using the hardware device before sending the email.
|
||||
|
||||
![04](https://user-images.githubusercontent.com/9900/31327356-660d098e-acd7-11e7-9e43-762898f5b57e.png)
|
||||
![05](https://user-images.githubusercontent.com/9900/31327365-76679dda-acd7-11e7-9403-6965f0c6d8fe.png)
|
||||
|
||||
After receiving the email, you will be asked to confirm the decryption the hardware device:
|
||||
![06](https://user-images.githubusercontent.com/9900/31327371-7c1da4cc-acd7-11e7-9a5a-20accf621b49.png)
|
@ -0,0 +1 @@
|
||||
"""SSH-agent implementation using hardware authentication devices."""
|
@ -0,0 +1,3 @@
|
||||
"""Cryptographic hardware device management."""
|
||||
|
||||
from . import interface, ui
|
@ -0,0 +1,74 @@
|
||||
"""Fake device - ONLY FOR TESTS!!! (NEVER USE WITH REAL DATA)."""
|
||||
|
||||
import hashlib
|
||||
import logging
|
||||
|
||||
import ecdsa
|
||||
|
||||
from . import interface
|
||||
from .. import formats
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _verify_support(identity):
|
||||
"""Make sure the device supports given configuration."""
|
||||
if identity.curve_name not in {formats.CURVE_NIST256}:
|
||||
raise NotImplementedError(
|
||||
'Unsupported elliptic curve: {}'.format(identity.curve_name))
|
||||
|
||||
|
||||
class FakeDevice(interface.Device):
|
||||
"""Connection to TREZOR device."""
|
||||
|
||||
@classmethod
|
||||
def package_name(cls):
|
||||
"""Python package name."""
|
||||
return 'fake-device-agent'
|
||||
|
||||
def connect(self):
|
||||
"""Return "dummy" connection."""
|
||||
log.critical('NEVER USE THIS CODE FOR REAL-LIFE USE-CASES!!!')
|
||||
log.critical('ONLY FOR DEBUGGING AND TESTING!!!')
|
||||
# The code below uses HARD-CODED secret key - and should be used ONLY
|
||||
# for GnuPG integration tests (e.g. when no real device is available).
|
||||
# pylint: disable=attribute-defined-outside-init
|
||||
self.secexp = 1
|
||||
self.sk = ecdsa.SigningKey.from_secret_exponent(
|
||||
secexp=self.secexp, curve=ecdsa.curves.NIST256p, hashfunc=hashlib.sha256)
|
||||
self.vk = self.sk.get_verifying_key()
|
||||
return self
|
||||
|
||||
def close(self):
|
||||
"""Close connection."""
|
||||
self.conn = None
|
||||
|
||||
def pubkey(self, identity, ecdh=False):
|
||||
"""Return public key."""
|
||||
_verify_support(identity)
|
||||
data = self.vk.to_string()
|
||||
x, y = data[:32], data[32:]
|
||||
prefix = bytearray([2 + (bytearray(y)[0] & 1)])
|
||||
return bytes(prefix) + x
|
||||
|
||||
def sign(self, identity, blob):
|
||||
"""Sign given blob and return the signature (as bytes)."""
|
||||
if identity.identity_dict['proto'] in {'ssh'}:
|
||||
digest = hashlib.sha256(blob).digest()
|
||||
else:
|
||||
digest = blob
|
||||
return self.sk.sign_digest_deterministic(digest=digest,
|
||||
hashfunc=hashlib.sha256)
|
||||
|
||||
def ecdh(self, identity, pubkey):
|
||||
"""Get shared session key using Elliptic Curve Diffie-Hellman."""
|
||||
assert pubkey[:1] == b'\x04'
|
||||
peer = ecdsa.VerifyingKey.from_string(
|
||||
pubkey[1:],
|
||||
curve=ecdsa.curves.NIST256p,
|
||||
hashfunc=hashlib.sha256)
|
||||
shared = ecdsa.VerifyingKey.from_public_point(
|
||||
point=(peer.pubkey.point * self.secexp),
|
||||
curve=ecdsa.curves.NIST256p,
|
||||
hashfunc=hashlib.sha256)
|
||||
return shared.to_string()
|
@ -0,0 +1,143 @@
|
||||
"""Device abstraction layer."""
|
||||
|
||||
import hashlib
|
||||
import io
|
||||
import logging
|
||||
import re
|
||||
import struct
|
||||
|
||||
import unidecode
|
||||
|
||||
from .. import formats, util
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
_identity_regexp = re.compile(''.join([
|
||||
'^'
|
||||
r'(?:(?P<proto>.*)://)?',
|
||||
r'(?:(?P<user>.*)@)?',
|
||||
r'(?P<host>.*?)',
|
||||
r'(?::(?P<port>\w*))?',
|
||||
r'(?P<path>/.*)?',
|
||||
'$'
|
||||
]))
|
||||
|
||||
|
||||
def string_to_identity(identity_str):
|
||||
"""Parse string into Identity dictionary."""
|
||||
m = _identity_regexp.match(identity_str)
|
||||
result = m.groupdict()
|
||||
log.debug('parsed identity: %s', result)
|
||||
return {k: v for k, v in result.items() if v}
|
||||
|
||||
|
||||
def identity_to_string(identity_dict):
|
||||
"""Dump Identity dictionary into its string representation."""
|
||||
result = []
|
||||
if identity_dict.get('proto'):
|
||||
result.append(identity_dict['proto'] + '://')
|
||||
if identity_dict.get('user'):
|
||||
result.append(identity_dict['user'] + '@')
|
||||
result.append(identity_dict['host'])
|
||||
if identity_dict.get('port'):
|
||||
result.append(':' + identity_dict['port'])
|
||||
if identity_dict.get('path'):
|
||||
result.append(identity_dict['path'])
|
||||
log.debug('identity parts: %s', result)
|
||||
return ''.join(result)
|
||||
|
||||
|
||||
class Error(Exception):
|
||||
"""Device-related error."""
|
||||
|
||||
|
||||
class NotFoundError(Error):
|
||||
"""Device could not be found."""
|
||||
|
||||
|
||||
class DeviceError(Error):
|
||||
"""Error during device operation."""
|
||||
|
||||
|
||||
class Identity:
|
||||
"""Represent SLIP-0013 identity, together with a elliptic curve choice."""
|
||||
|
||||
def __init__(self, identity_str, curve_name):
|
||||
"""Configure for specific identity and elliptic curve usage."""
|
||||
self.identity_dict = string_to_identity(identity_str)
|
||||
self.curve_name = curve_name
|
||||
|
||||
def items(self):
|
||||
"""Return a copy of identity_dict items."""
|
||||
return [(k, unidecode.unidecode(v))
|
||||
for k, v in self.identity_dict.items()]
|
||||
|
||||
def to_bytes(self):
|
||||
"""Transliterate Unicode into ASCII."""
|
||||
s = identity_to_string(self.identity_dict)
|
||||
return unidecode.unidecode(s).encode('ascii')
|
||||
|
||||
def to_string(self):
|
||||
"""Return identity serialized to string."""
|
||||
return u'<{}|{}>'.format(identity_to_string(self.identity_dict), self.curve_name)
|
||||
|
||||
def get_bip32_address(self, ecdh=False):
|
||||
"""Compute BIP32 derivation address according to SLIP-0013/0017."""
|
||||
index = struct.pack('<L', self.identity_dict.get('index', 0))
|
||||
addr = index + self.to_bytes()
|
||||
log.debug('bip32 address string: %r', addr)
|
||||
digest = hashlib.sha256(addr).digest()
|
||||
s = io.BytesIO(bytearray(digest))
|
||||
|
||||
hardened = 0x80000000
|
||||
addr_0 = 17 if bool(ecdh) else 13
|
||||
address_n = [addr_0] + list(util.recv(s, '<LLLL'))
|
||||
return [(hardened | value) for value in address_n]
|
||||
|
||||
def get_curve_name(self, ecdh=False):
|
||||
"""Return correct curve name for device operations."""
|
||||
if ecdh:
|
||||
return formats.get_ecdh_curve_name(self.curve_name)
|
||||
else:
|
||||
return self.curve_name
|
||||
|
||||
|
||||
class Device:
|
||||
"""Abstract cryptographic hardware device interface."""
|
||||
|
||||
def __init__(self):
|
||||
"""C-tor."""
|
||||
self.conn = None
|
||||
|
||||
def connect(self):
|
||||
"""Connect to device, otherwise raise NotFoundError."""
|
||||
raise NotImplementedError()
|
||||
|
||||
def __enter__(self):
|
||||
"""Allow usage as context manager."""
|
||||
self.conn = self.connect()
|
||||
return self
|
||||
|
||||
def __exit__(self, *args):
|
||||
"""Close and mark as disconnected."""
|
||||
try:
|
||||
self.conn.close()
|
||||
except Exception as e: # pylint: disable=broad-except
|
||||
log.exception('close failed: %s', e)
|
||||
self.conn = None
|
||||
|
||||
def pubkey(self, identity, ecdh=False):
|
||||
"""Get public key (as bytes)."""
|
||||
raise NotImplementedError()
|
||||
|
||||
def sign(self, identity, blob):
|
||||
"""Sign given blob and return the signature (as bytes)."""
|
||||
raise NotImplementedError()
|
||||
|
||||
def ecdh(self, identity, pubkey):
|
||||
"""Get shared session key using Elliptic Curve Diffie-Hellman."""
|
||||
raise NotImplementedError()
|
||||
|
||||
def __str__(self):
|
||||
"""Human-readable representation."""
|
||||
return '{}'.format(self.__class__.__name__)
|
@ -0,0 +1,45 @@
|
||||
"""KeepKey-related code (see https://www.keepkey.com/)."""
|
||||
|
||||
from . import trezor
|
||||
from .. import formats
|
||||
|
||||
|
||||
def _verify_support(identity, ecdh):
|
||||
"""Make sure the device supports given configuration."""
|
||||
protocol = identity.identity_dict['proto']
|
||||
if protocol not in {'ssh'}:
|
||||
raise NotImplementedError(
|
||||
'Unsupported protocol: {}'.format(protocol))
|
||||
if ecdh:
|
||||
raise NotImplementedError('No support for ECDH')
|
||||
if identity.curve_name not in {formats.CURVE_NIST256}:
|
||||
raise NotImplementedError(
|
||||
'Unsupported elliptic curve: {}'.format(identity.curve_name))
|
||||
|
||||
|
||||
class KeepKey(trezor.Trezor):
|
||||
"""Connection to KeepKey device."""
|
||||
|
||||
@classmethod
|
||||
def package_name(cls):
|
||||
"""Python package name (at PyPI)."""
|
||||
return 'keepkey-agent'
|
||||
|
||||
@property
|
||||
def _defs(self):
|
||||
from . import keepkey_defs
|
||||
return keepkey_defs
|
||||
|
||||
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)
|
||||
return trezor.Trezor.pubkey(self, identity=identity, ecdh=ecdh)
|
||||
|
||||
def ecdh(self, identity, pubkey):
|
||||
"""No support for ECDH in KeepKey firmware."""
|
||||
_verify_support(identity, ecdh=True)
|
@ -0,0 +1,14 @@
|
||||
"""KeepKey-related definitions."""
|
||||
|
||||
# pylint: disable=unused-import,import-error
|
||||
|
||||
from keepkeylib.client import CallException, PinException
|
||||
from keepkeylib.client import KeepKeyClient as Client
|
||||
from keepkeylib.messages_pb2 import PassphraseAck, PinMatrixAck
|
||||
from keepkeylib.transport_hid import HidTransport
|
||||
from keepkeylib.types_pb2 import IdentityType
|
||||
|
||||
|
||||
def find_device():
|
||||
"""Returns first USB HID transport."""
|
||||
return next(HidTransport(p) for p in HidTransport.enumerate())
|
@ -0,0 +1,122 @@
|
||||
"""Ledger-related code (see https://www.ledgerwallet.com/)."""
|
||||
|
||||
import binascii
|
||||
import logging
|
||||
import struct
|
||||
|
||||
from ledgerblue import comm # pylint: disable=import-error
|
||||
|
||||
from . import interface
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _expand_path(path):
|
||||
"""Convert BIP32 path into bytes."""
|
||||
return b''.join((struct.pack('>I', e) for e in path))
|
||||
|
||||
|
||||
def _convert_public_key(ecdsa_curve_name, result):
|
||||
"""Convert Ledger reply into PublicKey object."""
|
||||
if ecdsa_curve_name == 'nist256p1':
|
||||
if (result[64] & 1) != 0:
|
||||
result = bytearray([0x03]) + result[1:33]
|
||||
else:
|
||||
result = bytearray([0x02]) + result[1:33]
|
||||
else:
|
||||
result = result[1:]
|
||||
keyX = bytearray(result[0:32])
|
||||
keyY = bytearray(result[32:][::-1])
|
||||
if (keyX[31] & 1) != 0:
|
||||
keyY[31] |= 0x80
|
||||
result = b'\x00' + bytes(keyY)
|
||||
return bytes(result)
|
||||
|
||||
|
||||
class LedgerNanoS(interface.Device):
|
||||
"""Connection to Ledger Nano S device."""
|
||||
|
||||
@classmethod
|
||||
def package_name(cls):
|
||||
"""Python package name (at PyPI)."""
|
||||
return 'ledger-agent'
|
||||
|
||||
def connect(self):
|
||||
"""Enumerate and connect to the first USB HID interface."""
|
||||
try:
|
||||
return comm.getDongle()
|
||||
except comm.CommException as e:
|
||||
raise interface.NotFoundError(
|
||||
'{} not connected: "{}"'.format(self, e))
|
||||
|
||||
def pubkey(self, identity, ecdh=False):
|
||||
"""Get PublicKey object for specified BIP32 address and elliptic curve."""
|
||||
curve_name = identity.get_curve_name(ecdh)
|
||||
path = _expand_path(identity.get_bip32_address(ecdh))
|
||||
if curve_name == 'nist256p1':
|
||||
p2 = '01'
|
||||
else:
|
||||
p2 = '02'
|
||||
apdu = '800200' + p2
|
||||
apdu = binascii.unhexlify(apdu)
|
||||
apdu += bytearray([len(path) + 1, len(path) // 4])
|
||||
apdu += path
|
||||
log.debug('apdu: %r', apdu)
|
||||
result = bytearray(self.conn.exchange(bytes(apdu)))
|
||||
log.debug('result: %r', result)
|
||||
return _convert_public_key(curve_name, result[1:])
|
||||
|
||||
def sign(self, identity, blob):
|
||||
"""Sign given blob and return the signature (as bytes)."""
|
||||
path = _expand_path(identity.get_bip32_address(ecdh=False))
|
||||
if identity.identity_dict['proto'] == 'ssh':
|
||||
ins = '04'
|
||||
p1 = '00'
|
||||
else:
|
||||
ins = '08'
|
||||
p1 = '00'
|
||||
if identity.curve_name == 'nist256p1':
|
||||
p2 = '81' if identity.identity_dict['proto'] == 'ssh' else '01'
|
||||
else:
|
||||
p2 = '82' if identity.identity_dict['proto'] == 'ssh' else '02'
|
||||
apdu = '80' + ins + p1 + p2
|
||||
apdu = binascii.unhexlify(apdu)
|
||||
apdu += bytearray([len(blob) + len(path) + 1])
|
||||
apdu += bytearray([len(path) // 4]) + path
|
||||
apdu += blob
|
||||
log.debug('apdu: %r', apdu)
|
||||
result = bytearray(self.conn.exchange(bytes(apdu)))
|
||||
log.debug('result: %r', result)
|
||||
if identity.curve_name == 'nist256p1':
|
||||
offset = 3
|
||||
length = result[offset]
|
||||
r = result[offset+1:offset+1+length]
|
||||
if r[0] == 0:
|
||||
r = r[1:]
|
||||
offset = offset + 1 + length + 1
|
||||
length = result[offset]
|
||||
s = result[offset+1:offset+1+length]
|
||||
if s[0] == 0:
|
||||
s = s[1:]
|
||||
offset = offset + 1 + length
|
||||
return bytes(r) + bytes(s)
|
||||
else:
|
||||
return bytes(result[:64])
|
||||
|
||||
def ecdh(self, identity, pubkey):
|
||||
"""Get shared session key using Elliptic Curve Diffie-Hellman."""
|
||||
path = _expand_path(identity.get_bip32_address(ecdh=True))
|
||||
if identity.curve_name == 'nist256p1':
|
||||
p2 = '01'
|
||||
else:
|
||||
p2 = '02'
|
||||
apdu = '800a00' + p2
|
||||
apdu = binascii.unhexlify(apdu)
|
||||
apdu += bytearray([len(pubkey) + len(path) + 1])
|
||||
apdu += bytearray([len(path) // 4]) + path
|
||||
apdu += pubkey
|
||||
log.debug('apdu: %r', apdu)
|
||||
result = bytearray(self.conn.exchange(bytes(apdu)))
|
||||
log.debug('result: %r', result)
|
||||
assert result[0] == 0x04
|
||||
return bytes(result)
|
@ -0,0 +1,191 @@
|
||||
"""TREZOR-related code (see http://bitcointrezor.com/)."""
|
||||
|
||||
import binascii
|
||||
import logging
|
||||
|
||||
import mnemonic
|
||||
import semver
|
||||
|
||||
from . import interface
|
||||
from .. import util
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Trezor(interface.Device):
|
||||
"""Connection to TREZOR device."""
|
||||
|
||||
@classmethod
|
||||
def package_name(cls):
|
||||
"""Python package name (at PyPI)."""
|
||||
return 'trezor-agent'
|
||||
|
||||
@property
|
||||
def _defs(self):
|
||||
from . import trezor_defs
|
||||
return trezor_defs
|
||||
|
||||
required_version = '>=1.4.0'
|
||||
|
||||
ui = None # can be overridden by device's users
|
||||
|
||||
def _override_pin_handler(self, conn):
|
||||
if self.ui is None:
|
||||
return
|
||||
|
||||
def new_handler(_):
|
||||
try:
|
||||
scrambled_pin = self.ui.get_pin()
|
||||
result = self._defs.PinMatrixAck(pin=scrambled_pin)
|
||||
if not set(scrambled_pin).issubset('123456789'):
|
||||
raise self._defs.PinException(
|
||||
None, 'Invalid scrambled PIN: {!r}'.format(result.pin))
|
||||
return result
|
||||
except: # noqa
|
||||
conn.init_device()
|
||||
raise
|
||||
|
||||
conn.callback_PinMatrixRequest = new_handler
|
||||
|
||||
cached_passphrase_ack = util.ExpiringCache(seconds=float('inf'))
|
||||
cached_state = None
|
||||
|
||||
def _override_passphrase_handler(self, conn):
|
||||
if self.ui is None:
|
||||
return
|
||||
|
||||
def new_handler(msg):
|
||||
try:
|
||||
if msg.on_device is True:
|
||||
return self._defs.PassphraseAck()
|
||||
ack = self.__class__.cached_passphrase_ack.get()
|
||||
if ack:
|
||||
log.debug('re-using cached %s passphrase', self)
|
||||
return ack
|
||||
|
||||
passphrase = self.ui.get_passphrase()
|
||||
passphrase = mnemonic.Mnemonic.normalize_string(passphrase)
|
||||
ack = self._defs.PassphraseAck(passphrase=passphrase)
|
||||
|
||||
length = len(ack.passphrase)
|
||||
if length > 50:
|
||||
msg = 'Too long passphrase ({} chars)'.format(length)
|
||||
raise ValueError(msg)
|
||||
|
||||
self.__class__.cached_passphrase_ack.set(ack)
|
||||
return ack
|
||||
except: # noqa
|
||||
conn.init_device()
|
||||
raise
|
||||
|
||||
conn.callback_PassphraseRequest = new_handler
|
||||
|
||||
def _override_state_handler(self, conn):
|
||||
def callback_PassphraseStateRequest(msg):
|
||||
log.debug('caching state from %r', msg)
|
||||
self.__class__.cached_state = msg.state
|
||||
return self._defs.PassphraseStateAck()
|
||||
|
||||
conn.callback_PassphraseStateRequest = callback_PassphraseStateRequest
|
||||
|
||||
def _verify_version(self, connection):
|
||||
f = connection.features
|
||||
log.debug('connected to %s %s', self, f.device_id)
|
||||
log.debug('label : %s', f.label)
|
||||
log.debug('vendor : %s', f.vendor)
|
||||
current_version = '{}.{}.{}'.format(f.major_version,
|
||||
f.minor_version,
|
||||
f.patch_version)
|
||||
log.debug('version : %s', current_version)
|
||||
log.debug('revision : %s', binascii.hexlify(f.revision))
|
||||
if not semver.match(current_version, self.required_version):
|
||||
fmt = ('Please upgrade your {} firmware to {} version'
|
||||
' (current: {})')
|
||||
raise ValueError(fmt.format(self, self.required_version,
|
||||
current_version))
|
||||
|
||||
def connect(self):
|
||||
"""Enumerate and connect to the first available interface."""
|
||||
transport = self._defs.find_device()
|
||||
if not transport:
|
||||
raise interface.NotFoundError('{} not connected'.format(self))
|
||||
|
||||
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,
|
||||
state=self.__class__.cached_state)
|
||||
self._override_pin_handler(connection)
|
||||
self._override_passphrase_handler(connection)
|
||||
self._override_state_handler(connection)
|
||||
self._verify_version(connection)
|
||||
|
||||
try:
|
||||
connection.ping(msg='', pin_protection=True) # unlock PIN
|
||||
return connection
|
||||
except (self._defs.PinException, ValueError) as e:
|
||||
log.error('Invalid PIN: %s, retrying...', e)
|
||||
continue
|
||||
except Exception as e:
|
||||
log.exception('ping failed: %s', e)
|
||||
connection.close() # so the next HID open() will succeed
|
||||
raise
|
||||
|
||||
def close(self):
|
||||
"""Close connection."""
|
||||
self.conn.close()
|
||||
|
||||
def pubkey(self, identity, ecdh=False):
|
||||
"""Return public key."""
|
||||
curve_name = identity.get_curve_name(ecdh=ecdh)
|
||||
log.debug('"%s" getting public key (%s) from %s',
|
||||
identity.to_string(), curve_name, self)
|
||||
addr = identity.get_bip32_address(ecdh=ecdh)
|
||||
result = self.conn.get_public_node(
|
||||
n=addr, ecdsa_curve_name=curve_name)
|
||||
log.debug('result: %s', result)
|
||||
return bytes(result.node.public_key)
|
||||
|
||||
def _identity_proto(self, identity):
|
||||
result = self._defs.IdentityType()
|
||||
for name, value in identity.items():
|
||||
setattr(result, name, value)
|
||||
return result
|
||||
|
||||
def sign(self, identity, blob):
|
||||
"""Sign given blob and return the signature (as bytes)."""
|
||||
curve_name = identity.get_curve_name(ecdh=False)
|
||||
log.debug('"%s" signing %r (%s) on %s',
|
||||
identity.to_string(), blob, curve_name, self)
|
||||
try:
|
||||
result = self.conn.sign_identity(
|
||||
identity=self._identity_proto(identity),
|
||||
challenge_hidden=blob,
|
||||
challenge_visual='',
|
||||
ecdsa_curve_name=curve_name)
|
||||
log.debug('result: %s', result)
|
||||
assert len(result.signature) == 65
|
||||
assert result.signature[:1] == b'\x00'
|
||||
return bytes(result.signature[1:])
|
||||
except self._defs.CallException as e:
|
||||
msg = '{} error: {}'.format(self, e)
|
||||
log.debug(msg, exc_info=True)
|
||||
raise interface.DeviceError(msg)
|
||||
|
||||
def ecdh(self, identity, pubkey):
|
||||
"""Get shared session key using Elliptic Curve Diffie-Hellman."""
|
||||
curve_name = identity.get_curve_name(ecdh=True)
|
||||
log.debug('"%s" shared session key (%s) for %r from %s',
|
||||
identity.to_string(), curve_name, pubkey, self)
|
||||
try:
|
||||
result = self.conn.get_ecdh_session_key(
|
||||
identity=self._identity_proto(identity),
|
||||
peer_public_key=pubkey,
|
||||
ecdsa_curve_name=curve_name)
|
||||
log.debug('result: %s', result)
|
||||
assert len(result.session_key) in {65, 33} # NIST256 or Curve25519
|
||||
assert result.session_key[:1] == b'\x04'
|
||||
return bytes(result.session_key)
|
||||
except self._defs.CallException as e:
|
||||
msg = '{} error: {}'.format(self, e)
|
||||
log.debug(msg, exc_info=True)
|
||||
raise interface.DeviceError(msg)
|
@ -0,0 +1,28 @@
|
||||
"""TREZOR-related definitions."""
|
||||
|
||||
# pylint: disable=unused-import,import-error
|
||||
import os
|
||||
import logging
|
||||
|
||||
from trezorlib.client import CallException, PinException
|
||||
from trezorlib.client import TrezorClient as Client
|
||||
from trezorlib.messages import IdentityType, PassphraseAck, PinMatrixAck, PassphraseStateAck
|
||||
|
||||
try:
|
||||
from trezorlib.transport import get_transport
|
||||
except ImportError:
|
||||
from trezorlib.device import TrezorDevice
|
||||
get_transport = TrezorDevice.find_by_path
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def find_device():
|
||||
"""Selects a transport based on `TREZOR_PATH` environment variable.
|
||||
|
||||
If unset, picks first connected device.
|
||||
"""
|
||||
try:
|
||||
return get_transport(os.environ.get("TREZOR_PATH"))
|
||||
except Exception as e: # pylint: disable=broad-except
|
||||
log.debug("Failed to find a Trezor device: %s", e)
|
@ -0,0 +1,129 @@
|
||||
"""UIs for PIN/passphrase entry."""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import subprocess
|
||||
|
||||
from .. import util
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class UI:
|
||||
"""UI for PIN/passphrase entry (for TREZOR devices)."""
|
||||
|
||||
def __init__(self, device_type, config=None):
|
||||
"""C-tor."""
|
||||
default_pinentry = 'pinentry' # by default, use GnuPG pinentry tool
|
||||
if config is None:
|
||||
config = {}
|
||||
self.pin_entry_binary = config.get('pin_entry_binary',
|
||||
default_pinentry)
|
||||
self.passphrase_entry_binary = config.get('passphrase_entry_binary',
|
||||
default_pinentry)
|
||||
self.options_getter = create_default_options_getter()
|
||||
self.device_name = device_type.__name__
|
||||
|
||||
def get_pin(self, name=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(name or self.device_name),
|
||||
prompt='PIN:',
|
||||
description=description,
|
||||
binary=self.pin_entry_binary,
|
||||
options=self.options_getter())
|
||||
|
||||
def get_passphrase(self, name=None):
|
||||
"""Ask the user for passphrase."""
|
||||
return interact(
|
||||
title='{} passphrase'.format(name or self.device_name),
|
||||
prompt='Passphrase:',
|
||||
description=None,
|
||||
binary=self.passphrase_entry_binary,
|
||||
options=self.options_getter())
|
||||
|
||||
|
||||
def create_default_options_getter():
|
||||
"""Return current TTY and DISPLAY settings for GnuPG pinentry."""
|
||||
options = []
|
||||
try:
|
||||
ttyname = subprocess.check_output(args=['tty']).strip()
|
||||
options.append(b'ttyname=' + ttyname)
|
||||
except subprocess.CalledProcessError as e:
|
||||
log.warning('no TTY found: %s', e)
|
||||
|
||||
display = os.environ.get('DISPLAY')
|
||||
if display is not None:
|
||||
options.append('display={}'.format(display).encode('ascii'))
|
||||
else:
|
||||
log.warning('DISPLAY not defined')
|
||||
|
||||
log.info('using %s for pinentry options', options)
|
||||
return lambda: options
|
||||
|
||||
|
||||
def write(p, line):
|
||||
"""Send and flush a single line to the subprocess' stdin."""
|
||||
log.debug('%s <- %r', p.args, line)
|
||||
p.stdin.write(line)
|
||||
p.stdin.flush()
|
||||
|
||||
|
||||
class UnexpectedError(Exception):
|
||||
"""Unexpected response."""
|
||||
|
||||
|
||||
def expect(p, prefixes, confidential=False):
|
||||
"""Read a line and return it without required prefix."""
|
||||
resp = p.stdout.readline()
|
||||
log.debug('%s -> %r', p.args, resp if not confidential else '********')
|
||||
for prefix in prefixes:
|
||||
if resp.startswith(prefix):
|
||||
return resp[len(prefix):]
|
||||
raise UnexpectedError(resp)
|
||||
|
||||
|
||||
def interact(title, description, prompt, binary, options):
|
||||
"""Use GPG pinentry program to interact with the user."""
|
||||
args = [binary]
|
||||
p = subprocess.Popen(args=args,
|
||||
stdin=subprocess.PIPE,
|
||||
stdout=subprocess.PIPE,
|
||||
env=os.environ)
|
||||
p.args = args # TODO: remove after Python 2 deprecation.
|
||||
expect(p, [b'OK'])
|
||||
|
||||
title = util.assuan_serialize(title.encode('ascii'))
|
||||
write(p, b'SETTITLE ' + title + b'\n')
|
||||
expect(p, [b'OK'])
|
||||
|
||||
if description:
|
||||
description = util.assuan_serialize(description.encode('ascii'))
|
||||
write(p, b'SETDESC ' + description + b'\n')
|
||||
expect(p, [b'OK'])
|
||||
|
||||
if prompt:
|
||||
prompt = util.assuan_serialize(prompt.encode('ascii'))
|
||||
write(p, b'SETPROMPT ' + prompt + b'\n')
|
||||
expect(p, [b'OK'])
|
||||
|
||||
log.debug('setting %d options', len(options))
|
||||
for opt in options:
|
||||
write(p, b'OPTION ' + opt + b'\n')
|
||||
expect(p, [b'OK', b'ERR'])
|
||||
|
||||
write(p, b'GETPIN\n')
|
||||
pin = expect(p, [b'OK', b'D '], confidential=True)
|
||||
|
||||
p.communicate() # close stdin and wait for the process to exit
|
||||
exit_code = p.wait()
|
||||
if exit_code:
|
||||
raise subprocess.CalledProcessError(exit_code, binary)
|
||||
|
||||
return pin.decode('ascii').strip()
|
@ -0,0 +1,212 @@
|
||||
"""SSH format parsing and formatting tools."""
|
||||
import base64
|
||||
import hashlib
|
||||
import io
|
||||
import logging
|
||||
|
||||
import ecdsa
|
||||
import ed25519
|
||||
|
||||
from . import util
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
# Supported ECDSA curves (for SSH and GPG)
|
||||
CURVE_NIST256 = 'nist256p1'
|
||||
CURVE_ED25519 = 'ed25519'
|
||||
SUPPORTED_CURVES = {CURVE_NIST256, CURVE_ED25519}
|
||||
|
||||
# Supported ECDH curves (for GPG)
|
||||
ECDH_NIST256 = 'nist256p1'
|
||||
ECDH_CURVE25519 = 'curve25519'
|
||||
|
||||
# SSH key types
|
||||
SSH_NIST256_DER_OCTET = b'\x04'
|
||||
SSH_NIST256_KEY_PREFIX = b'ecdsa-sha2-'
|
||||
SSH_NIST256_CURVE_NAME = b'nistp256'
|
||||
SSH_NIST256_KEY_TYPE = SSH_NIST256_KEY_PREFIX + SSH_NIST256_CURVE_NAME
|
||||
SSH_ED25519_KEY_TYPE = b'ssh-ed25519'
|
||||
SUPPORTED_KEY_TYPES = {SSH_NIST256_KEY_TYPE, SSH_ED25519_KEY_TYPE}
|
||||
|
||||
hashfunc = hashlib.sha256
|
||||
|
||||
|
||||
def fingerprint(blob):
|
||||
"""
|
||||
Compute SSH fingerprint for specified blob.
|
||||
|
||||
See https://en.wikipedia.org/wiki/Public_key_fingerprint for details.
|
||||
"""
|
||||
digest = hashlib.md5(blob).digest()
|
||||
return ':'.join('{:02x}'.format(c) for c in bytearray(digest))
|
||||
|
||||
|
||||
def parse_pubkey(blob):
|
||||
"""
|
||||
Parse SSH public key from given blob.
|
||||
|
||||
Construct a verifier for ECDSA signatures.
|
||||
The verifier returns the signatures in the required SSH format.
|
||||
Currently, NIST256P1 and ED25519 elliptic curves are supported.
|
||||
"""
|
||||
fp = fingerprint(blob)
|
||||
s = io.BytesIO(blob)
|
||||
key_type = util.read_frame(s)
|
||||
log.debug('key type: %s', key_type)
|
||||
assert key_type in SUPPORTED_KEY_TYPES, key_type
|
||||
|
||||
result = {'blob': blob, 'type': key_type, 'fingerprint': fp}
|
||||
|
||||
if key_type == SSH_NIST256_KEY_TYPE:
|
||||
curve_name = util.read_frame(s)
|
||||
log.debug('curve name: %s', curve_name)
|
||||
point = util.read_frame(s)
|
||||
assert s.read() == b''
|
||||
_type, point = point[:1], point[1:]
|
||||
assert _type == SSH_NIST256_DER_OCTET
|
||||
size = len(point) // 2
|
||||
assert len(point) == 2 * size
|
||||
coords = (util.bytes2num(point[:size]), util.bytes2num(point[size:]))
|
||||
|
||||
curve = ecdsa.NIST256p
|
||||
point = ecdsa.ellipticcurve.Point(curve.curve, *coords)
|
||||
|
||||
def ecdsa_verifier(sig, msg):
|
||||
assert len(sig) == 2 * size
|
||||
sig_decode = ecdsa.util.sigdecode_string
|
||||
vk = ecdsa.VerifyingKey.from_public_point(point, curve, hashfunc)
|
||||
vk.verify(signature=sig, data=msg, sigdecode=sig_decode)
|
||||
parts = [sig[:size], sig[size:]]
|
||||
return b''.join([util.frame(b'\x00' + p) for p in parts])
|
||||
|
||||
result.update(point=coords, curve=CURVE_NIST256,
|
||||
verifier=ecdsa_verifier)
|
||||
|
||||
if key_type == SSH_ED25519_KEY_TYPE:
|
||||
pubkey = util.read_frame(s)
|
||||
assert s.read() == b''
|
||||
|
||||
def ed25519_verify(sig, msg):
|
||||
assert len(sig) == 64
|
||||
vk = ed25519.VerifyingKey(pubkey)
|
||||
vk.verify(sig, msg)
|
||||
return sig
|
||||
|
||||
result.update(curve=CURVE_ED25519, verifier=ed25519_verify)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def _decompress_ed25519(pubkey):
|
||||
"""Load public key from the serialized blob (stripping the prefix byte)."""
|
||||
if pubkey[:1] == b'\x00':
|
||||
# set by Trezor fsm_msgSignIdentity() and fsm_msgGetPublicKey()
|
||||
return ed25519.VerifyingKey(pubkey[1:])
|
||||
else:
|
||||
return None
|
||||
|
||||
|
||||
def _decompress_nist256(pubkey):
|
||||
"""
|
||||
Load public key from the serialized blob.
|
||||
|
||||
The leading byte least-significant bit is used to decide how to recreate
|
||||
the y-coordinate from the specified x-coordinate. See bitcoin/main.py#L198
|
||||
(from https://github.com/vbuterin/pybitcointools/) for details.
|
||||
"""
|
||||
if pubkey[:1] in {b'\x02', b'\x03'}: # set by ecdsa_get_public_key33()
|
||||
curve = ecdsa.NIST256p
|
||||
P = curve.curve.p()
|
||||
A = curve.curve.a()
|
||||
B = curve.curve.b()
|
||||
x = util.bytes2num(pubkey[1:33])
|
||||
beta = pow(int(x * x * x + A * x + B), int((P + 1) // 4), int(P))
|
||||
|
||||
p0 = util.bytes2num(pubkey[:1])
|
||||
y = (P - beta) if ((beta + p0) % 2) else beta
|
||||
|
||||
point = ecdsa.ellipticcurve.Point(curve.curve, x, y)
|
||||
return ecdsa.VerifyingKey.from_public_point(point, curve=curve,
|
||||
hashfunc=hashfunc)
|
||||
else:
|
||||
return None
|
||||
|
||||
|
||||
def decompress_pubkey(pubkey, curve_name):
|
||||
"""
|
||||
Load public key from the serialized blob.
|
||||
|
||||
Raise ValueError on parsing error.
|
||||
"""
|
||||
vk = None
|
||||
if len(pubkey) == 33:
|
||||
decompress = {
|
||||
CURVE_NIST256: _decompress_nist256,
|
||||
CURVE_ED25519: _decompress_ed25519,
|
||||
ECDH_CURVE25519: _decompress_ed25519,
|
||||
}[curve_name]
|
||||
vk = decompress(pubkey)
|
||||
|
||||
if not vk:
|
||||
msg = 'invalid {!s} public key: {!r}'.format(curve_name, pubkey)
|
||||
raise ValueError(msg)
|
||||
|
||||
return vk
|
||||
|
||||
|
||||
def serialize_verifying_key(vk):
|
||||
"""
|
||||
Serialize a public key into SSH format (for exporting to text format).
|
||||
|
||||
Currently, NIST256P1 and ED25519 elliptic curves are supported.
|
||||
Raise TypeError on unsupported key format.
|
||||
"""
|
||||
if isinstance(vk, ed25519.keys.VerifyingKey):
|
||||
pubkey = vk.to_bytes()
|
||||
key_type = SSH_ED25519_KEY_TYPE
|
||||
blob = util.frame(SSH_ED25519_KEY_TYPE) + util.frame(pubkey)
|
||||
return key_type, blob
|
||||
|
||||
if isinstance(vk, ecdsa.keys.VerifyingKey):
|
||||
curve_name = SSH_NIST256_CURVE_NAME
|
||||
key_blob = SSH_NIST256_DER_OCTET + vk.to_string()
|
||||
parts = [SSH_NIST256_KEY_TYPE, curve_name, key_blob]
|
||||
key_type = SSH_NIST256_KEY_TYPE
|
||||
blob = b''.join([util.frame(p) for p in parts])
|
||||
return key_type, blob
|
||||
|
||||
raise TypeError('unsupported {!r}'.format(vk))
|
||||
|
||||
|
||||
def export_public_key(vk, label):
|
||||
"""
|
||||
Export public key to text format.
|
||||
|
||||
The resulting string can be written into a .pub file or
|
||||
appended to the ~/.ssh/authorized_keys file.
|
||||
"""
|
||||
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)
|
||||
|
||||
|
||||
def import_public_key(line):
|
||||
"""Parse public key textual format, as saved at a .pub file."""
|
||||
log.debug('loading SSH public key: %r', line)
|
||||
file_type, base64blob, name = line.split()
|
||||
blob = base64.b64decode(base64blob)
|
||||
result = parse_pubkey(blob)
|
||||
result['name'] = name.encode('utf-8')
|
||||
assert result['type'] == file_type.encode('ascii')
|
||||
log.debug('loaded %s public key: %s', file_type, result['fingerprint'])
|
||||
return result
|
||||
|
||||
|
||||
def get_ecdh_curve_name(signature_curve_name):
|
||||
"""Return appropriate curve for ECDH for specified signing curve."""
|
||||
return {
|
||||
CURVE_NIST256: ECDH_NIST256,
|
||||
CURVE_ED25519: ECDH_CURVE25519,
|
||||
ECDH_CURVE25519: ECDH_CURVE25519,
|
||||
}[signature_curve_name]
|
@ -0,0 +1,324 @@
|
||||
"""
|
||||
TREZOR support for ECDSA GPG signatures.
|
||||
|
||||
See these links for more details:
|
||||
- https://www.gnupg.org/faq/whats-new-in-2.1.html
|
||||
- https://tools.ietf.org/html/rfc4880
|
||||
- https://tools.ietf.org/html/rfc6637
|
||||
- https://tools.ietf.org/html/draft-irtf-cfrg-eddsa-05
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import contextlib
|
||||
import functools
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
|
||||
import pkg_resources
|
||||
import semver
|
||||
|
||||
|
||||
from . import agent, client, encode, keyring, protocol
|
||||
from .. import device, formats, server, util
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def export_public_key(device_type, args):
|
||||
"""Generate a new pubkey for a new/existing GPG identity."""
|
||||
log.warning('NOTE: in order to re-generate the exact same GPG key later, '
|
||||
'run this command with "--time=%d" commandline flag (to set '
|
||||
'the timestamp of the GPG key manually).', args.time)
|
||||
c = client.Client(device=device_type())
|
||||
identity = client.create_identity(user_id=args.user_id,
|
||||
curve_name=args.ecdsa_curve)
|
||||
verifying_key = c.pubkey(identity=identity, ecdh=False)
|
||||
decryption_key = c.pubkey(identity=identity, ecdh=True)
|
||||
signer_func = functools.partial(c.sign, identity=identity)
|
||||
|
||||
if args.subkey: # add as subkey
|
||||
log.info('adding %s GPG subkey for "%s" to existing key',
|
||||
args.ecdsa_curve, args.user_id)
|
||||
# subkey for signing
|
||||
signing_key = protocol.PublicKey(
|
||||
curve_name=args.ecdsa_curve, created=args.time,
|
||||
verifying_key=verifying_key, ecdh=False)
|
||||
# subkey for encryption
|
||||
encryption_key = protocol.PublicKey(
|
||||
curve_name=formats.get_ecdh_curve_name(args.ecdsa_curve),
|
||||
created=args.time, verifying_key=decryption_key, ecdh=True)
|
||||
primary_bytes = keyring.export_public_key(args.user_id)
|
||||
result = encode.create_subkey(primary_bytes=primary_bytes,
|
||||
subkey=signing_key,
|
||||
signer_func=signer_func)
|
||||
result = encode.create_subkey(primary_bytes=result,
|
||||
subkey=encryption_key,
|
||||
signer_func=signer_func)
|
||||
else: # add as primary
|
||||
log.info('creating new %s GPG primary key for "%s"',
|
||||
args.ecdsa_curve, args.user_id)
|
||||
# primary key for signing
|
||||
primary = protocol.PublicKey(
|
||||
curve_name=args.ecdsa_curve, created=args.time,
|
||||
verifying_key=verifying_key, ecdh=False)
|
||||
# subkey for encryption
|
||||
subkey = protocol.PublicKey(
|
||||
curve_name=formats.get_ecdh_curve_name(args.ecdsa_curve),
|
||||
created=args.time, verifying_key=decryption_key, ecdh=True)
|
||||
|
||||
result = encode.create_primary(user_id=args.user_id,
|
||||
pubkey=primary,
|
||||
signer_func=signer_func)
|
||||
result = encode.create_subkey(primary_bytes=result,
|
||||
subkey=subkey,
|
||||
signer_func=signer_func)
|
||||
|
||||
return protocol.armor(result, 'PUBLIC KEY BLOCK')
|
||||
|
||||
|
||||
def verify_gpg_version():
|
||||
"""Make sure that the installed GnuPG is not too old."""
|
||||
existing_gpg = keyring.gpg_version().decode('ascii')
|
||||
required_gpg = '>=2.1.11'
|
||||
msg = 'Existing GnuPG has version "{}" ({} required)'.format(existing_gpg,
|
||||
required_gpg)
|
||||
if not semver.match(existing_gpg, required_gpg):
|
||||
log.error(msg)
|
||||
|
||||
|
||||
def check_output(args):
|
||||
"""Runs command and returns the output as string."""
|
||||
log.debug('run: %s', args)
|
||||
out = subprocess.check_output(args=args).decode('utf-8')
|
||||
log.debug('out: %r', out)
|
||||
return out
|
||||
|
||||
|
||||
def check_call(args, stdin=None, env=None):
|
||||
"""Runs command and verifies its success."""
|
||||
log.debug('run: %s%s', args, ' {}'.format(env) if env else '')
|
||||
subprocess.check_call(args=args, stdin=stdin, env=env)
|
||||
|
||||
|
||||
def write_file(path, data):
|
||||
"""Writes data to specified path."""
|
||||
with open(path, 'w') as f:
|
||||
log.debug('setting %s contents:\n%s', path, data)
|
||||
f.write(data)
|
||||
return f
|
||||
|
||||
|
||||
def run_init(device_type, args):
|
||||
"""Initialize hardware-based GnuPG identity."""
|
||||
util.setup_logging(verbosity=args.verbose)
|
||||
log.warning('This GPG tool is still in EXPERIMENTAL mode, '
|
||||
'so please note that the API and features may '
|
||||
'change without backwards compatibility!')
|
||||
|
||||
verify_gpg_version()
|
||||
|
||||
# Prepare new GPG home directory for hardware-based identity
|
||||
device_name = os.path.basename(sys.argv[0]).rsplit('-', 1)[0]
|
||||
log.info('device name: %s', device_name)
|
||||
homedir = args.homedir
|
||||
if not homedir:
|
||||
homedir = os.path.expanduser('~/.gnupg/{}'.format(device_name))
|
||||
|
||||
log.info('GPG home directory: %s', homedir)
|
||||
|
||||
if os.path.exists(homedir):
|
||||
log.error('GPG home directory %s exists, '
|
||||
'remove it manually if required', homedir)
|
||||
sys.exit(1)
|
||||
|
||||
check_call(['mkdir', '-p', homedir])
|
||||
check_call(['chmod', '700', homedir])
|
||||
|
||||
agent_path = util.which('{}-gpg-agent'.format(device_name))
|
||||
|
||||
# Prepare GPG agent invocation script (to pass the PATH from environment).
|
||||
with open(os.path.join(homedir, 'run-agent.sh'), 'w') as f:
|
||||
f.write(r"""#!/bin/sh
|
||||
export PATH={0}
|
||||
{1} \
|
||||
-vv \
|
||||
--pin-entry-binary={pin_entry_binary} \
|
||||
--passphrase-entry-binary={passphrase_entry_binary} \
|
||||
--cache-expiry-seconds={cache_expiry_seconds} \
|
||||
$*
|
||||
""".format(os.environ['PATH'], agent_path, **vars(args)))
|
||||
check_call(['chmod', '700', f.name])
|
||||
run_agent_script = f.name
|
||||
|
||||
# Prepare GPG configuration file
|
||||
with open(os.path.join(homedir, 'gpg.conf'), 'w') as f:
|
||||
f.write("""# Hardware-based GPG configuration
|
||||
agent-program {0}
|
||||
personal-digest-preferences SHA512
|
||||
default-key \"{1}\"
|
||||
""".format(run_agent_script, args.user_id))
|
||||
|
||||
# Prepare a helper script for setting up the new identity
|
||||
with open(os.path.join(homedir, 'env'), 'w') as f:
|
||||
f.write("""#!/bin/bash
|
||||
set -eu
|
||||
export GNUPGHOME={0}
|
||||
COMMAND=$*
|
||||
if [ -z "${{COMMAND}}" ]
|
||||
then
|
||||
${{SHELL}}
|
||||
else
|
||||
${{COMMAND}}
|
||||
fi
|
||||
""".format(homedir))
|
||||
check_call(['chmod', '700', f.name])
|
||||
|
||||
# Generate new GPG identity and import into GPG keyring
|
||||
pubkey = write_file(os.path.join(homedir, 'pubkey.asc'),
|
||||
export_public_key(device_type, args))
|
||||
verbosity = ('-' + ('v' * args.verbose)) if args.verbose else '--quiet'
|
||||
check_call(keyring.gpg_command(['--homedir', homedir, verbosity,
|
||||
'--import', pubkey.name]))
|
||||
|
||||
# Make new GPG identity with "ultimate" trust (via its fingerprint)
|
||||
out = check_output(keyring.gpg_command(['--homedir', homedir,
|
||||
'--list-public-keys',
|
||||
'--with-fingerprint',
|
||||
'--with-colons']))
|
||||
fpr = re.findall('fpr:::::::::([0-9A-F]+):', out)[0]
|
||||
f = write_file(os.path.join(homedir, 'ownertrust.txt'), fpr + ':6\n')
|
||||
check_call(keyring.gpg_command(['--homedir', homedir,
|
||||
'--import-ownertrust', f.name]))
|
||||
|
||||
# Load agent and make sure it responds with the new identity
|
||||
check_call(keyring.gpg_command(['--list-secret-keys', args.user_id,
|
||||
'--homedir', homedir]))
|
||||
|
||||
|
||||
def run_unlock(device_type, args):
|
||||
"""Unlock hardware device (for future interaction)."""
|
||||
util.setup_logging(verbosity=args.verbose)
|
||||
with device_type() as d:
|
||||
log.info('unlocked %s device', d)
|
||||
|
||||
|
||||
def _server_from_assuan_fd(env):
|
||||
fd = env.get('_assuan_connection_fd')
|
||||
if fd is None:
|
||||
return None
|
||||
log.info('using fd=%r for UNIX socket server', fd)
|
||||
return server.unix_domain_socket_server_from_fd(int(fd))
|
||||
|
||||
|
||||
def _server_from_sock_path(env):
|
||||
sock_path = keyring.get_agent_sock_path(env=env)
|
||||
return server.unix_domain_socket_server(sock_path)
|
||||
|
||||
|
||||
def run_agent(device_type):
|
||||
"""Run a simple GPG-agent server."""
|
||||
p = argparse.ArgumentParser()
|
||||
p.add_argument('--homedir', default=os.environ.get('GNUPGHOME'))
|
||||
p.add_argument('-v', '--verbose', default=0, action='count')
|
||||
p.add_argument('--server', default=False, action='store_true',
|
||||
help='Use stdin/stdout for communication with GPG.')
|
||||
|
||||
p.add_argument('--pin-entry-binary', type=str, default='pinentry',
|
||||
help='Path to PIN entry UI helper.')
|
||||
p.add_argument('--passphrase-entry-binary', type=str, default='pinentry',
|
||||
help='Path to passphrase entry UI helper.')
|
||||
p.add_argument('--cache-expiry-seconds', type=float, default=float('inf'),
|
||||
help='Expire passphrase from cache after this duration.')
|
||||
|
||||
args, _ = p.parse_known_args()
|
||||
|
||||
assert args.homedir
|
||||
|
||||
log_file = os.path.join(args.homedir, 'gpg-agent.log')
|
||||
util.setup_logging(verbosity=args.verbose, filename=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, 'PATH': os.environ['PATH']}
|
||||
pubkey_bytes = keyring.export_public_keys(env=env)
|
||||
device_type.ui = device.ui.UI(device_type=device_type,
|
||||
config=vars(args))
|
||||
device_type.cached_passphrase_ack = util.ExpiringCache(
|
||||
seconds=float(args.cache_expiry_seconds))
|
||||
handler = agent.Handler(device=device_type(),
|
||||
pubkey_bytes=pubkey_bytes)
|
||||
|
||||
sock_server = _server_from_assuan_fd(os.environ)
|
||||
if sock_server is None:
|
||||
sock_server = _server_from_sock_path(env)
|
||||
|
||||
with sock_server as sock:
|
||||
for conn in agent.yield_connections(sock):
|
||||
with contextlib.closing(conn):
|
||||
try:
|
||||
handler.handle(conn)
|
||||
except agent.AgentStop:
|
||||
log.info('stopping gpg-agent')
|
||||
return
|
||||
except IOError as e:
|
||||
log.info('connection closed: %s', e)
|
||||
return
|
||||
except Exception as e: # pylint: disable=broad-except
|
||||
log.exception('handler failed: %s', e)
|
||||
|
||||
except Exception as e: # pylint: disable=broad-except
|
||||
log.exception('gpg-agent failed: %s', e)
|
||||
|
||||
|
||||
def main(device_type):
|
||||
"""Parse command-line arguments."""
|
||||
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)}
|
||||
resources = [resources_map[agent_package], resources_map['libagent']]
|
||||
versions = '\n'.join('{}={}'.format(r.key, r.version) for r in resources)
|
||||
parser.add_argument('--version', help='print the version info',
|
||||
action='version', version=versions)
|
||||
|
||||
subparsers = parser.add_subparsers(title='Action', dest='action')
|
||||
subparsers.required = True
|
||||
|
||||
p = subparsers.add_parser('init',
|
||||
help='initialize hardware-based GnuPG identity')
|
||||
p.add_argument('user_id')
|
||||
p.add_argument('-e', '--ecdsa-curve', default='nist256p1')
|
||||
p.add_argument('-t', '--time', type=int, default=int(time.time()))
|
||||
p.add_argument('-v', '--verbose', default=0, action='count')
|
||||
p.add_argument('-s', '--subkey', default=False, action='store_true')
|
||||
|
||||
p.add_argument('--homedir', type=str, default=os.environ.get('GNUPGHOME'),
|
||||
help='Customize GnuPG home directory for the new identity.')
|
||||
|
||||
p.add_argument('--pin-entry-binary', type=str, default='pinentry',
|
||||
help='Path to PIN entry UI helper.')
|
||||
p.add_argument('--passphrase-entry-binary', type=str, default='pinentry',
|
||||
help='Path to passphrase entry UI helper.')
|
||||
p.add_argument('--cache-expiry-seconds', type=float, default=float('inf'),
|
||||
help='Expire passphrase from cache after this duration.')
|
||||
|
||||
p.set_defaults(func=run_init)
|
||||
|
||||
p = subparsers.add_parser('unlock', help='unlock the hardware device')
|
||||
p.add_argument('-v', '--verbose', default=0, action='count')
|
||||
p.set_defaults(func=run_unlock)
|
||||
|
||||
args = parser.parse_args()
|
||||
device_type.ui = device.ui.UI(device_type=device_type, config=vars(args))
|
||||
device_type.cached_passphrase_ack = util.ExpiringCache(
|
||||
seconds=float(args.cache_expiry_seconds))
|
||||
|
||||
return args.func(device_type=device_type, args=args)
|
@ -0,0 +1,247 @@
|
||||
"""GPG-agent utilities."""
|
||||
import binascii
|
||||
import logging
|
||||
|
||||
from . import client, decode, keyring, protocol
|
||||
from .. import util
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def yield_connections(sock):
|
||||
"""Run a server on the specified socket."""
|
||||
while True:
|
||||
log.debug('waiting for connection on %s', sock.getsockname())
|
||||
try:
|
||||
conn, _ = sock.accept()
|
||||
except KeyboardInterrupt:
|
||||
return
|
||||
conn.settimeout(None)
|
||||
log.debug('accepted connection on %s', sock.getsockname())
|
||||
yield conn
|
||||
|
||||
|
||||
def sig_encode(r, s):
|
||||
"""Serialize ECDSA signature data into GPG S-expression."""
|
||||
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' + util.assuan_serialize(prefix + data) + b')'
|
||||
|
||||
|
||||
def parse_ecdh(line):
|
||||
"""Parse ECDH request and return remote public key."""
|
||||
prefix, line = line.split(b' ', 1)
|
||||
assert prefix == b'D'
|
||||
exp, leftover = keyring.parse(keyring.unescape(line))
|
||||
log.debug('ECDH s-exp: %r', exp)
|
||||
assert not leftover
|
||||
label, exp = exp
|
||||
assert label == b'enc-val'
|
||||
assert exp[0] == b'ecdh'
|
||||
items = exp[1:]
|
||||
log.debug('ECDH parameters: %r', items)
|
||||
return dict(items)[b'e']
|
||||
|
||||
|
||||
def _key_info(conn, args):
|
||||
"""
|
||||
Dummy reply (mainly for 'gpg --edit' to succeed).
|
||||
|
||||
For details, see GnuPG agent KEYINFO command help.
|
||||
https://git.gnupg.org/cgi-bin/gitweb.cgi?p=gnupg.git;a=blob;f=agent/command.c;h=c8b34e9882076b1b724346787781f657cac75499;hb=refs/heads/master#l1082
|
||||
"""
|
||||
fmt = 'S KEYINFO {0} X - - - - - - -'
|
||||
keygrip, = args
|
||||
keyring.sendline(conn, fmt.format(keygrip).encode('ascii'))
|
||||
|
||||
|
||||
class AgentError(Exception):
|
||||
"""GnuPG agent-related error."""
|
||||
|
||||
|
||||
class AgentStop(Exception):
|
||||
"""Raised to close the agent."""
|
||||
|
||||
|
||||
# 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 public keys from GnuPG
|
||||
self.pubkey_bytes = pubkey_bytes
|
||||
# "Clone" existing GPG version
|
||||
self.version = keyring.gpg_version()
|
||||
|
||||
self.handlers = {
|
||||
b'RESET': lambda *_: self.reset(),
|
||||
b'OPTION': lambda _, args: self.handle_option(*args),
|
||||
b'SETKEYDESC': None,
|
||||
b'NOP': None,
|
||||
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'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 = {
|
||||
(b'GETINFO', b'version'): self.version,
|
||||
}.get(args)
|
||||
if reply is None:
|
||||
raise AgentError(b'ERR 100696144 No such device <SCD>')
|
||||
keyring.sendline(conn, b'D ' + reply)
|
||||
|
||||
@util.memoize_method # global cache for key grips
|
||||
def get_identity(self, keygrip):
|
||||
"""
|
||||
Returns device.interface.Identity that matches specified keygrip.
|
||||
|
||||
In case of missing keygrip, KeyError will be raised.
|
||||
"""
|
||||
keygrip_bytes = binascii.unhexlify(keygrip)
|
||||
pubkey_dict, user_ids = decode.load_by_keygrip(
|
||||
pubkey_bytes=self.pubkey_bytes, keygrip=keygrip_bytes)
|
||||
# We assume the first user ID is used to generate TREZOR-based GPG keys.
|
||||
user_id = user_ids[0]['value'].decode('utf-8')
|
||||
curve_name = protocol.get_curve_name_by_oid(pubkey_dict['curve_oid'])
|
||||
ecdh = (pubkey_dict['algo'] == protocol.ECDH_ALGO_ID)
|
||||
|
||||
identity = client.create_identity(user_id=user_id, curve_name=curve_name)
|
||||
verifying_key = self.client.pubkey(identity=identity, ecdh=ecdh)
|
||||
pubkey = protocol.PublicKey(
|
||||
curve_name=curve_name, created=pubkey_dict['created'],
|
||||
verifying_key=verifying_key, ecdh=ecdh)
|
||||
assert pubkey.key_id() == pubkey_dict['key_id']
|
||||
assert pubkey.keygrip() == keygrip_bytes
|
||||
return identity
|
||||
|
||||
def pksign(self, conn):
|
||||
"""Sign a message digest using a private EC key."""
|
||||
log.debug('signing %r digest (algo #%s)', self.digest, self.algo)
|
||||
identity = self.get_identity(keygrip=self.keygrip)
|
||||
r, s = self.client.sign(identity=identity,
|
||||
digest=binascii.unhexlify(self.digest))
|
||||
result = sig_encode(r, s)
|
||||
log.debug('result: %r', result)
|
||||
keyring.sendline(conn, b'D ' + result)
|
||||
|
||||
def pkdecrypt(self, conn):
|
||||
"""Handle decryption using ECDH."""
|
||||
for msg in [b'S INQUIRE_MAXLEN 4096', b'INQUIRE CIPHERTEXT']:
|
||||
keyring.sendline(conn, msg)
|
||||
|
||||
line = keyring.recvline(conn)
|
||||
assert keyring.recvline(conn) == b'END'
|
||||
remote_pubkey = parse_ecdh(line)
|
||||
|
||||
identity = self.get_identity(keygrip=self.keygrip)
|
||||
ec_point = self.client.ecdh(identity=identity, pubkey=remote_pubkey)
|
||||
keyring.sendline(conn, b'D ' + _serialize_point(ec_point))
|
||||
|
||||
def have_key(self, *keygrips):
|
||||
"""Check if any keygrip corresponds to a TREZOR-based key."""
|
||||
for keygrip in keygrips:
|
||||
try:
|
||||
self.get_identity(keygrip=keygrip)
|
||||
break
|
||||
except KeyError as e:
|
||||
log.warning('HAVEKEY(%s) failed: %s', keygrip, e)
|
||||
else:
|
||||
raise AgentError(b'ERR 67108881 No secret key <GPG Agent>')
|
||||
|
||||
def set_key(self, keygrip):
|
||||
"""Set hexadecimal keygrip for next operation."""
|
||||
self.keygrip = keygrip
|
||||
|
||||
def set_hash(self, algo, digest):
|
||||
"""Set algorithm ID and hexadecimal digest for next operation."""
|
||||
self.algo = algo
|
||||
self.digest = digest
|
||||
|
||||
def handle(self, conn):
|
||||
"""Handle connection from GPG binary using the ASSUAN protocol."""
|
||||
keyring.sendline(conn, b'OK')
|
||||
for line in keyring.iterlines(conn):
|
||||
parts = line.split(b' ')
|
||||
command = parts[0]
|
||||
args = tuple(parts[1:])
|
||||
|
||||
if command == b'BYE':
|
||||
return
|
||||
elif command == b'KILLAGENT':
|
||||
keyring.sendline(conn, b'OK')
|
||||
raise AgentStop()
|
||||
|
||||
if command not in self.handlers:
|
||||
log.error('unknown request: %r', line)
|
||||
continue
|
||||
|
||||
handler = self.handlers[command]
|
||||
if handler:
|
||||
try:
|
||||
handler(conn, args)
|
||||
except AgentError as e:
|
||||
msg, = e.args
|
||||
keyring.sendline(conn, msg)
|
||||
continue
|
||||
keyring.sendline(conn, b'OK')
|
@ -0,0 +1,48 @@
|
||||
"""Device abstraction layer for GPG operations."""
|
||||
|
||||
import logging
|
||||
|
||||
from .. import formats, util
|
||||
from ..device import interface
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def create_identity(user_id, curve_name):
|
||||
"""Create GPG identity for hardware device."""
|
||||
result = interface.Identity(identity_str='gpg://', curve_name=curve_name)
|
||||
result.identity_dict['host'] = user_id
|
||||
return result
|
||||
|
||||
|
||||
class Client:
|
||||
"""Sign messages and get public keys from a hardware device."""
|
||||
|
||||
def __init__(self, device):
|
||||
"""C-tor."""
|
||||
self.device = device
|
||||
|
||||
def pubkey(self, identity, ecdh=False):
|
||||
"""Return public key as VerifyingKey object."""
|
||||
with self.device:
|
||||
pubkey = self.device.pubkey(ecdh=ecdh, identity=identity)
|
||||
return formats.decompress_pubkey(
|
||||
pubkey=pubkey, curve_name=identity.curve_name)
|
||||
|
||||
def sign(self, identity, digest):
|
||||
"""Sign the digest and return a serialized signature."""
|
||||
log.info('please confirm GPG signature on %s for "%s"...',
|
||||
self.device, identity.to_string())
|
||||
if identity.curve_name == formats.CURVE_NIST256:
|
||||
digest = digest[:32] # sign the first 256 bits
|
||||
log.debug('signing digest: %s', util.hexlify(digest))
|
||||
with self.device:
|
||||
sig = self.device.sign(blob=digest, identity=identity)
|
||||
return (util.bytes2num(sig[:32]), util.bytes2num(sig[32:]))
|
||||
|
||||
def ecdh(self, identity, pubkey):
|
||||
"""Derive shared secret using ECDH from remote public key."""
|
||||
log.info('please confirm GPG decryption on %s for "%s"...',
|
||||
self.device, identity.to_string())
|
||||
with self.device:
|
||||
return self.device.ecdh(pubkey=pubkey, identity=identity)
|
@ -0,0 +1,320 @@
|
||||
"""Decoders for GPG v2 data structures."""
|
||||
import base64
|
||||
import functools
|
||||
import hashlib
|
||||
import io
|
||||
import logging
|
||||
import struct
|
||||
|
||||
import ecdsa
|
||||
import ed25519
|
||||
|
||||
from . import protocol
|
||||
from .. import util
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def parse_subpackets(s):
|
||||
"""See https://tools.ietf.org/html/rfc4880#section-5.2.3.1 for details."""
|
||||
subpackets = []
|
||||
total_size = s.readfmt('>H')
|
||||
data = s.read(total_size)
|
||||
s = util.Reader(io.BytesIO(data))
|
||||
|
||||
while True:
|
||||
try:
|
||||
first = s.readfmt('B')
|
||||
except EOFError:
|
||||
break
|
||||
|
||||
if first < 192:
|
||||
subpacket_len = first
|
||||
elif first < 255:
|
||||
subpacket_len = ((first - 192) << 8) + s.readfmt('B') + 192
|
||||
else: # first == 255
|
||||
subpacket_len = s.readfmt('>L')
|
||||
|
||||
subpackets.append(s.read(subpacket_len))
|
||||
|
||||
return subpackets
|
||||
|
||||
|
||||
def parse_mpi(s):
|
||||
"""See https://tools.ietf.org/html/rfc4880#section-3.2 for details."""
|
||||
bits = s.readfmt('>H')
|
||||
blob = bytearray(s.read(int((bits + 7) // 8)))
|
||||
return sum(v << (8 * i) for i, v in enumerate(reversed(blob)))
|
||||
|
||||
|
||||
def parse_mpis(s, n):
|
||||
"""Parse multiple MPIs from stream."""
|
||||
return [parse_mpi(s) for _ in range(n)]
|
||||
|
||||
|
||||
def _parse_nist256p1_pubkey(mpi):
|
||||
prefix, x, y = util.split_bits(mpi, 4, 256, 256)
|
||||
if prefix != 4:
|
||||
raise ValueError('Invalid MPI prefix: {}'.format(prefix))
|
||||
point = ecdsa.ellipticcurve.Point(curve=ecdsa.NIST256p.curve,
|
||||
x=x, y=y)
|
||||
return ecdsa.VerifyingKey.from_public_point(
|
||||
point=point, curve=ecdsa.curves.NIST256p,
|
||||
hashfunc=hashlib.sha256)
|
||||
|
||||
|
||||
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))
|
||||
|
||||
|
||||
SUPPORTED_CURVES = {
|
||||
b'\x2A\x86\x48\xCE\x3D\x03\x01\x07':
|
||||
(_parse_nist256p1_pubkey, protocol.keygrip_nist256),
|
||||
b'\x2B\x06\x01\x04\x01\xDA\x47\x0F\x01':
|
||||
(_parse_ed25519_pubkey, protocol.keygrip_ed25519),
|
||||
b'\x2B\x06\x01\x04\x01\x97\x55\x01\x05\x01':
|
||||
(_parse_ed25519_pubkey, protocol.keygrip_curve25519),
|
||||
}
|
||||
|
||||
RSA_ALGO_IDS = {1, 2, 3}
|
||||
ELGAMAL_ALGO_ID = 16
|
||||
DSA_ALGO_ID = 17
|
||||
ECDSA_ALGO_IDS = {18, 19, 22} # {ecdsa, nist256, ed25519}
|
||||
|
||||
|
||||
def _parse_embedded_signatures(subpackets):
|
||||
for packet in subpackets:
|
||||
data = bytearray(packet)
|
||||
if data[0] == 32:
|
||||
# https://tools.ietf.org/html/rfc4880#section-5.2.3.26
|
||||
stream = io.BytesIO(data[1:])
|
||||
yield _parse_signature(util.Reader(stream))
|
||||
|
||||
|
||||
def has_custom_subpacket(signature_packet):
|
||||
"""Detect our custom public keys by matching subpacket data."""
|
||||
return any(protocol.CUSTOM_KEY_LABEL == subpacket[1:]
|
||||
for subpacket in signature_packet['unhashed_subpackets'])
|
||||
|
||||
|
||||
def _parse_signature(stream):
|
||||
"""See https://tools.ietf.org/html/rfc4880#section-5.2 for details."""
|
||||
p = {'type': 'signature'}
|
||||
|
||||
to_hash = io.BytesIO()
|
||||
with stream.capture(to_hash):
|
||||
p['version'] = stream.readfmt('B')
|
||||
p['sig_type'] = stream.readfmt('B')
|
||||
p['pubkey_alg'] = stream.readfmt('B')
|
||||
p['hash_alg'] = stream.readfmt('B')
|
||||
p['hashed_subpackets'] = parse_subpackets(stream)
|
||||
|
||||
# https://tools.ietf.org/html/rfc4880#section-5.2.4
|
||||
tail_to_hash = b'\x04\xff' + struct.pack('>L', to_hash.tell())
|
||||
|
||||
p['_to_hash'] = to_hash.getvalue() + tail_to_hash
|
||||
|
||||
p['unhashed_subpackets'] = parse_subpackets(stream)
|
||||
embedded = list(_parse_embedded_signatures(p['unhashed_subpackets']))
|
||||
if embedded:
|
||||
log.debug('embedded sigs: %s', embedded)
|
||||
p['embedded'] = embedded
|
||||
|
||||
p['hash_prefix'] = stream.readfmt('2s')
|
||||
if p['pubkey_alg'] in ECDSA_ALGO_IDS:
|
||||
p['sig'] = (parse_mpi(stream), parse_mpi(stream))
|
||||
elif p['pubkey_alg'] in RSA_ALGO_IDS: # RSA
|
||||
p['sig'] = (parse_mpi(stream),)
|
||||
elif p['pubkey_alg'] == DSA_ALGO_ID:
|
||||
p['sig'] = (parse_mpi(stream), parse_mpi(stream))
|
||||
else:
|
||||
log.error('unsupported public key algo: %d', p['pubkey_alg'])
|
||||
|
||||
assert not stream.read()
|
||||
return p
|
||||
|
||||
|
||||
def _parse_pubkey(stream, packet_type='pubkey'):
|
||||
"""See https://tools.ietf.org/html/rfc4880#section-5.5 for details."""
|
||||
p = {'type': packet_type}
|
||||
packet = io.BytesIO()
|
||||
with stream.capture(packet):
|
||||
p['version'] = stream.readfmt('B')
|
||||
p['created'] = stream.readfmt('>L')
|
||||
p['algo'] = stream.readfmt('B')
|
||||
if p['algo'] in ECDSA_ALGO_IDS:
|
||||
log.debug('parsing elliptic curve key')
|
||||
# https://tools.ietf.org/html/rfc6637#section-11
|
||||
oid_size = stream.readfmt('B')
|
||||
oid = stream.read(oid_size)
|
||||
assert oid in SUPPORTED_CURVES, util.hexlify(oid)
|
||||
p['curve_oid'] = oid
|
||||
|
||||
mpi = parse_mpi(stream)
|
||||
log.debug('mpi: %x (%d bits)', mpi, mpi.bit_length())
|
||||
leftover = stream.read()
|
||||
if leftover:
|
||||
leftover = io.BytesIO(leftover)
|
||||
# https://tools.ietf.org/html/rfc6637#section-8
|
||||
# should be b'\x03\x01\x08\x07': SHA256 + AES128
|
||||
size, = util.readfmt(leftover, 'B')
|
||||
p['kdf'] = leftover.read(size)
|
||||
p['secret'] = leftover.read()
|
||||
|
||||
parse_func, keygrip_func = SUPPORTED_CURVES[oid]
|
||||
keygrip = keygrip_func(parse_func(mpi))
|
||||
log.debug('keygrip: %s', util.hexlify(keygrip))
|
||||
p['keygrip'] = keygrip
|
||||
|
||||
elif p['algo'] == DSA_ALGO_ID:
|
||||
parse_mpis(stream, n=4) # DSA keys are not supported
|
||||
elif p['algo'] == ELGAMAL_ALGO_ID:
|
||||
parse_mpis(stream, n=3) # ElGamal keys are not supported
|
||||
else: # assume RSA
|
||||
parse_mpis(stream, n=2) # RSA keys are not supported
|
||||
assert not stream.read()
|
||||
|
||||
# https://tools.ietf.org/html/rfc4880#section-12.2
|
||||
packet_data = packet.getvalue()
|
||||
data_to_hash = (b'\x99' + struct.pack('>H', len(packet_data)) +
|
||||
packet_data)
|
||||
p['key_id'] = hashlib.sha1(data_to_hash).digest()[-8:]
|
||||
p['_to_hash'] = data_to_hash
|
||||
log.debug('key ID: %s', util.hexlify(p['key_id']))
|
||||
return p
|
||||
|
||||
|
||||
_parse_subkey = functools.partial(_parse_pubkey, packet_type='subkey')
|
||||
|
||||
|
||||
def _parse_user_id(stream, packet_type='user_id'):
|
||||
"""See https://tools.ietf.org/html/rfc4880#section-5.11 for details."""
|
||||
value = stream.read()
|
||||
to_hash = b'\xb4' + util.prefix_len('>L', value)
|
||||
return {'type': packet_type, 'value': value, '_to_hash': to_hash}
|
||||
|
||||
|
||||
# User attribute is handled as an opaque user ID
|
||||
_parse_attribute = functools.partial(_parse_user_id,
|
||||
packet_type='user_attribute')
|
||||
|
||||
PACKET_TYPES = {
|
||||
2: _parse_signature,
|
||||
5: _parse_pubkey,
|
||||
6: _parse_pubkey,
|
||||
7: _parse_subkey,
|
||||
13: _parse_user_id,
|
||||
14: _parse_subkey,
|
||||
17: _parse_attribute,
|
||||
}
|
||||
|
||||
|
||||
def parse_packets(stream):
|
||||
"""
|
||||
Support iterative parsing of available GPG packets.
|
||||
|
||||
See https://tools.ietf.org/html/rfc4880#section-4.2 for details.
|
||||
"""
|
||||
reader = util.Reader(stream)
|
||||
while True:
|
||||
try:
|
||||
value = reader.readfmt('B')
|
||||
except EOFError:
|
||||
return
|
||||
|
||||
log.debug('prefix byte: %s', bin(value))
|
||||
assert util.bit(value, 7) == 1
|
||||
|
||||
tag = util.low_bits(value, 6)
|
||||
if util.bit(value, 6) == 0:
|
||||
length_type = util.low_bits(tag, 2)
|
||||
tag = tag >> 2
|
||||
fmt = {0: '>B', 1: '>H', 2: '>L'}[length_type]
|
||||
packet_size = reader.readfmt(fmt)
|
||||
else:
|
||||
first = reader.readfmt('B')
|
||||
if first < 192:
|
||||
packet_size = first
|
||||
elif first < 224:
|
||||
packet_size = ((first - 192) << 8) + reader.readfmt('B') + 192
|
||||
elif first == 255:
|
||||
packet_size = reader.readfmt('>L')
|
||||
else:
|
||||
log.error('Partial Body Lengths unsupported')
|
||||
|
||||
log.debug('packet length: %d', packet_size)
|
||||
packet_data = reader.read(packet_size)
|
||||
packet_type = PACKET_TYPES.get(tag)
|
||||
|
||||
p = {'type': 'unknown', 'tag': tag, 'raw': packet_data}
|
||||
if packet_type is not None:
|
||||
try:
|
||||
p = packet_type(util.Reader(io.BytesIO(packet_data)))
|
||||
p['tag'] = tag
|
||||
except ValueError:
|
||||
log.exception('Skipping packet: %s', util.hexlify(packet_data))
|
||||
|
||||
log.debug('packet "%s": %s', p['type'], p)
|
||||
yield p
|
||||
|
||||
|
||||
def digest_packets(packets, hasher):
|
||||
"""Compute digest on specified packets, according to '_to_hash' field."""
|
||||
data_to_hash = io.BytesIO()
|
||||
for p in packets:
|
||||
data_to_hash.write(p['_to_hash'])
|
||||
hasher.update(data_to_hash.getvalue())
|
||||
return hasher.digest()
|
||||
|
||||
|
||||
HASH_ALGORITHMS = {
|
||||
1: 'md5',
|
||||
2: 'sha1',
|
||||
3: 'ripemd160',
|
||||
8: 'sha256',
|
||||
9: 'sha384',
|
||||
10: 'sha512',
|
||||
11: 'sha224',
|
||||
}
|
||||
|
||||
|
||||
def load_by_keygrip(pubkey_bytes, keygrip):
|
||||
"""Return public key and first user ID for specified keygrip."""
|
||||
stream = io.BytesIO(pubkey_bytes)
|
||||
packets = list(parse_packets(stream))
|
||||
packets_per_pubkey = []
|
||||
for p in packets:
|
||||
if p['type'] == 'pubkey':
|
||||
# Add a new packet list for each pubkey.
|
||||
packets_per_pubkey.append([])
|
||||
packets_per_pubkey[-1].append(p)
|
||||
|
||||
for packets in packets_per_pubkey:
|
||||
user_ids = [p for p in packets if p['type'] == 'user_id']
|
||||
for p in packets:
|
||||
if p.get('keygrip') == keygrip:
|
||||
return p, user_ids
|
||||
raise KeyError('{} keygrip not found'.format(util.hexlify(keygrip)))
|
||||
|
||||
|
||||
def load_signature(stream, original_data):
|
||||
"""Load signature from stream, and compute GPG digest for verification."""
|
||||
signature, = list(parse_packets((stream)))
|
||||
hash_alg = HASH_ALGORITHMS[signature['hash_alg']]
|
||||
digest = digest_packets([{'_to_hash': original_data}, signature],
|
||||
hasher=hashlib.new(hash_alg))
|
||||
assert signature['hash_prefix'] == digest[:2]
|
||||
return signature, digest
|
||||
|
||||
|
||||
def remove_armor(armored_data):
|
||||
"""Decode armored data into its binary form."""
|
||||
stream = io.BytesIO(armored_data)
|
||||
lines = stream.readlines()[3:-1]
|
||||
data = base64.b64decode(b''.join(lines))
|
||||
payload, checksum = data[:-3], data[-3:]
|
||||
assert util.crc24(payload) == checksum
|
||||
return payload
|
@ -0,0 +1,103 @@
|
||||
"""Create GPG ECDSA signatures and public keys using TREZOR device."""
|
||||
import io
|
||||
import logging
|
||||
|
||||
from . import decode, keyring, protocol
|
||||
from .. import util
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def create_primary(user_id, pubkey, signer_func, secret_bytes=b''):
|
||||
"""Export new primary GPG public key, ready for "gpg2 --import"."""
|
||||
pubkey_packet = protocol.packet(tag=(5 if secret_bytes else 6),
|
||||
blob=(pubkey.data() + secret_bytes))
|
||||
user_id_bytes = user_id.encode('utf-8')
|
||||
user_id_packet = protocol.packet(tag=13, blob=user_id_bytes)
|
||||
data_to_sign = (pubkey.data_to_hash() + user_id_packet[:1] +
|
||||
util.prefix_len('>L', user_id_bytes))
|
||||
hashed_subpackets = [
|
||||
protocol.subpacket_time(pubkey.created), # signature time
|
||||
# https://tools.ietf.org/html/rfc4880#section-5.2.3.7
|
||||
protocol.subpacket_byte(0x0B, 9), # preferred symmetric algo (AES-256)
|
||||
# https://tools.ietf.org/html/rfc4880#section-5.2.3.4
|
||||
protocol.subpacket_byte(0x1B, 1 | 2), # key flags (certify & sign)
|
||||
# https://tools.ietf.org/html/rfc4880#section-5.2.3.21
|
||||
protocol.subpacket_bytes(0x15, [8, 9, 10]), # preferred hash
|
||||
# https://tools.ietf.org/html/rfc4880#section-5.2.3.8
|
||||
protocol.subpacket_bytes(0x16, [2, 3, 1]), # preferred compression
|
||||
# https://tools.ietf.org/html/rfc4880#section-5.2.3.9
|
||||
protocol.subpacket_byte(0x17, 0x80), # key server prefs (no-modify)
|
||||
# https://tools.ietf.org/html/rfc4880#section-5.2.3.17
|
||||
protocol.subpacket_byte(0x1E, 0x01), # advanced features (MDC)
|
||||
# https://tools.ietf.org/html/rfc4880#section-5.2.3.24
|
||||
]
|
||||
unhashed_subpackets = [
|
||||
protocol.subpacket(16, pubkey.key_id()), # issuer key id
|
||||
protocol.CUSTOM_SUBPACKET]
|
||||
|
||||
signature = protocol.make_signature(
|
||||
signer_func=signer_func,
|
||||
public_algo=pubkey.algo_id,
|
||||
data_to_sign=data_to_sign,
|
||||
sig_type=0x13, # user id & public key
|
||||
hashed_subpackets=hashed_subpackets,
|
||||
unhashed_subpackets=unhashed_subpackets)
|
||||
|
||||
sign_packet = protocol.packet(tag=2, blob=signature)
|
||||
return pubkey_packet + user_id_packet + sign_packet
|
||||
|
||||
|
||||
def create_subkey(primary_bytes, subkey, signer_func, secret_bytes=b''):
|
||||
"""Export new subkey to GPG primary key."""
|
||||
subkey_packet = protocol.packet(tag=(7 if secret_bytes else 14),
|
||||
blob=(subkey.data() + secret_bytes))
|
||||
packets = list(decode.parse_packets(io.BytesIO(primary_bytes)))
|
||||
primary, user_id, signature = packets[:3]
|
||||
|
||||
data_to_sign = primary['_to_hash'] + subkey.data_to_hash()
|
||||
|
||||
if subkey.ecdh:
|
||||
embedded_sig = None
|
||||
else:
|
||||
# Primary Key Binding Signature
|
||||
hashed_subpackets = [
|
||||
protocol.subpacket_time(subkey.created)] # signature time
|
||||
unhashed_subpackets = [
|
||||
protocol.subpacket(16, subkey.key_id())] # issuer key id
|
||||
embedded_sig = protocol.make_signature(
|
||||
signer_func=signer_func,
|
||||
data_to_sign=data_to_sign,
|
||||
public_algo=subkey.algo_id,
|
||||
sig_type=0x19,
|
||||
hashed_subpackets=hashed_subpackets,
|
||||
unhashed_subpackets=unhashed_subpackets)
|
||||
|
||||
# Subkey Binding Signature
|
||||
|
||||
# Key flags: https://tools.ietf.org/html/rfc4880#section-5.2.3.21
|
||||
# (certify & sign) (encrypt)
|
||||
flags = (2) if (not subkey.ecdh) else (4 | 8)
|
||||
|
||||
hashed_subpackets = [
|
||||
protocol.subpacket_time(subkey.created), # signature time
|
||||
protocol.subpacket_byte(0x1B, flags)]
|
||||
|
||||
unhashed_subpackets = []
|
||||
unhashed_subpackets.append(protocol.subpacket(16, primary['key_id']))
|
||||
if embedded_sig is not None:
|
||||
unhashed_subpackets.append(protocol.subpacket(32, embedded_sig))
|
||||
unhashed_subpackets.append(protocol.CUSTOM_SUBPACKET)
|
||||
|
||||
if not decode.has_custom_subpacket(signature):
|
||||
signer_func = keyring.create_agent_signer(user_id['value'])
|
||||
|
||||
signature = protocol.make_signature(
|
||||
signer_func=signer_func,
|
||||
data_to_sign=data_to_sign,
|
||||
public_algo=primary['algo'],
|
||||
sig_type=0x18,
|
||||
hashed_subpackets=hashed_subpackets,
|
||||
unhashed_subpackets=unhashed_subpackets)
|
||||
sign_packet = protocol.packet(tag=2, blob=signature)
|
||||
return primary_bytes + subkey_packet + sign_packet
|
@ -0,0 +1,261 @@
|
||||
"""Tools for doing signature using gpg-agent."""
|
||||
from __future__ import absolute_import, print_function, unicode_literals
|
||||
|
||||
import binascii
|
||||
import io
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import socket
|
||||
import subprocess
|
||||
|
||||
from .. import util
|
||||
|
||||
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)
|
||||
log.debug('output: %r', output)
|
||||
return output
|
||||
|
||||
|
||||
def get_agent_sock_path(env=None, sp=subprocess):
|
||||
"""Parse gpgconf output to find out GPG agent UNIX socket path."""
|
||||
args = [util.which('gpgconf'), '--list-dirs']
|
||||
output = check_output(args=args, env=env, sp=sp)
|
||||
lines = output.strip().split(b'\n')
|
||||
dirs = dict(line.split(b':', 1) for line in lines)
|
||||
log.debug('%s: %s', args, dirs)
|
||||
return dirs[b'agent-socket']
|
||||
|
||||
|
||||
def connect_to_agent(env=None, sp=subprocess):
|
||||
"""Connect to GPG agent's UNIX socket."""
|
||||
sock_path = get_agent_sock_path(sp=sp, env=env)
|
||||
# Make sure the original gpg-agent is running.
|
||||
check_output(args=['gpg-connect-agent', '/bye'], sp=sp)
|
||||
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||
sock.connect(sock_path)
|
||||
return sock
|
||||
|
||||
|
||||
def communicate(sock, msg):
|
||||
"""Send a message and receive a single line."""
|
||||
sendline(sock, msg.encode('ascii'))
|
||||
return recvline(sock)
|
||||
|
||||
|
||||
def sendline(sock, msg, confidential=False):
|
||||
"""Send a binary message, followed by EOL."""
|
||||
log.debug('<- %r', ('<snip>' if confidential else msg))
|
||||
sock.sendall(msg + b'\n')
|
||||
|
||||
|
||||
def recvline(sock):
|
||||
"""Receive a single line from the socket."""
|
||||
reply = io.BytesIO()
|
||||
|
||||
while True:
|
||||
c = sock.recv(1)
|
||||
if not c:
|
||||
return None # socket is closed
|
||||
|
||||
if c == b'\n':
|
||||
break
|
||||
reply.write(c)
|
||||
|
||||
result = reply.getvalue()
|
||||
log.debug('-> %r', result)
|
||||
return result
|
||||
|
||||
|
||||
def iterlines(conn):
|
||||
"""Iterate over input, split by lines."""
|
||||
while True:
|
||||
line = recvline(conn)
|
||||
if line is None:
|
||||
break
|
||||
yield line
|
||||
|
||||
|
||||
def unescape(s):
|
||||
"""Unescape ASSUAN message (0xAB <-> '%AB')."""
|
||||
s = bytearray(s)
|
||||
i = 0
|
||||
while i < len(s):
|
||||
if s[i] == ord('%'):
|
||||
hex_bytes = bytes(s[i+1:i+3])
|
||||
value = int(hex_bytes.decode('ascii'), 16)
|
||||
s[i:i+3] = [value]
|
||||
i += 1
|
||||
return bytes(s)
|
||||
|
||||
|
||||
def parse_term(s):
|
||||
"""Parse single s-expr term from bytes."""
|
||||
size, s = s.split(b':', 1)
|
||||
size = int(size)
|
||||
return s[:size], s[size:]
|
||||
|
||||
|
||||
def parse(s):
|
||||
"""Parse full s-expr from bytes."""
|
||||
if s.startswith(b'('):
|
||||
s = s[1:]
|
||||
name, s = parse_term(s)
|
||||
values = [name]
|
||||
while not s.startswith(b')'):
|
||||
value, s = parse(s)
|
||||
values.append(value)
|
||||
return values, s[1:]
|
||||
|
||||
return parse_term(s)
|
||||
|
||||
|
||||
def _parse_ecdsa_sig(args):
|
||||
(r, sig_r), (s, sig_s) = args
|
||||
assert r == b'r'
|
||||
assert s == b's'
|
||||
return (util.bytes2num(sig_r),
|
||||
util.bytes2num(sig_s))
|
||||
|
||||
|
||||
# DSA and EDDSA happen to have the same structure as ECDSA signatures
|
||||
_parse_dsa_sig = _parse_ecdsa_sig
|
||||
_parse_eddsa_sig = _parse_ecdsa_sig
|
||||
|
||||
|
||||
def _parse_rsa_sig(args):
|
||||
(s, sig_s), = args
|
||||
assert s == b's'
|
||||
return (util.bytes2num(sig_s),)
|
||||
|
||||
|
||||
def parse_sig(sig):
|
||||
"""Parse signature integer values from s-expr."""
|
||||
label, sig = sig
|
||||
assert label == b'sig-val'
|
||||
algo_name = sig[0]
|
||||
parser = {b'rsa': _parse_rsa_sig,
|
||||
b'ecdsa': _parse_ecdsa_sig,
|
||||
b'eddsa': _parse_eddsa_sig,
|
||||
b'dsa': _parse_dsa_sig}[algo_name]
|
||||
return parser(args=sig[1:])
|
||||
|
||||
|
||||
def sign_digest(sock, keygrip, digest, sp=subprocess, environ=None):
|
||||
"""Sign a digest using specified key using GPG agent."""
|
||||
hash_algo = 8 # SHA256
|
||||
assert len(digest) == 32
|
||||
|
||||
assert communicate(sock, 'RESET').startswith(b'OK')
|
||||
|
||||
ttyname = check_output(args=['tty'], sp=sp).strip()
|
||||
options = ['ttyname={}'.format(ttyname)] # set TTY for passphrase entry
|
||||
|
||||
display = (environ or os.environ).get('DISPLAY')
|
||||
if display is not None:
|
||||
options.append('display={}'.format(display))
|
||||
|
||||
for opt in options:
|
||||
assert communicate(sock, 'OPTION {}'.format(opt)) == b'OK'
|
||||
|
||||
assert communicate(sock, 'SIGKEY {}'.format(keygrip)) == b'OK'
|
||||
hex_digest = binascii.hexlify(digest).upper().decode('ascii')
|
||||
assert communicate(sock, 'SETHASH {} {}'.format(hash_algo,
|
||||
hex_digest)) == b'OK'
|
||||
|
||||
assert communicate(sock, 'SETKEYDESC '
|
||||
'Sign+a+new+TREZOR-based+subkey') == b'OK'
|
||||
assert communicate(sock, 'PKSIGN') == b'OK'
|
||||
while True:
|
||||
line = recvline(sock).strip()
|
||||
if line.startswith(b'S PROGRESS'):
|
||||
continue
|
||||
else:
|
||||
break
|
||||
line = unescape(line)
|
||||
log.debug('unescaped: %r', line)
|
||||
prefix, sig = line.split(b' ', 1)
|
||||
if prefix != b'D':
|
||||
raise ValueError(prefix)
|
||||
|
||||
sig, leftover = parse(sig)
|
||||
assert not leftover, leftover
|
||||
return parse_sig(sig)
|
||||
|
||||
|
||||
def get_gnupg_components(sp=subprocess):
|
||||
"""Parse GnuPG components' paths."""
|
||||
args = [util.which('gpgconf'), '--list-components']
|
||||
output = check_output(args=args, sp=sp)
|
||||
components = dict(re.findall('(.*):.*:(.*)', output.decode('utf-8')))
|
||||
log.debug('gpgconf --list-components: %s', components)
|
||||
return components
|
||||
|
||||
|
||||
@util.memoize
|
||||
def get_gnupg_binary(sp=subprocess, neopg_binary=None):
|
||||
"""Starting GnuPG 2.2.x, the default installation uses `gpg`."""
|
||||
if neopg_binary:
|
||||
return neopg_binary
|
||||
return get_gnupg_components(sp=sp)['gpg']
|
||||
|
||||
|
||||
def gpg_command(args, env=None):
|
||||
"""Prepare common GPG command line arguments."""
|
||||
if env is None:
|
||||
env = os.environ
|
||||
cmd = get_gnupg_binary(neopg_binary=env.get('NEOPG_BINARY'))
|
||||
return [cmd] + args
|
||||
|
||||
|
||||
def get_keygrip(user_id, sp=subprocess):
|
||||
"""Get a keygrip of the primary GPG key of the specified user."""
|
||||
args = gpg_command(['--list-keys', '--with-keygrip', user_id])
|
||||
output = check_output(args=args, sp=sp).decode('utf-8')
|
||||
return re.findall(r'Keygrip = (\w+)', output)[0]
|
||||
|
||||
|
||||
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'
|
||||
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):
|
||||
"""Export GPG public key for specified `user_id`."""
|
||||
args = gpg_command(['--export', user_id])
|
||||
result = check_output(args=args, env=env, sp=sp)
|
||||
if not result:
|
||||
log.error('could not find public key %r in local GPG keyring', user_id)
|
||||
raise KeyError(user_id)
|
||||
return result
|
||||
|
||||
|
||||
def export_public_keys(env=None, sp=subprocess):
|
||||
"""Export all GPG public keys."""
|
||||
args = gpg_command(['--export'])
|
||||
result = check_output(args=args, env=env, sp=sp)
|
||||
if not result:
|
||||
raise KeyError('No GPG public keys found at env: {!r}'.format(env))
|
||||
return result
|
||||
|
||||
|
||||
def create_agent_signer(user_id):
|
||||
"""Sign digest with existing GPG keys using gpg-agent tool."""
|
||||
sock = connect_to_agent(env=os.environ)
|
||||
keygrip = get_keygrip(user_id)
|
||||
|
||||
def sign(digest):
|
||||
"""Sign the digest and return an ECDSA/RSA/DSA signature."""
|
||||
return sign_digest(sock=sock, keygrip=keygrip, digest=digest)
|
||||
|
||||
return sign
|
@ -0,0 +1,276 @@
|
||||
"""GPG protocol utilities."""
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import logging
|
||||
import struct
|
||||
|
||||
from .. import formats, util
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def packet(tag, blob):
|
||||
"""Create small GPG packet."""
|
||||
assert len(blob) < 2**32
|
||||
|
||||
if len(blob) < 2**8:
|
||||
length_type = 0
|
||||
elif len(blob) < 2**16:
|
||||
length_type = 1
|
||||
else:
|
||||
length_type = 2
|
||||
|
||||
fmt = ['>B', '>H', '>L'][length_type]
|
||||
leading_byte = 0x80 | (tag << 2) | (length_type)
|
||||
return struct.pack('>B', leading_byte) + util.prefix_len(fmt, blob)
|
||||
|
||||
|
||||
def subpacket(subpacket_type, fmt, *values):
|
||||
"""Create GPG subpacket."""
|
||||
blob = struct.pack(fmt, *values) if values else fmt
|
||||
return struct.pack('>B', subpacket_type) + blob
|
||||
|
||||
|
||||
def subpacket_long(subpacket_type, value):
|
||||
"""Create GPG subpacket with 32-bit unsigned integer."""
|
||||
return subpacket(subpacket_type, '>L', value)
|
||||
|
||||
|
||||
def subpacket_time(value):
|
||||
"""Create GPG subpacket with time in seconds (since Epoch)."""
|
||||
return subpacket_long(2, value)
|
||||
|
||||
|
||||
def subpacket_byte(subpacket_type, value):
|
||||
"""Create GPG subpacket with 8-bit unsigned integer."""
|
||||
return subpacket(subpacket_type, '>B', value)
|
||||
|
||||
|
||||
def subpacket_bytes(subpacket_type, values):
|
||||
"""Create GPG subpacket with 8-bit unsigned integers."""
|
||||
return subpacket(subpacket_type, '>' + 'B'*len(values), *values)
|
||||
|
||||
|
||||
def subpacket_prefix_len(item):
|
||||
"""Prefix subpacket length according to RFC 4880 section-5.2.3.1."""
|
||||
n = len(item)
|
||||
if n >= 8384:
|
||||
prefix = b'\xFF' + struct.pack('>L', n)
|
||||
elif n >= 192:
|
||||
n = n - 192
|
||||
prefix = struct.pack('BB', (n // 256) + 192, n % 256)
|
||||
else:
|
||||
prefix = struct.pack('B', n)
|
||||
return prefix + item
|
||||
|
||||
|
||||
def subpackets(*items):
|
||||
"""Serialize several GPG subpackets."""
|
||||
prefixed = [subpacket_prefix_len(item) for item in items]
|
||||
return util.prefix_len('>H', b''.join(prefixed))
|
||||
|
||||
|
||||
def mpi(value):
|
||||
"""Serialize multipresicion integer using GPG format."""
|
||||
bits = value.bit_length()
|
||||
data_size = (bits + 7) // 8
|
||||
data_bytes = bytearray(data_size)
|
||||
for i in range(data_size):
|
||||
data_bytes[i] = value & 0xFF
|
||||
value = value >> 8
|
||||
|
||||
data_bytes.reverse()
|
||||
return struct.pack('>H', bits) + bytes(data_bytes)
|
||||
|
||||
|
||||
def _serialize_nist256(vk):
|
||||
return mpi((4 << 512) |
|
||||
(vk.pubkey.point.x() << 256) |
|
||||
(vk.pubkey.point.y()))
|
||||
|
||||
|
||||
def _serialize_ed25519(vk):
|
||||
return mpi((0x40 << 256) |
|
||||
util.bytes2num(vk.to_bytes()))
|
||||
|
||||
|
||||
def _compute_keygrip(params):
|
||||
parts = []
|
||||
for name, value in params:
|
||||
exp = '{}:{}{}:'.format(len(name), name, len(value))
|
||||
parts.append(b'(' + exp.encode('ascii') + value + b')')
|
||||
|
||||
return hashlib.sha1(b''.join(parts)).digest()
|
||||
|
||||
|
||||
def keygrip_nist256(vk):
|
||||
"""Compute keygrip for NIST256 curve public keys."""
|
||||
curve = vk.curve.curve
|
||||
gen = vk.curve.generator
|
||||
g = (4 << 512) | (gen.x() << 256) | gen.y()
|
||||
point = vk.pubkey.point
|
||||
q = (4 << 512) | (point.x() << 256) | point.y()
|
||||
|
||||
return _compute_keygrip([
|
||||
['p', util.num2bytes(curve.p(), size=32)],
|
||||
['a', util.num2bytes(curve.a() % curve.p(), size=32)],
|
||||
['b', util.num2bytes(curve.b() % curve.p(), size=32)],
|
||||
['g', util.num2bytes(g, size=65)],
|
||||
['n', util.num2bytes(vk.curve.order, size=32)],
|
||||
['q', util.num2bytes(q, size=65)],
|
||||
])
|
||||
|
||||
|
||||
def keygrip_ed25519(vk):
|
||||
"""Compute keygrip for Ed25519 public keys."""
|
||||
# pylint: disable=line-too-long
|
||||
return _compute_keygrip([
|
||||
['p', util.num2bytes(0x7FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFED, size=32)], # nopep8
|
||||
['a', b'\x01'],
|
||||
['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()],
|
||||
])
|
||||
|
||||
|
||||
def keygrip_curve25519(vk):
|
||||
"""Compute keygrip for Curve25519 public keys."""
|
||||
# pylint: disable=line-too-long
|
||||
return _compute_keygrip([
|
||||
['p', util.num2bytes(0x7FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFED, size=32)], # nopep8
|
||||
['a', b'\x01\xDB\x41'],
|
||||
['b', b'\x01'],
|
||||
['g', util.num2bytes(0x04000000000000000000000000000000000000000000000000000000000000000920ae19a1b8a086b4e01edd2c7748d14c923d4d7e6d7c61b229e9c5a27eced3d9, size=65)], # nopep8
|
||||
['n', util.num2bytes(0x1000000000000000000000000000000014DEF9DEA2F79CD65812631A5CF5D3ED, size=32)], # nopep8
|
||||
['q', vk.to_bytes()],
|
||||
])
|
||||
|
||||
|
||||
SUPPORTED_CURVES = {
|
||||
formats.CURVE_NIST256: {
|
||||
# https://tools.ietf.org/html/rfc6637#section-11
|
||||
'oid': b'\x2A\x86\x48\xCE\x3D\x03\x01\x07',
|
||||
'algo_id': 19,
|
||||
'serialize': _serialize_nist256,
|
||||
'keygrip': keygrip_nist256,
|
||||
},
|
||||
formats.CURVE_ED25519: {
|
||||
'oid': b'\x2B\x06\x01\x04\x01\xDA\x47\x0F\x01',
|
||||
'algo_id': 22,
|
||||
'serialize': _serialize_ed25519,
|
||||
'keygrip': keygrip_ed25519,
|
||||
},
|
||||
formats.ECDH_CURVE25519: {
|
||||
'oid': b'\x2B\x06\x01\x04\x01\x97\x55\x01\x05\x01',
|
||||
'algo_id': 18,
|
||||
'serialize': _serialize_ed25519,
|
||||
'keygrip': keygrip_curve25519,
|
||||
},
|
||||
}
|
||||
|
||||
ECDH_ALGO_ID = 18
|
||||
|
||||
CUSTOM_KEY_LABEL = b'TREZOR-GPG' # marks "our" pubkey
|
||||
CUSTOM_SUBPACKET_ID = 26 # use "policy URL" subpacket
|
||||
CUSTOM_SUBPACKET = subpacket(CUSTOM_SUBPACKET_ID, CUSTOM_KEY_LABEL)
|
||||
|
||||
|
||||
def get_curve_name_by_oid(oid):
|
||||
"""Return curve name matching specified OID, or raise KeyError."""
|
||||
for curve_name, info in SUPPORTED_CURVES.items():
|
||||
if info['oid'] == oid:
|
||||
return curve_name
|
||||
raise KeyError('Unknown OID: {!r}'.format(oid))
|
||||
|
||||
|
||||
class PublicKey:
|
||||
"""GPG representation for public key packets."""
|
||||
|
||||
def __init__(self, curve_name, created, verifying_key, ecdh=False):
|
||||
"""Contruct using a ECDSA VerifyingKey object."""
|
||||
self.curve_name = curve_name
|
||||
self.curve_info = SUPPORTED_CURVES[curve_name]
|
||||
self.created = int(created) # time since Epoch
|
||||
self.verifying_key = verifying_key
|
||||
self.ecdh = bool(ecdh)
|
||||
if ecdh:
|
||||
self.algo_id = ECDH_ALGO_ID
|
||||
self.ecdh_packet = b'\x03\x01\x08\x07'
|
||||
else:
|
||||
self.algo_id = self.curve_info['algo_id']
|
||||
self.ecdh_packet = b''
|
||||
|
||||
def keygrip(self):
|
||||
"""Compute GPG keygrip of the verifying key."""
|
||||
return self.curve_info['keygrip'](self.verifying_key)
|
||||
|
||||
def data(self):
|
||||
"""Data for packet creation."""
|
||||
header = struct.pack('>BLB',
|
||||
4, # version
|
||||
self.created, # creation
|
||||
self.algo_id) # public key algorithm ID
|
||||
oid = util.prefix_len('>B', self.curve_info['oid'])
|
||||
blob = self.curve_info['serialize'](self.verifying_key)
|
||||
return header + oid + blob + self.ecdh_packet
|
||||
|
||||
def data_to_hash(self):
|
||||
"""Data for digest computation."""
|
||||
return b'\x99' + util.prefix_len('>H', self.data())
|
||||
|
||||
def _fingerprint(self):
|
||||
return hashlib.sha1(self.data_to_hash()).digest()
|
||||
|
||||
def key_id(self):
|
||||
"""Short (8 byte) GPG key ID."""
|
||||
return self._fingerprint()[-8:]
|
||||
|
||||
def __repr__(self):
|
||||
"""Short (8 hexadecimal digits) GPG key ID."""
|
||||
hex_key_id = util.hexlify(self.key_id())[-8:]
|
||||
return 'GPG public key {}/{}'.format(self.curve_name, hex_key_id)
|
||||
|
||||
__str__ = __repr__
|
||||
|
||||
|
||||
def _split_lines(body, size):
|
||||
lines = []
|
||||
for i in range(0, len(body), size):
|
||||
lines.append(body[i:i+size] + '\n')
|
||||
return ''.join(lines)
|
||||
|
||||
|
||||
def armor(blob, type_str):
|
||||
"""See https://tools.ietf.org/html/rfc4880#section-6 for details."""
|
||||
head = '-----BEGIN PGP {}-----\nVersion: GnuPG v2\n\n'.format(type_str)
|
||||
body = base64.b64encode(blob).decode('ascii')
|
||||
checksum = base64.b64encode(util.crc24(blob)).decode('ascii')
|
||||
tail = '-----END PGP {}-----\n'.format(type_str)
|
||||
return head + _split_lines(body, 64) + '=' + checksum + '\n' + tail
|
||||
|
||||
|
||||
def make_signature(signer_func, data_to_sign, public_algo,
|
||||
hashed_subpackets, unhashed_subpackets, sig_type=0):
|
||||
"""Create new GPG signature."""
|
||||
# pylint: disable=too-many-arguments
|
||||
header = struct.pack('>BBBB',
|
||||
4, # version
|
||||
sig_type, # rfc4880 (section-5.2.1)
|
||||
public_algo,
|
||||
8) # hash_alg (SHA256)
|
||||
hashed = subpackets(*hashed_subpackets)
|
||||
unhashed = subpackets(*unhashed_subpackets)
|
||||
tail = b'\x04\xff' + struct.pack('>L', len(header) + len(hashed))
|
||||
data_to_hash = data_to_sign + header + hashed + tail
|
||||
|
||||
log.debug('hashing %d bytes', len(data_to_hash))
|
||||
digest = hashlib.sha256(data_to_hash).digest()
|
||||
log.debug('signing digest: %s', util.hexlify(digest))
|
||||
params = signer_func(digest=digest)
|
||||
sig = b''.join(mpi(p) for p in params)
|
||||
|
||||
return bytes(header + hashed + unhashed +
|
||||
digest[:2] + # used for decoder's sanity check
|
||||
sig) # actual ECDSA signature
|
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -0,0 +1 @@
|
||||
"""Tests for GPG module."""
|
@ -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
|
@ -0,0 +1,62 @@
|
||||
import glob
|
||||
import io
|
||||
import os
|
||||
|
||||
import pytest
|
||||
|
||||
from .. import decode, protocol
|
||||
from ... import util
|
||||
|
||||
|
||||
def test_subpackets():
|
||||
s = io.BytesIO(b'\x00\x05\x02\xAB\xCD\x01\xEF')
|
||||
assert decode.parse_subpackets(util.Reader(s)) == [b'\xAB\xCD', b'\xEF']
|
||||
|
||||
|
||||
def test_subpackets_prefix():
|
||||
for n in [0, 1, 2, 4, 5, 10, 191, 192, 193,
|
||||
255, 256, 257, 8383, 8384, 65530]:
|
||||
item = b'?' * n # create dummy subpacket
|
||||
prefixed = protocol.subpackets(item)
|
||||
result = decode.parse_subpackets(util.Reader(io.BytesIO(prefixed)))
|
||||
assert [item] == result
|
||||
|
||||
|
||||
def test_mpi():
|
||||
s = io.BytesIO(b'\x00\x09\x01\x23')
|
||||
assert decode.parse_mpi(util.Reader(s)) == 0x123
|
||||
|
||||
s = io.BytesIO(b'\x00\x09\x01\x23\x00\x03\x05')
|
||||
assert decode.parse_mpis(util.Reader(s), n=2) == [0x123, 5]
|
||||
|
||||
|
||||
cwd = os.path.join(os.path.dirname(__file__))
|
||||
input_files = glob.glob(os.path.join(cwd, '*.gpg'))
|
||||
|
||||
|
||||
@pytest.fixture(params=input_files)
|
||||
def public_key_path(request):
|
||||
return request.param
|
||||
|
||||
|
||||
def test_gpg_files(public_key_path): # pylint: disable=redefined-outer-name
|
||||
with open(public_key_path, 'rb') as f:
|
||||
assert list(decode.parse_packets(f))
|
||||
|
||||
|
||||
def test_has_custom_subpacket():
|
||||
sig = {'unhashed_subpackets': []}
|
||||
assert not decode.has_custom_subpacket(sig)
|
||||
|
||||
custom_markers = [
|
||||
protocol.CUSTOM_SUBPACKET,
|
||||
protocol.subpacket(10, protocol.CUSTOM_KEY_LABEL),
|
||||
]
|
||||
for marker in custom_markers:
|
||||
sig = {'unhashed_subpackets': [marker]}
|
||||
assert decode.has_custom_subpacket(sig)
|
||||
|
||||
|
||||
def test_load_by_keygrip_missing():
|
||||
with pytest.raises(KeyError):
|
||||
decode.load_by_keygrip(pubkey_bytes=b'', keygrip=b'')
|
@ -0,0 +1,101 @@
|
||||
import io
|
||||
|
||||
import mock
|
||||
|
||||
from .. import keyring
|
||||
|
||||
|
||||
def test_unescape_short():
|
||||
assert keyring.unescape(b'abc%0AX%0D %25;.-+()') == b'abc\nX\r %;.-+()'
|
||||
|
||||
|
||||
def test_unescape_long():
|
||||
escaped = (b'D (7:sig-val(3:dsa(1:r32:\x1d\x15.\x12\xe8h\x19\xd9O\xeb\x06'
|
||||
b'yD?a:/\xae\xdb\xac\x93\xa6\x86\xcbs\xb8\x03\xf1\xcb\x89\xc7'
|
||||
b'\x1f)(1:s32:%25\xb5\x04\x94\xc7\xc4X\xc7\xe0%0D\x08\xbb%0DuN'
|
||||
b'\x9c6}[\xc2=t\x8c\xfdD\x81\xe8\xdd\x86=\xe2\xa9)))')
|
||||
unescaped = (b'D (7:sig-val(3:dsa(1:r32:\x1d\x15.\x12\xe8h\x19\xd9O\xeb'
|
||||
b'\x06yD?a:/\xae\xdb\xac\x93\xa6\x86\xcbs\xb8\x03\xf1\xcb\x89'
|
||||
b'\xc7\x1f)(1:s32:%\xb5\x04\x94\xc7\xc4X\xc7\xe0\r\x08\xbb\ru'
|
||||
b'N\x9c6}[\xc2=t\x8c\xfdD\x81\xe8\xdd\x86=\xe2\xa9)))')
|
||||
assert keyring.unescape(escaped) == unescaped
|
||||
|
||||
|
||||
def test_parse_term():
|
||||
assert keyring.parse(b'4:abcdXXX') == (b'abcd', b'XXX')
|
||||
|
||||
|
||||
def test_parse_ecdsa():
|
||||
sig, rest = keyring.parse(b'(7:sig-val(5:ecdsa'
|
||||
b'(1:r2:\x01\x02)(1:s2:\x03\x04)))')
|
||||
values = [[b'r', b'\x01\x02'], [b's', b'\x03\x04']]
|
||||
assert sig == [b'sig-val', [b'ecdsa'] + values]
|
||||
assert rest == b''
|
||||
assert keyring.parse_sig(sig) == (0x102, 0x304)
|
||||
|
||||
|
||||
def test_parse_rsa():
|
||||
sig, rest = keyring.parse(b'(7:sig-val(3:rsa(1:s4:\x01\x02\x03\x04)))')
|
||||
assert sig == [b'sig-val', [b'rsa', [b's', b'\x01\x02\x03\x04']]]
|
||||
assert rest == b''
|
||||
assert keyring.parse_sig(sig) == (0x1020304,)
|
||||
|
||||
|
||||
class FakeSocket:
|
||||
def __init__(self):
|
||||
self.rx = io.BytesIO()
|
||||
self.tx = io.BytesIO()
|
||||
|
||||
def recv(self, n):
|
||||
return self.rx.read(n)
|
||||
|
||||
def sendall(self, data):
|
||||
self.tx.write(data)
|
||||
|
||||
|
||||
def test_sign_digest():
|
||||
sock = FakeSocket()
|
||||
sock.rx.write(b'OK Pleased to meet you, process XYZ\n')
|
||||
sock.rx.write(b'OK\n' * 6)
|
||||
sock.rx.write(b'D (7:sig-val(3:rsa(1:s16:0123456789ABCDEF)))\n')
|
||||
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,
|
||||
environ={'DISPLAY': ':0'})
|
||||
assert sig == (0x30313233343536373839414243444546,)
|
||||
assert sock.tx.getvalue() == b'''RESET
|
||||
OPTION ttyname=/dev/pts/0
|
||||
OPTION display=:0
|
||||
SIGKEY 1234
|
||||
SETHASH 8 4141414141414141414141414141414141414141414141414141414141414141
|
||||
SETKEYDESC Sign+a+new+TREZOR-based+subkey
|
||||
PKSIGN
|
||||
'''
|
||||
|
||||
|
||||
def test_iterlines():
|
||||
sock = FakeSocket()
|
||||
sock.rx.write(b'foo\nbar\nxyz')
|
||||
sock.rx.seek(0)
|
||||
assert list(keyring.iterlines(sock)) == [b'foo', b'bar']
|
||||
|
||||
|
||||
def test_get_agent_sock_path():
|
||||
sp = mock.Mock(spec=['check_output'])
|
||||
sp.check_output.return_value = b'''sysconfdir:/usr/local/etc/gnupg
|
||||
bindir:/usr/local/bin
|
||||
libexecdir:/usr/local/libexec
|
||||
libdir:/usr/local/lib/gnupg
|
||||
datadir:/usr/local/share/gnupg
|
||||
localedir:/usr/local/share/locale
|
||||
dirmngr-socket:/run/user/1000/gnupg/S.dirmngr
|
||||
agent-ssh-socket:/run/user/1000/gnupg/S.gpg-agent.ssh
|
||||
agent-socket:/run/user/1000/gnupg/S.gpg-agent
|
||||
homedir:/home/roman/.gnupg
|
||||
'''
|
||||
expected = b'/run/user/1000/gnupg/S.gpg-agent'
|
||||
assert keyring.get_agent_sock_path(sp=sp) == expected
|
@ -0,0 +1,107 @@
|
||||
import ecdsa
|
||||
import ed25519
|
||||
import pytest
|
||||
|
||||
from .. import protocol
|
||||
from ... import formats
|
||||
|
||||
|
||||
def test_packet():
|
||||
assert protocol.packet(1, b'') == b'\x84\x00'
|
||||
assert protocol.packet(2, b'A') == b'\x88\x01A'
|
||||
blob = b'B' * 0xAB
|
||||
assert protocol.packet(3, blob) == b'\x8c\xAB' + blob
|
||||
blob = b'C' * 0x1234
|
||||
assert protocol.packet(3, blob) == b'\x8d\x12\x34' + blob
|
||||
blob = b'D' * 0x12345678
|
||||
assert protocol.packet(4, blob) == b'\x92\x12\x34\x56\x78' + blob
|
||||
|
||||
|
||||
def test_subpackets():
|
||||
assert protocol.subpacket(1, b'') == b'\x01'
|
||||
assert protocol.subpacket(2, '>H', 0x0304) == b'\x02\x03\x04'
|
||||
assert protocol.subpacket_long(9, 0x12345678) == b'\x09\x12\x34\x56\x78'
|
||||
assert protocol.subpacket_time(0x12345678) == b'\x02\x12\x34\x56\x78'
|
||||
assert protocol.subpacket_byte(0xAB, 0xCD) == b'\xAB\xCD'
|
||||
assert protocol.subpackets() == b'\x00\x00'
|
||||
assert protocol.subpackets(b'ABC', b'12345') == b'\x00\x0A\x03ABC\x0512345'
|
||||
|
||||
|
||||
def test_mpi():
|
||||
assert protocol.mpi(0x123) == b'\x00\x09\x01\x23'
|
||||
|
||||
|
||||
def test_armor():
|
||||
data = bytearray(range(256))
|
||||
assert protocol.armor(data, 'TEST') == '''-----BEGIN PGP TEST-----
|
||||
Version: GnuPG v2
|
||||
|
||||
AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8gISIjJCUmJygpKissLS4v
|
||||
MDEyMzQ1Njc4OTo7PD0+P0BBQkNERUZHSElKS0xNTk9QUVJTVFVWV1hZWltcXV5f
|
||||
YGFiY2RlZmdoaWprbG1ub3BxcnN0dXZ3eHl6e3x9fn+AgYKDhIWGh4iJiouMjY6P
|
||||
kJGSk5SVlpeYmZqbnJ2en6ChoqOkpaanqKmqq6ytrq+wsbKztLW2t7i5uru8vb6/
|
||||
wMHCw8TFxsfIycrLzM3Oz9DR0tPU1dbX2Nna29zd3t/g4eLj5OXm5+jp6uvs7e7v
|
||||
8PHy8/T19vf4+fr7/P3+/w==
|
||||
=W700
|
||||
-----END PGP TEST-----
|
||||
'''
|
||||
|
||||
|
||||
def test_make_signature():
|
||||
def signer_func(digest):
|
||||
assert digest == (b'\xd0\xe5]|\x8bP\xe6\x91\xb3\xe8+\xf4A\xf0`(\xb1'
|
||||
b'\xc7\xf4;\x86\x97s\xdb\x9a\xda\xee< \xcb\x9e\x00')
|
||||
return (7, 8)
|
||||
|
||||
sig = protocol.make_signature(
|
||||
signer_func=signer_func,
|
||||
data_to_sign=b'Hello World!',
|
||||
public_algo=22,
|
||||
hashed_subpackets=[protocol.subpacket_time(1)],
|
||||
unhashed_subpackets=[],
|
||||
sig_type=25)
|
||||
assert sig == (b'\x04\x19\x16\x08\x00\x06\x05\x02'
|
||||
b'\x00\x00\x00\x01\x00\x00\xd0\xe5\x00\x03\x07\x00\x04\x08')
|
||||
|
||||
|
||||
def test_nist256p1():
|
||||
sk = ecdsa.SigningKey.from_secret_exponent(secexp=1, curve=ecdsa.NIST256p)
|
||||
vk = sk.get_verifying_key()
|
||||
pk = protocol.PublicKey(curve_name=formats.CURVE_NIST256,
|
||||
created=42, verifying_key=vk)
|
||||
assert repr(pk) == 'GPG public key nist256p1/F82361D9'
|
||||
assert pk.keygrip() == b'\x95\x85.\x91\x7f\xe2\xc3\x91R\xba\x99\x81\x92\xb5y\x1d\xb1\\\xdc\xf0'
|
||||
|
||||
|
||||
def test_nist256p1_ecdh():
|
||||
sk = ecdsa.SigningKey.from_secret_exponent(secexp=1, curve=ecdsa.NIST256p)
|
||||
vk = sk.get_verifying_key()
|
||||
pk = protocol.PublicKey(curve_name=formats.CURVE_NIST256,
|
||||
created=42, verifying_key=vk, ecdh=True)
|
||||
assert repr(pk) == 'GPG public key nist256p1/5811DF46'
|
||||
assert pk.keygrip() == b'\x95\x85.\x91\x7f\xe2\xc3\x91R\xba\x99\x81\x92\xb5y\x1d\xb1\\\xdc\xf0'
|
||||
|
||||
|
||||
def test_ed25519():
|
||||
sk = ed25519.SigningKey(b'\x00' * 32)
|
||||
vk = sk.get_verifying_key()
|
||||
pk = protocol.PublicKey(curve_name=formats.CURVE_ED25519,
|
||||
created=42, verifying_key=vk)
|
||||
assert repr(pk) == 'GPG public key ed25519/36B40FE6'
|
||||
assert pk.keygrip() == b'\xbf\x01\x90l\x17\xb64\xa3-\xf4\xc0gr\x99\x18<\xddBQ?'
|
||||
|
||||
|
||||
def test_curve25519():
|
||||
sk = ed25519.SigningKey(b'\x00' * 32)
|
||||
vk = sk.get_verifying_key()
|
||||
pk = protocol.PublicKey(curve_name=formats.ECDH_CURVE25519,
|
||||
created=42, verifying_key=vk)
|
||||
assert repr(pk) == 'GPG public key curve25519/69460384'
|
||||
assert pk.keygrip() == b'x\xd6\x86\xe4\xa6\xfc;\x0fY\xe1}Lw\xc4\x9ed\xf1Q\x8a\x00'
|
||||
|
||||
|
||||
def test_get_curve_name_by_oid():
|
||||
for name, info in protocol.SUPPORTED_CURVES.items():
|
||||
assert protocol.get_curve_name_by_oid(info['oid']) == name
|
||||
with pytest.raises(KeyError):
|
||||
protocol.get_curve_name_by_oid('BAD_OID')
|
@ -0,0 +1,166 @@
|
||||
"""UNIX-domain socket server for ssh-agent implementation."""
|
||||
import contextlib
|
||||
import logging
|
||||
import os
|
||||
import socket
|
||||
import subprocess
|
||||
import threading
|
||||
|
||||
from . import util
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def remove_file(path, remove=os.remove, exists=os.path.exists):
|
||||
"""Remove file, and raise OSError if still exists."""
|
||||
try:
|
||||
remove(path)
|
||||
except OSError:
|
||||
if exists(path):
|
||||
raise
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def unix_domain_socket_server(sock_path):
|
||||
"""
|
||||
Create UNIX-domain socket on specified path.
|
||||
|
||||
Listen on it, and delete it after the generated context is over.
|
||||
"""
|
||||
log.debug('serving on %s', sock_path)
|
||||
remove_file(sock_path)
|
||||
|
||||
server = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||
server.bind(sock_path)
|
||||
server.listen(1)
|
||||
try:
|
||||
yield server
|
||||
finally:
|
||||
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.
|
||||
|
||||
Since this function may be called concurrently from server_thread,
|
||||
the specified mutex is used to synchronize the device handling.
|
||||
|
||||
Exit when EOFError is raised.
|
||||
All other exceptions are logged as warnings.
|
||||
"""
|
||||
try:
|
||||
log.debug('welcome agent')
|
||||
with contextlib.closing(conn):
|
||||
while True:
|
||||
msg = util.read_frame(conn)
|
||||
with mutex:
|
||||
reply = handler.handle(msg=msg)
|
||||
util.send(conn, reply)
|
||||
except EOFError:
|
||||
log.debug('goodbye agent')
|
||||
except Exception as e: # pylint: disable=broad-except
|
||||
log.warning('error: %s', e, exc_info=True)
|
||||
|
||||
|
||||
def retry(func, exception_type, quit_event):
|
||||
"""
|
||||
Run the function, retrying when the specified exception_type occurs.
|
||||
|
||||
Poll quit_event on each iteration, to be responsive to an external
|
||||
exit request.
|
||||
"""
|
||||
while True:
|
||||
if quit_event.is_set():
|
||||
raise StopIteration
|
||||
try:
|
||||
return func()
|
||||
except exception_type:
|
||||
pass
|
||||
|
||||
|
||||
def server_thread(sock, handle_conn, quit_event):
|
||||
"""Run a server on the specified socket."""
|
||||
log.debug('server thread started')
|
||||
|
||||
def accept_connection():
|
||||
conn, _ = sock.accept()
|
||||
conn.settimeout(None)
|
||||
return conn
|
||||
|
||||
while True:
|
||||
log.debug('waiting for connection on %s', sock.getsockname())
|
||||
try:
|
||||
conn = retry(accept_connection, socket.timeout, quit_event)
|
||||
except StopIteration:
|
||||
log.debug('server stopped')
|
||||
break
|
||||
# Handle connections from SSH concurrently.
|
||||
threading.Thread(target=handle_conn,
|
||||
kwargs=dict(conn=conn)).start()
|
||||
log.debug('server thread stopped')
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def spawn(func, kwargs):
|
||||
"""Spawn a thread, and join it after the context is over."""
|
||||
t = threading.Thread(target=func, kwargs=kwargs)
|
||||
t.start()
|
||||
yield
|
||||
t.join()
|
||||
|
||||
|
||||
def run_process(command, environ):
|
||||
"""
|
||||
Run the specified process and wait until it finishes.
|
||||
|
||||
Use environ dict for environment variables.
|
||||
"""
|
||||
log.info('running %r with %r', command, environ)
|
||||
env = dict(os.environ)
|
||||
env.update(environ)
|
||||
try:
|
||||
p = subprocess.Popen(args=command, env=env)
|
||||
except OSError as e:
|
||||
raise OSError('cannot run %r: %s' % (command, e))
|
||||
log.debug('subprocess %d is running', p.pid)
|
||||
ret = p.wait()
|
||||
log.debug('subprocess %d exited: %d', p.pid, ret)
|
||||
return ret
|
@ -0,0 +1,313 @@
|
||||
"""SSH-agent implementation using hardware authentication devices."""
|
||||
import contextlib
|
||||
import functools
|
||||
import io
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import signal
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
|
||||
import pkg_resources
|
||||
import configargparse
|
||||
import daemon
|
||||
|
||||
from .. import device, formats, server, util
|
||||
from . import client, protocol
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
UNIX_SOCKET_TIMEOUT = 0.1
|
||||
|
||||
|
||||
def ssh_args(conn):
|
||||
"""Create SSH command for connecting specified server."""
|
||||
I, = conn.identities
|
||||
identity = I.identity_dict
|
||||
pubkey_tempfile, = conn.public_keys_as_files()
|
||||
|
||||
args = []
|
||||
if 'port' in identity:
|
||||
args += ['-p', identity['port']]
|
||||
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(conn):
|
||||
"""Create SSH command for connecting specified server."""
|
||||
I, = conn.identities
|
||||
identity = I.identity_dict
|
||||
|
||||
args = []
|
||||
if 'port' in identity:
|
||||
args += ['-p', identity['port']]
|
||||
if 'user' in identity:
|
||||
args += [identity['user']+'@'+identity['host']]
|
||||
else:
|
||||
args += [identity['host']]
|
||||
|
||||
return args
|
||||
|
||||
|
||||
def _to_unicode(s):
|
||||
try:
|
||||
return unicode(s, 'utf-8')
|
||||
except NameError:
|
||||
return s
|
||||
|
||||
|
||||
def create_agent_parser(device_type):
|
||||
"""Create an ArgumentParser for this tool."""
|
||||
epilog = ('See https://github.com/romanz/trezor-agent/blob/master/'
|
||||
'doc/README-SSH.md for usage examples.')
|
||||
p = configargparse.ArgParser(default_config_files=['~/.ssh/agent.config'],
|
||||
epilog=epilog)
|
||||
p.add_argument('-v', '--verbose', default=0, action='count')
|
||||
|
||||
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)
|
||||
|
||||
curve_names = [name for name in formats.SUPPORTED_CURVES]
|
||||
curve_names = ', '.join(sorted(curve_names))
|
||||
p.add_argument('-e', '--ecdsa-curve-name', metavar='CURVE',
|
||||
default=formats.CURVE_NIST256,
|
||||
help='specify ECDSA curve name: ' + curve_names)
|
||||
p.add_argument('--timeout',
|
||||
default=UNIX_SOCKET_TIMEOUT, type=float,
|
||||
help='timeout for accepting SSH client connections')
|
||||
p.add_argument('--debug', default=False, action='store_true',
|
||||
help='log SSH protocol messages for debugging.')
|
||||
p.add_argument('--log-file', type=str,
|
||||
help='Path to the log file (to be written by the agent).')
|
||||
p.add_argument('--sock-path', type=str,
|
||||
help='Path to the UNIX domain socket of the agent.')
|
||||
|
||||
p.add_argument('--pin-entry-binary', type=str, default='pinentry',
|
||||
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'))
|
||||
g.add_argument('-c', '--connect', default=False, action='store_true',
|
||||
help='connect to specified host via SSH')
|
||||
g.add_argument('--mosh', default=False, action='store_true',
|
||||
help='connect to specified host via using Mosh')
|
||||
|
||||
p.add_argument('identity', type=_to_unicode, default=None,
|
||||
help='proto://[user@]host[:port][/path]')
|
||||
p.add_argument('command', type=str, nargs='*', metavar='ARGUMENT',
|
||||
help='command to run under the SSH agent')
|
||||
return p
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def serve(handler, sock_path, timeout=UNIX_SOCKET_TIMEOUT):
|
||||
"""
|
||||
Start the ssh-agent server on a UNIX-domain socket.
|
||||
|
||||
If no connection is made during the specified timeout,
|
||||
retry until the context is over.
|
||||
"""
|
||||
ssh_version = subprocess.check_output(['ssh', '-V'],
|
||||
stderr=subprocess.STDOUT)
|
||||
log.debug('local SSH version: %r', ssh_version)
|
||||
environ = {'SSH_AUTH_SOCK': sock_path, 'SSH_AGENT_PID': str(os.getpid())}
|
||||
device_mutex = threading.Lock()
|
||||
with server.unix_domain_socket_server(sock_path) as sock:
|
||||
sock.settimeout(timeout)
|
||||
quit_event = threading.Event()
|
||||
handle_conn = functools.partial(server.handle_connection,
|
||||
handler=handler,
|
||||
mutex=device_mutex)
|
||||
kwargs = dict(sock=sock,
|
||||
handle_conn=handle_conn,
|
||||
quit_event=quit_event)
|
||||
with server.spawn(server.server_thread, kwargs):
|
||||
try:
|
||||
yield environ
|
||||
finally:
|
||||
log.debug('closing server')
|
||||
quit_event.set()
|
||||
|
||||
|
||||
def run_server(conn, command, sock_path, debug, timeout):
|
||||
"""Common code for run_agent and run_git below."""
|
||||
ret = 0
|
||||
try:
|
||||
handler = protocol.Handler(conn=conn, debug=debug)
|
||||
with serve(handler=handler, sock_path=sock_path,
|
||||
timeout=timeout) as env:
|
||||
if command:
|
||||
ret = server.run_process(command=command, environ=env)
|
||||
else:
|
||||
signal.pause() # wait for signal (e.g. SIGINT)
|
||||
except KeyboardInterrupt:
|
||||
log.info('server stopped')
|
||||
return ret
|
||||
|
||||
|
||||
def handle_connection_error(func):
|
||||
"""Fail with non-zero exit code."""
|
||||
@functools.wraps(func)
|
||||
def wrapper(*args, **kwargs):
|
||||
try:
|
||||
return func(*args, **kwargs)
|
||||
except device.interface.NotFoundError as e:
|
||||
log.error('Connection error (try unplugging and replugging your device): %s', e)
|
||||
return 1
|
||||
return wrapper
|
||||
|
||||
|
||||
def parse_config(contents):
|
||||
"""Parse config file into a list of Identity objects."""
|
||||
for identity_str, curve_name in re.findall(r'\<(.*?)\|(.*?)\>', contents):
|
||||
yield device.interface.Identity(identity_str=identity_str,
|
||||
curve_name=curve_name)
|
||||
|
||||
|
||||
def import_public_keys(contents):
|
||||
"""Load (previously exported) SSH public keys from a file's contents."""
|
||||
for line in io.StringIO(contents):
|
||||
# Verify this line represents valid SSH public key
|
||||
formats.import_public_key(line)
|
||||
yield line
|
||||
|
||||
|
||||
class JustInTimeConnection:
|
||||
"""Connect to the device just before the needed operation."""
|
||||
|
||||
def __init__(self, conn_factory, identities, public_keys=None):
|
||||
"""Create a JIT connection object."""
|
||||
self.conn_factory = conn_factory
|
||||
self.identities = identities
|
||||
self.public_keys_cache = public_keys
|
||||
self.public_keys_tempfiles = []
|
||||
|
||||
def public_keys(self):
|
||||
"""Return a list of SSH public keys (in textual format)."""
|
||||
if not self.public_keys_cache:
|
||||
conn = self.conn_factory()
|
||||
self.public_keys_cache = conn.export_public_keys(self.identities)
|
||||
return self.public_keys_cache
|
||||
|
||||
def parse_public_keys(self):
|
||||
"""Parse SSH public keys into dictionaries."""
|
||||
public_keys = [formats.import_public_key(pk)
|
||||
for pk in self.public_keys()]
|
||||
for pk, identity in zip(public_keys, self.identities):
|
||||
pk['identity'] = identity
|
||||
return public_keys
|
||||
|
||||
def 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()
|
||||
return conn.sign_ssh_challenge(blob=blob, identity=identity)
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def _dummy_context():
|
||||
yield
|
||||
|
||||
|
||||
def _get_sock_path(args):
|
||||
sock_path = args.sock_path
|
||||
if not sock_path:
|
||||
if args.foreground:
|
||||
log.error('running in foreground mode requires specifying UNIX socket path')
|
||||
sys.exit(1)
|
||||
else:
|
||||
sock_path = tempfile.mktemp(prefix='trezor-ssh-agent-')
|
||||
return sock_path
|
||||
|
||||
|
||||
@handle_connection_error
|
||||
def main(device_type):
|
||||
"""Run ssh-agent using given hardware client factory."""
|
||||
args = create_agent_parser(device_type=device_type).parse_args()
|
||||
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')
|
||||
# Allow loading previously exported SSH public keys
|
||||
if filename.endswith('.pub'):
|
||||
public_keys = list(import_public_keys(contents))
|
||||
identities = list(parse_config(contents))
|
||||
else:
|
||||
identities = [device.interface.Identity(
|
||||
identity_str=args.identity, curve_name=args.ecdsa_curve_name)]
|
||||
for index, identity in enumerate(identities):
|
||||
identity.identity_dict['proto'] = u'ssh'
|
||||
log.info('identity #%d: %s', index, identity.to_string())
|
||||
|
||||
# override default PIN/passphrase entry tools (relevant for TREZOR/Keepkey):
|
||||
device_type.ui = device.ui.UI(device_type=device_type, config=vars(args))
|
||||
device_type.cached_passphrase_ack = util.ExpiringCache(
|
||||
args.cache_expiry_seconds)
|
||||
|
||||
conn = JustInTimeConnection(
|
||||
conn_factory=lambda: client.Client(device_type()),
|
||||
identities=identities, public_keys=public_keys)
|
||||
|
||||
sock_path = _get_sock_path(args)
|
||||
command = args.command
|
||||
context = _dummy_context()
|
||||
if args.connect:
|
||||
command = ['ssh'] + ssh_args(conn) + args.command
|
||||
elif args.mosh:
|
||||
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()
|
||||
|
||||
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)
|
||||
else:
|
||||
for pk in conn.public_keys():
|
||||
sys.stdout.write(pk)
|
||||
return 0 # success exit code
|
@ -0,0 +1,65 @@
|
||||
"""
|
||||
Connection to hardware authentication device.
|
||||
|
||||
It is used for getting SSH public keys and ECDSA signing of server requests.
|
||||
"""
|
||||
import io
|
||||
import logging
|
||||
|
||||
from . import formats, util
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Client:
|
||||
"""Client wrapper for SSH authentication device."""
|
||||
|
||||
def __init__(self, device):
|
||||
"""Connect to hardware device."""
|
||||
self.device = device
|
||||
|
||||
def export_public_keys(self, identities):
|
||||
"""Export SSH public keys from the device."""
|
||||
public_keys = []
|
||||
with self.device:
|
||||
for i in identities:
|
||||
pubkey = self.device.pubkey(identity=i)
|
||||
vk = formats.decompress_pubkey(pubkey=pubkey,
|
||||
curve_name=i.curve_name)
|
||||
public_key = formats.export_public_key(vk=vk,
|
||||
label=i.to_string())
|
||||
public_keys.append(public_key)
|
||||
return public_keys
|
||||
|
||||
def sign_ssh_challenge(self, blob, identity):
|
||||
"""Sign given blob using a private key on the device."""
|
||||
msg = _parse_ssh_blob(blob)
|
||||
log.debug('%s: user %r via %r (%r)',
|
||||
msg['conn'], msg['user'], msg['auth'], msg['key_type'])
|
||||
log.debug('nonce: %r', msg['nonce'])
|
||||
fp = msg['public_key']['fingerprint']
|
||||
log.debug('fingerprint: %s', fp)
|
||||
log.debug('hidden challenge size: %d bytes', len(blob))
|
||||
|
||||
log.info('please confirm user "%s" login to "%s" using %s...',
|
||||
msg['user'].decode('ascii'), identity.to_string(),
|
||||
self.device)
|
||||
|
||||
with self.device:
|
||||
return self.device.sign(blob=blob, identity=identity)
|
||||
|
||||
|
||||
def _parse_ssh_blob(data):
|
||||
res = {}
|
||||
i = io.BytesIO(data)
|
||||
res['nonce'] = util.read_frame(i)
|
||||
i.read(1) # SSH2_MSG_USERAUTH_REQUEST == 50 (from ssh2.h, line 108)
|
||||
res['user'] = util.read_frame(i)
|
||||
res['conn'] = util.read_frame(i)
|
||||
res['auth'] = util.read_frame(i)
|
||||
i.read(1) # have_sig == 1 (from sshconnect2.c, line 1056)
|
||||
res['key_type'] = util.read_frame(i)
|
||||
public_key = util.read_frame(i)
|
||||
res['public_key'] = formats.parse_pubkey(public_key)
|
||||
assert not i.read()
|
||||
return res
|
@ -0,0 +1,160 @@
|
||||
"""
|
||||
SSH-agent protocol implementation library.
|
||||
|
||||
See https://github.com/openssh/openssh-portable/blob/master/PROTOCOL.agent and
|
||||
http://ptspts.blogspot.co.il/2010/06/how-to-use-ssh-agent-programmatically.html
|
||||
for more details.
|
||||
The server's source code can be found here:
|
||||
https://github.com/openssh/openssh-portable/blob/master/authfd.c
|
||||
"""
|
||||
import io
|
||||
import logging
|
||||
|
||||
from . import formats, util
|
||||
|
||||
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,
|
||||
)
|
||||
|
||||
|
||||
def msg_code(name):
|
||||
"""Convert string name into a integer message code."""
|
||||
return COMMANDS[name]
|
||||
|
||||
|
||||
def msg_name(code):
|
||||
"""Convert integer message code into a string name."""
|
||||
ids = {v: k for k, v in COMMANDS.items()}
|
||||
return ids[code]
|
||||
|
||||
|
||||
def failure():
|
||||
"""Return error code to SSH binary."""
|
||||
error_msg = util.pack('B', msg_code('SSH_AGENT_FAILURE'))
|
||||
return util.frame(error_msg)
|
||||
|
||||
|
||||
def _legacy_pubs(buf):
|
||||
"""SSH v1 public keys are not supported."""
|
||||
leftover = buf.read()
|
||||
if leftover:
|
||||
log.warning('skipping leftover: %r', leftover)
|
||||
code = util.pack('B', msg_code('SSH_AGENT_RSA_IDENTITIES_ANSWER'))
|
||||
num = util.pack('L', 0) # no SSH v1 keys
|
||||
return util.frame(code, num)
|
||||
|
||||
|
||||
class Handler:
|
||||
"""ssh-agent protocol handler."""
|
||||
|
||||
def __init__(self, conn, debug=False):
|
||||
"""
|
||||
Create a protocol handler with specified public keys.
|
||||
|
||||
Use specified signer function to sign SSH authentication requests.
|
||||
"""
|
||||
self.conn = conn
|
||||
self.debug = debug
|
||||
|
||||
self.methods = {
|
||||
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,
|
||||
}
|
||||
|
||||
def handle(self, msg):
|
||||
"""Handle SSH message from the SSH client and return the response."""
|
||||
debug_msg = ': {!r}'.format(msg) if self.debug else ''
|
||||
log.debug('request: %d bytes%s', len(msg), debug_msg)
|
||||
buf = io.BytesIO(msg)
|
||||
code, = util.recv(buf, '>B')
|
||||
if code not in self.methods:
|
||||
log.warning('Unsupported command: %s (%d)', msg_name(code), code)
|
||||
return failure()
|
||||
|
||||
method = self.methods[code]
|
||||
log.debug('calling %s()', method.__name__)
|
||||
reply = method(buf=buf)
|
||||
debug_reply = ': {!r}'.format(reply) if self.debug else ''
|
||||
log.debug('reply: %d bytes%s', len(reply), debug_reply)
|
||||
return reply
|
||||
|
||||
def list_pubs(self, buf):
|
||||
"""SSH v2 public keys are serialized and returned."""
|
||||
assert not buf.read()
|
||||
keys = self.conn.parse_public_keys()
|
||||
code = util.pack('B', msg_code('SSH2_AGENT_IDENTITIES_ANSWER'))
|
||||
num = util.pack('L', len(keys))
|
||||
log.debug('available keys: %s', [k['name'] for k in keys])
|
||||
for i, k in enumerate(keys):
|
||||
log.debug('%2d) %s', i+1, k['fingerprint'])
|
||||
pubs = [util.frame(k['blob']) + util.frame(k['name']) for k in keys]
|
||||
return util.frame(code, num, *pubs)
|
||||
|
||||
def sign_message(self, buf):
|
||||
"""
|
||||
SSH v2 public key authentication is performed.
|
||||
|
||||
If the required key is not supported, raise KeyError
|
||||
If the signature is invalid, raise ValueError
|
||||
"""
|
||||
key = formats.parse_pubkey(util.read_frame(buf))
|
||||
log.debug('looking for %s', key['fingerprint'])
|
||||
blob = util.read_frame(buf)
|
||||
assert util.read_frame(buf) == b''
|
||||
assert not buf.read()
|
||||
|
||||
for k in self.conn.parse_public_keys():
|
||||
if (k['fingerprint']) == (key['fingerprint']):
|
||||
log.debug('using key %r (%s)', k['name'], k['fingerprint'])
|
||||
key = k
|
||||
break
|
||||
else:
|
||||
raise KeyError('key not found')
|
||||
|
||||
label = key['name'].decode('utf-8')
|
||||
log.debug('signing %d-byte blob with "%s" key', len(blob), label)
|
||||
try:
|
||||
signature = self.conn.sign(blob=blob, identity=key['identity'])
|
||||
except IOError:
|
||||
return failure()
|
||||
log.debug('signature: %r', signature)
|
||||
|
||||
try:
|
||||
sig_bytes = key['verifier'](sig=signature, msg=blob)
|
||||
log.info('signature status: OK')
|
||||
except formats.ecdsa.BadSignatureError:
|
||||
log.exception('signature status: ERROR')
|
||||
raise ValueError('invalid ECDSA signature')
|
||||
|
||||
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)
|
@ -0,0 +1 @@
|
||||
"""Unit-tests for this package."""
|
@ -0,0 +1,72 @@
|
||||
import io
|
||||
|
||||
import mock
|
||||
import pytest
|
||||
|
||||
from .. import client, device, formats, util
|
||||
|
||||
ADDR = [2147483661, 2810943954, 3938368396, 3454558782, 3848009040]
|
||||
CURVE = 'nist256p1'
|
||||
|
||||
PUBKEY = (b'\x03\xd8(\xb5\xa6`\xbet0\x95\xac:[;]\xdc,\xbd\xdc?\xd7\xc0\xec'
|
||||
b'\xdd\xbc+\xfar~\x9dAis')
|
||||
PUBKEY_TEXT = ('ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzd'
|
||||
'HAyNTYAAABBBNgotaZgvnQwlaw6Wztd3Cy93D/XwOzdvCv6cn6dQWlzNMEQeW'
|
||||
'VUfhvrGljR2Z/CMRONY6ejB+9PnpUOPuzYqi8= <localhost:22|nist256p1>\n')
|
||||
|
||||
|
||||
class MockDevice(device.interface.Device): # pylint: disable=abstract-method
|
||||
|
||||
def connect(self): # pylint: disable=no-self-use
|
||||
return mock.Mock()
|
||||
|
||||
def pubkey(self, identity, ecdh=False): # pylint: disable=unused-argument
|
||||
assert self.conn
|
||||
return PUBKEY
|
||||
|
||||
def sign(self, identity, blob):
|
||||
"""Sign given blob and return the signature (as bytes)."""
|
||||
assert self.conn
|
||||
assert blob == BLOB
|
||||
return SIG
|
||||
|
||||
|
||||
BLOB = (b'\x00\x00\x00 \xce\xe0\xc9\xd5\xceu/\xe8\xc5\xf2\xbfR+x\xa1\xcf\xb0'
|
||||
b'\x8e;R\xd3)m\x96\x1b\xb4\xd8s\xf1\x99\x16\xaa2\x00\x00\x00\x05roman'
|
||||
b'\x00\x00\x00\x0essh-connection\x00\x00\x00\tpublickey'
|
||||
b'\x01\x00\x00\x00\x13ecdsa-sha2-nistp256\x00\x00\x00h\x00\x00\x00'
|
||||
b'\x13ecdsa-sha2-nistp256\x00\x00\x00\x08nistp256\x00\x00\x00A'
|
||||
b'\x04\xd8(\xb5\xa6`\xbet0\x95\xac:[;]\xdc,\xbd\xdc?\xd7\xc0\xec'
|
||||
b'\xdd\xbc+\xfar~\x9dAis4\xc1\x10yeT~\x1b\xeb\x1aX\xd1\xd9\x9f\xc21'
|
||||
b'\x13\x8dc\xa7\xa3\x07\xefO\x9e\x95\x0e>\xec\xd8\xaa/')
|
||||
|
||||
SIG = (b'R\x19T\xf2\x84$\xef#\x0e\xee\x04X\xc6\xc3\x99T`\xd1\xd8\xf7!'
|
||||
b'\x862@cx\xb8\xb9i@1\x1b3#\x938\x86]\x97*Y\xb2\x02Xa\xdf@\xecK'
|
||||
b'\xdc\xf0H\xab\xa8\xac\xa7? \x8f=C\x88N\xe2')
|
||||
|
||||
|
||||
def test_ssh_agent():
|
||||
identity = device.interface.Identity(identity_str='localhost:22',
|
||||
curve_name=CURVE)
|
||||
c = client.Client(device=MockDevice())
|
||||
assert c.export_public_keys([identity]) == [PUBKEY_TEXT]
|
||||
signature = c.sign_ssh_challenge(blob=BLOB, identity=identity)
|
||||
|
||||
key = formats.import_public_key(PUBKEY_TEXT)
|
||||
serialized_sig = key['verifier'](sig=signature, msg=BLOB)
|
||||
|
||||
stream = io.BytesIO(serialized_sig)
|
||||
r = util.read_frame(stream)
|
||||
s = util.read_frame(stream)
|
||||
assert not stream.read()
|
||||
assert r[:1] == b'\x00'
|
||||
assert s[:1] == b'\x00'
|
||||
assert r[1:] + s[1:] == SIG
|
||||
|
||||
# pylint: disable=unused-argument
|
||||
def cancel_sign(identity, blob):
|
||||
raise IOError(42, 'ERROR')
|
||||
|
||||
c.device.sign = cancel_sign
|
||||
with pytest.raises(IOError):
|
||||
c.sign_ssh_challenge(blob=BLOB, identity=identity)
|
@ -0,0 +1,109 @@
|
||||
import mock
|
||||
import pytest
|
||||
|
||||
from .. import device, formats, protocol
|
||||
|
||||
# pylint: disable=line-too-long
|
||||
|
||||
NIST256_KEY = 'ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBEUksojS/qRlTKBKLQO7CBX7a7oqFkysuFn1nJ6gzlR3wNuQXEgd7qb2bjmiiBHsjNxyWvH5SxVi3+fghrqODWo= ssh://localhost' # nopep8
|
||||
NIST256_BLOB = b'\x00\x00\x00 !S^\xe7\xf8\x1cKN\xde\xcbo\x0c\x83\x9e\xc48\r\xac\xeb,]"\xc1\x9bA\x0eit\xc1\x81\xd4E2\x00\x00\x00\x05roman\x00\x00\x00\x0essh-connection\x00\x00\x00\tpublickey\x01\x00\x00\x00\x13ecdsa-sha2-nistp256\x00\x00\x00h\x00\x00\x00\x13ecdsa-sha2-nistp256\x00\x00\x00\x08nistp256\x00\x00\x00A\x04E$\xb2\x88\xd2\xfe\xa4eL\xa0J-\x03\xbb\x08\x15\xfbk\xba*\x16L\xac\xb8Y\xf5\x9c\x9e\xa0\xceTw\xc0\xdb\x90\\H\x1d\xee\xa6\xf6n9\xa2\x88\x11\xec\x8c\xdcrZ\xf1\xf9K\x15b\xdf\xe7\xe0\x86\xba\x8e\rj' # nopep8
|
||||
NIST256_SIG = b'\x88G!\x0c\n\x16:\xbeF\xbe\xb9\xd2\xa9&e\x89\xad\xc4}\x10\xf8\xbc\xdc\xef\x0e\x8d_\x8a6.\xb6\x1fq\xf0\x16>,\x9a\xde\xe7(\xd6\xd7\x93\x1f\xed\xf9\x94ddw\xfe\xbdq\x13\xbb\xfc\xa9K\xea\x9dC\xa1\xe9' # nopep8
|
||||
|
||||
LIST_MSG = b'\x0b'
|
||||
LIST_NIST256_REPLY = b'\x00\x00\x00\x84\x0c\x00\x00\x00\x01\x00\x00\x00h\x00\x00\x00\x13ecdsa-sha2-nistp256\x00\x00\x00\x08nistp256\x00\x00\x00A\x04E$\xb2\x88\xd2\xfe\xa4eL\xa0J-\x03\xbb\x08\x15\xfbk\xba*\x16L\xac\xb8Y\xf5\x9c\x9e\xa0\xceTw\xc0\xdb\x90\\H\x1d\xee\xa6\xf6n9\xa2\x88\x11\xec\x8c\xdcrZ\xf1\xf9K\x15b\xdf\xe7\xe0\x86\xba\x8e\rj\x00\x00\x00\x0fssh://localhost' # nopep8
|
||||
|
||||
NIST256_SIGN_MSG = b'\r\x00\x00\x00h\x00\x00\x00\x13ecdsa-sha2-nistp256\x00\x00\x00\x08nistp256\x00\x00\x00A\x04E$\xb2\x88\xd2\xfe\xa4eL\xa0J-\x03\xbb\x08\x15\xfbk\xba*\x16L\xac\xb8Y\xf5\x9c\x9e\xa0\xceTw\xc0\xdb\x90\\H\x1d\xee\xa6\xf6n9\xa2\x88\x11\xec\x8c\xdcrZ\xf1\xf9K\x15b\xdf\xe7\xe0\x86\xba\x8e\rj\x00\x00\x00\xd1\x00\x00\x00 !S^\xe7\xf8\x1cKN\xde\xcbo\x0c\x83\x9e\xc48\r\xac\xeb,]"\xc1\x9bA\x0eit\xc1\x81\xd4E2\x00\x00\x00\x05roman\x00\x00\x00\x0essh-connection\x00\x00\x00\tpublickey\x01\x00\x00\x00\x13ecdsa-sha2-nistp256\x00\x00\x00h\x00\x00\x00\x13ecdsa-sha2-nistp256\x00\x00\x00\x08nistp256\x00\x00\x00A\x04E$\xb2\x88\xd2\xfe\xa4eL\xa0J-\x03\xbb\x08\x15\xfbk\xba*\x16L\xac\xb8Y\xf5\x9c\x9e\xa0\xceTw\xc0\xdb\x90\\H\x1d\xee\xa6\xf6n9\xa2\x88\x11\xec\x8c\xdcrZ\xf1\xf9K\x15b\xdf\xe7\xe0\x86\xba\x8e\rj\x00\x00\x00\x00' # nopep8
|
||||
NIST256_SIGN_REPLY = b'\x00\x00\x00j\x0e\x00\x00\x00e\x00\x00\x00\x13ecdsa-sha2-nistp256\x00\x00\x00J\x00\x00\x00!\x00\x88G!\x0c\n\x16:\xbeF\xbe\xb9\xd2\xa9&e\x89\xad\xc4}\x10\xf8\xbc\xdc\xef\x0e\x8d_\x8a6.\xb6\x1f\x00\x00\x00!\x00q\xf0\x16>,\x9a\xde\xe7(\xd6\xd7\x93\x1f\xed\xf9\x94ddw\xfe\xbdq\x13\xbb\xfc\xa9K\xea\x9dC\xa1\xe9' # nopep8
|
||||
|
||||
|
||||
def fake_connection(keys, signer):
|
||||
c = mock.Mock()
|
||||
c.parse_public_keys.return_value = keys
|
||||
c.sign = signer
|
||||
return c
|
||||
|
||||
|
||||
def test_list():
|
||||
key = formats.import_public_key(NIST256_KEY)
|
||||
key['identity'] = device.interface.Identity('ssh://localhost', 'nist256p1')
|
||||
h = protocol.Handler(fake_connection(keys=[key], signer=None))
|
||||
reply = h.handle(LIST_MSG)
|
||||
assert reply == LIST_NIST256_REPLY
|
||||
|
||||
|
||||
def test_list_legacy_pubs_with_suffix():
|
||||
h = protocol.Handler(fake_connection(keys=[], signer=None))
|
||||
suffix = b'\x00\x00\x00\x06foobar'
|
||||
reply = h.handle(b'\x01' + suffix)
|
||||
assert reply == b'\x00\x00\x00\x05\x02\x00\x00\x00\x00' # no legacy keys
|
||||
|
||||
|
||||
def test_unsupported():
|
||||
h = protocol.Handler(fake_connection(keys=[], signer=None))
|
||||
reply = h.handle(b'\x09')
|
||||
assert reply == b'\x00\x00\x00\x01\x05'
|
||||
|
||||
|
||||
def ecdsa_signer(identity, blob):
|
||||
assert identity.to_string() == '<ssh://localhost|nist256p1>'
|
||||
assert blob == NIST256_BLOB
|
||||
return NIST256_SIG
|
||||
|
||||
|
||||
def test_ecdsa_sign():
|
||||
key = formats.import_public_key(NIST256_KEY)
|
||||
key['identity'] = device.interface.Identity('ssh://localhost', 'nist256p1')
|
||||
h = protocol.Handler(fake_connection(keys=[key], signer=ecdsa_signer))
|
||||
reply = h.handle(NIST256_SIGN_MSG)
|
||||
assert reply == NIST256_SIGN_REPLY
|
||||
|
||||
|
||||
def test_sign_missing():
|
||||
h = protocol.Handler(fake_connection(keys=[], signer=ecdsa_signer))
|
||||
with pytest.raises(KeyError):
|
||||
h.handle(NIST256_SIGN_MSG)
|
||||
|
||||
|
||||
def test_sign_wrong():
|
||||
def wrong_signature(identity, blob):
|
||||
assert identity.to_string() == '<ssh://localhost|nist256p1>'
|
||||
assert blob == NIST256_BLOB
|
||||
return b'\x00' * 64
|
||||
|
||||
key = formats.import_public_key(NIST256_KEY)
|
||||
key['identity'] = device.interface.Identity('ssh://localhost', 'nist256p1')
|
||||
h = protocol.Handler(fake_connection(keys=[key], signer=wrong_signature))
|
||||
with pytest.raises(ValueError):
|
||||
h.handle(NIST256_SIGN_MSG)
|
||||
|
||||
|
||||
def test_sign_cancel():
|
||||
def cancel_signature(identity, blob): # pylint: disable=unused-argument
|
||||
raise IOError()
|
||||
|
||||
key = formats.import_public_key(NIST256_KEY)
|
||||
key['identity'] = device.interface.Identity('ssh://localhost', 'nist256p1')
|
||||
h = protocol.Handler(fake_connection(keys=[key], signer=cancel_signature))
|
||||
assert h.handle(NIST256_SIGN_MSG) == protocol.failure()
|
||||
|
||||
|
||||
ED25519_KEY = 'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFBdF2tjfSO8nLIi736is+f0erq28RTc7CkM11NZtTKR ssh://localhost' # nopep8
|
||||
ED25519_SIGN_MSG = b'''\r\x00\x00\x003\x00\x00\x00\x0bssh-ed25519\x00\x00\x00 P]\x17kc}#\xbc\x9c\xb2"\xef~\xa2\xb3\xe7\xf4z\xba\xb6\xf1\x14\xdc\xec)\x0c\xd7SY\xb52\x91\x00\x00\x00\x94\x00\x00\x00 i3\xae}yk\\\xa1L\xb9\xe1\xbf\xbc\x8e\x87\r\x0e\xc0\x9f\x97\x0fTC!\x80\x07\x91\xdb^8\xc1\xd62\x00\x00\x00\x05roman\x00\x00\x00\x0essh-connection\x00\x00\x00\tpublickey\x01\x00\x00\x00\x0bssh-ed25519\x00\x00\x003\x00\x00\x00\x0bssh-ed25519\x00\x00\x00 P]\x17kc}#\xbc\x9c\xb2"\xef~\xa2\xb3\xe7\xf4z\xba\xb6\xf1\x14\xdc\xec)\x0c\xd7SY\xb52\x91\x00\x00\x00\x00''' # nopep8
|
||||
ED25519_SIGN_REPLY = b'''\x00\x00\x00X\x0e\x00\x00\x00S\x00\x00\x00\x0bssh-ed25519\x00\x00\x00@\x8eb)\xa6\xe9P\x83VE\xfbq\xc6\xbf\x1dV3\xe3<O\x11\xc0\xfa\xe4\xed\xb8\x81.\x81\xc8\xa6\xba\x10RA'a\xbc\xa9\xd3\xdb\x98\x07\xf0\x1a\x9c4\x84<\xaf\x99\xb7\xe5G\xeb\xf7$\xc1\r\x86f\x16\x8e\x08\x05''' # nopep8
|
||||
|
||||
ED25519_BLOB = b'''\x00\x00\x00 i3\xae}yk\\\xa1L\xb9\xe1\xbf\xbc\x8e\x87\r\x0e\xc0\x9f\x97\x0fTC!\x80\x07\x91\xdb^8\xc1\xd62\x00\x00\x00\x05roman\x00\x00\x00\x0essh-connection\x00\x00\x00\tpublickey\x01\x00\x00\x00\x0bssh-ed25519\x00\x00\x003\x00\x00\x00\x0bssh-ed25519\x00\x00\x00 P]\x17kc}#\xbc\x9c\xb2"\xef~\xa2\xb3\xe7\xf4z\xba\xb6\xf1\x14\xdc\xec)\x0c\xd7SY\xb52\x91''' # nopep8
|
||||
ED25519_SIG = b'''\x8eb)\xa6\xe9P\x83VE\xfbq\xc6\xbf\x1dV3\xe3<O\x11\xc0\xfa\xe4\xed\xb8\x81.\x81\xc8\xa6\xba\x10RA'a\xbc\xa9\xd3\xdb\x98\x07\xf0\x1a\x9c4\x84<\xaf\x99\xb7\xe5G\xeb\xf7$\xc1\r\x86f\x16\x8e\x08\x05''' # nopep8
|
||||
|
||||
|
||||
def ed25519_signer(identity, blob):
|
||||
assert identity.to_string() == '<ssh://localhost|ed25519>'
|
||||
assert blob == ED25519_BLOB
|
||||
return ED25519_SIG
|
||||
|
||||
|
||||
def test_ed25519_sign():
|
||||
key = formats.import_public_key(ED25519_KEY)
|
||||
key['identity'] = device.interface.Identity('ssh://localhost', 'ed25519')
|
||||
h = protocol.Handler(fake_connection(keys=[key], signer=ed25519_signer))
|
||||
reply = h.handle(ED25519_SIGN_MSG)
|
||||
assert reply == ED25519_SIGN_REPLY
|
@ -0,0 +1 @@
|
||||
"""Unit-tests for this package."""
|
@ -0,0 +1,103 @@
|
||||
import binascii
|
||||
|
||||
import pytest
|
||||
|
||||
from .. import formats
|
||||
|
||||
|
||||
def test_fingerprint():
|
||||
fp = '5d:41:40:2a:bc:4b:2a:76:b9:71:9d:91:10:17:c5:92'
|
||||
assert formats.fingerprint(b'hello') == fp
|
||||
|
||||
|
||||
_point = (
|
||||
44423495295951059636974944244307637263954375053872017334547086177777411863925, # nopep8
|
||||
111713194882028655451852320740440245619792555065469028846314891587105736340201 # nopep8
|
||||
)
|
||||
|
||||
_public_key = (
|
||||
'ecdsa-sha2-nistp256 '
|
||||
'AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTY'
|
||||
'AAABBBGI2zqveJSB+geQEWG46OvGs2h3+0qu7tIdsH8Wylr'
|
||||
'V19vttd7GR5rKvTWJt8b9ErthmnFALelAFKOB/u50jsuk= '
|
||||
'home\n'
|
||||
)
|
||||
|
||||
|
||||
def test_parse_public_key():
|
||||
key = formats.import_public_key(_public_key)
|
||||
assert key['name'] == b'home'
|
||||
assert key['point'] == _point
|
||||
|
||||
assert key['curve'] == 'nist256p1'
|
||||
assert key['fingerprint'] == '4b:19:bc:0f:c8:7e:dc:fa:1a:e3:c2:ff:6f:e0:80:a2' # nopep8
|
||||
assert key['type'] == b'ecdsa-sha2-nistp256'
|
||||
|
||||
|
||||
def test_decompress():
|
||||
blob = '036236ceabde25207e81e404586e3a3af1acda1dfed2abbbb4876c1fc5b296b575'
|
||||
vk = formats.decompress_pubkey(binascii.unhexlify(blob),
|
||||
curve_name=formats.CURVE_NIST256)
|
||||
assert formats.export_public_key(vk, label='home') == _public_key
|
||||
|
||||
|
||||
def test_parse_ed25519():
|
||||
pubkey = ('ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFBdF2tj'
|
||||
'fSO8nLIi736is+f0erq28RTc7CkM11NZtTKR hello\n')
|
||||
p = formats.import_public_key(pubkey)
|
||||
assert p['name'] == b'hello'
|
||||
assert p['curve'] == 'ed25519'
|
||||
|
||||
BLOB = (b'\x00\x00\x00\x0bssh-ed25519\x00\x00\x00 P]\x17kc}#'
|
||||
b'\xbc\x9c\xb2"\xef~\xa2\xb3\xe7\xf4z\xba\xb6\xf1\x14'
|
||||
b'\xdc\xec)\x0c\xd7SY\xb52\x91')
|
||||
assert p['blob'] == BLOB
|
||||
assert p['fingerprint'] == '6b:b0:77:af:e5:3a:21:6d:17:82:9b:06:19:03:a1:97' # nopep8
|
||||
assert p['type'] == b'ssh-ed25519'
|
||||
|
||||
|
||||
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')
|
||||
vk = formats.decompress_pubkey(pub, formats.CURVE_ED25519)
|
||||
result = formats.serialize_verifying_key(vk)
|
||||
assert result == (b'ssh-ed25519',
|
||||
b'\x00\x00\x00\x0bssh-ed25519\x00\x00\x00 P]\x17kc}#\xbc'
|
||||
b'\x9c\xb2"\xef~\xa2\xb3\xe7\xf4z\xba\xb6\xf1\x14\xdc'
|
||||
b'\xec)\x0c\xd7SY\xb52\x91')
|
||||
|
||||
|
||||
def test_decompress_error():
|
||||
with pytest.raises(ValueError):
|
||||
formats.decompress_pubkey('', formats.CURVE_NIST256)
|
||||
|
||||
|
||||
def test_curve_mismatch():
|
||||
# NIST256 public key
|
||||
blob = '036236ceabde25207e81e404586e3a3af1acda1dfed2abbbb4876c1fc5b296b575'
|
||||
with pytest.raises(ValueError):
|
||||
formats.decompress_pubkey(binascii.unhexlify(blob),
|
||||
curve_name=formats.CURVE_ED25519)
|
||||
|
||||
blob = '00' * 33 # Dummy public key
|
||||
with pytest.raises(ValueError):
|
||||
formats.decompress_pubkey(binascii.unhexlify(blob),
|
||||
curve_name=formats.CURVE_NIST256)
|
||||
|
||||
blob = 'FF' * 33 # Unsupported prefix byte
|
||||
with pytest.raises(ValueError):
|
||||
formats.decompress_pubkey(binascii.unhexlify(blob),
|
||||
curve_name=formats.CURVE_NIST256)
|
||||
|
||||
|
||||
def test_serialize_error():
|
||||
with pytest.raises(TypeError):
|
||||
formats.serialize_verifying_key(None)
|
||||
|
||||
|
||||
def test_get_ecdh_curve_name():
|
||||
for c in [formats.CURVE_NIST256, formats.ECDH_CURVE25519]:
|
||||
assert c == formats.get_ecdh_curve_name(c)
|
||||
|
||||
assert (formats.ECDH_CURVE25519 ==
|
||||
formats.get_ecdh_curve_name(formats.CURVE_ED25519))
|
@ -0,0 +1,7 @@
|
||||
from ..device import interface
|
||||
|
||||
|
||||
def test_unicode():
|
||||
i = interface.Identity(u'ko\u017eu\u0161\u010dek@host', 'ed25519')
|
||||
assert i.to_bytes() == b'kozuscek@host'
|
||||
assert sorted(i.items()) == [('host', 'host'), ('user', 'kozuscek')]
|
@ -0,0 +1,135 @@
|
||||
import io
|
||||
import os
|
||||
import socket
|
||||
import tempfile
|
||||
import threading
|
||||
|
||||
import mock
|
||||
import pytest
|
||||
|
||||
from .. import server, util
|
||||
from ..ssh import protocol
|
||||
|
||||
|
||||
def test_socket():
|
||||
path = tempfile.mktemp()
|
||||
with server.unix_domain_socket_server(path):
|
||||
pass
|
||||
assert not os.path.isfile(path)
|
||||
|
||||
|
||||
class FakeSocket:
|
||||
|
||||
def __init__(self, data=b''):
|
||||
self.rx = io.BytesIO(data)
|
||||
self.tx = io.BytesIO()
|
||||
|
||||
def sendall(self, data):
|
||||
self.tx.write(data)
|
||||
|
||||
def recv(self, size):
|
||||
return self.rx.read(size)
|
||||
|
||||
def close(self):
|
||||
pass
|
||||
|
||||
def settimeout(self, value):
|
||||
pass
|
||||
|
||||
|
||||
def empty_device():
|
||||
c = mock.Mock(spec=['parse_public_keys'])
|
||||
c.parse_public_keys.return_value = []
|
||||
return c
|
||||
|
||||
|
||||
def test_handle():
|
||||
mutex = threading.Lock()
|
||||
|
||||
handler = protocol.Handler(conn=empty_device())
|
||||
conn = FakeSocket()
|
||||
server.handle_connection(conn, handler, mutex)
|
||||
|
||||
msg = bytearray([protocol.msg_code('SSH_AGENTC_REQUEST_RSA_IDENTITIES')])
|
||||
conn = FakeSocket(util.frame(msg))
|
||||
server.handle_connection(conn, handler, mutex)
|
||||
assert conn.tx.getvalue() == b'\x00\x00\x00\x05\x02\x00\x00\x00\x00'
|
||||
|
||||
msg = bytearray([protocol.msg_code('SSH2_AGENTC_REQUEST_IDENTITIES')])
|
||||
conn = FakeSocket(util.frame(msg))
|
||||
server.handle_connection(conn, handler, mutex)
|
||||
assert conn.tx.getvalue() == b'\x00\x00\x00\x05\x0C\x00\x00\x00\x00'
|
||||
|
||||
msg = bytearray([protocol.msg_code('SSH2_AGENTC_ADD_IDENTITY')])
|
||||
conn = FakeSocket(util.frame(msg))
|
||||
server.handle_connection(conn, handler, mutex)
|
||||
conn.tx.seek(0)
|
||||
reply = util.read_frame(conn.tx)
|
||||
assert reply == util.pack('B', protocol.msg_code('SSH_AGENT_FAILURE'))
|
||||
|
||||
conn_mock = mock.Mock(spec=FakeSocket)
|
||||
conn_mock.recv.side_effect = [Exception, EOFError]
|
||||
server.handle_connection(conn=conn_mock, handler=None, mutex=mutex)
|
||||
|
||||
|
||||
def test_server_thread():
|
||||
sock = FakeSocket()
|
||||
connections = [sock]
|
||||
quit_event = threading.Event()
|
||||
|
||||
class FakeServer:
|
||||
def accept(self): # pylint: disable=no-self-use
|
||||
if not connections:
|
||||
raise socket.timeout()
|
||||
return connections.pop(), 'address'
|
||||
|
||||
def getsockname(self): # pylint: disable=no-self-use
|
||||
return 'fake_server'
|
||||
|
||||
def handle_conn(conn):
|
||||
assert conn is sock
|
||||
quit_event.set()
|
||||
|
||||
server.server_thread(sock=FakeServer(),
|
||||
handle_conn=handle_conn,
|
||||
quit_event=quit_event)
|
||||
quit_event.wait()
|
||||
|
||||
|
||||
def test_spawn():
|
||||
obj = []
|
||||
|
||||
def thread(x):
|
||||
obj.append(x)
|
||||
|
||||
with server.spawn(thread, dict(x=1)):
|
||||
pass
|
||||
|
||||
assert obj == [1]
|
||||
|
||||
|
||||
def test_run():
|
||||
assert server.run_process(['true'], environ={}) == 0
|
||||
assert server.run_process(['false'], environ={}) == 1
|
||||
assert server.run_process(command=['bash', '-c', 'exit $X'],
|
||||
environ={'X': '42'}) == 42
|
||||
|
||||
with pytest.raises(OSError):
|
||||
server.run_process([''], environ={})
|
||||
|
||||
|
||||
def test_remove():
|
||||
path = 'foo.bar'
|
||||
|
||||
def remove(p):
|
||||
assert p == path
|
||||
|
||||
server.remove_file(path, remove=remove)
|
||||
|
||||
def remove_raise(_):
|
||||
raise OSError('boom')
|
||||
|
||||
server.remove_file(path, remove=remove_raise, exists=lambda _: False)
|
||||
|
||||
with pytest.raises(OSError):
|
||||
server.remove_file(path, remove=remove_raise, exists=lambda _: True)
|
@ -0,0 +1,146 @@
|
||||
import io
|
||||
|
||||
import mock
|
||||
import pytest
|
||||
|
||||
from .. import util
|
||||
|
||||
|
||||
def test_bytes2num():
|
||||
assert util.bytes2num(b'\x12\x34') == 0x1234
|
||||
|
||||
|
||||
def test_num2bytes():
|
||||
assert util.num2bytes(0x1234, size=2) == b'\x12\x34'
|
||||
|
||||
|
||||
def test_pack():
|
||||
assert util.pack('BHL', 1, 2, 3) == b'\x01\x00\x02\x00\x00\x00\x03'
|
||||
|
||||
|
||||
def test_frames():
|
||||
msgs = [b'aaa', b'bb', b'c' * 0x12340]
|
||||
f = util.frame(*msgs)
|
||||
assert f == b'\x00\x01\x23\x45' + b''.join(msgs)
|
||||
assert util.read_frame(io.BytesIO(f)) == b''.join(msgs)
|
||||
|
||||
|
||||
class FakeSocket:
|
||||
def __init__(self):
|
||||
self.buf = io.BytesIO()
|
||||
|
||||
def sendall(self, data):
|
||||
self.buf.write(data)
|
||||
|
||||
def recv(self, size):
|
||||
return self.buf.read(size)
|
||||
|
||||
|
||||
def test_send_recv():
|
||||
s = FakeSocket()
|
||||
util.send(s, b'123')
|
||||
util.send(s, b'*')
|
||||
assert s.buf.getvalue() == b'123*'
|
||||
|
||||
s.buf.seek(0)
|
||||
assert util.recv(s, 2) == b'12'
|
||||
assert util.recv(s, 2) == b'3*'
|
||||
|
||||
pytest.raises(EOFError, util.recv, s, 1)
|
||||
|
||||
|
||||
def test_crc24():
|
||||
assert util.crc24(b'') == b'\xb7\x04\xce'
|
||||
assert util.crc24(b'1234567890') == b'\x8c\x00\x72'
|
||||
|
||||
|
||||
def test_bit():
|
||||
assert util.bit(6, 3) == 0
|
||||
assert util.bit(6, 2) == 1
|
||||
assert util.bit(6, 1) == 1
|
||||
assert util.bit(6, 0) == 0
|
||||
|
||||
|
||||
def test_split_bits():
|
||||
assert util.split_bits(0x1234, 4, 8, 4) == [0x1, 0x23, 0x4]
|
||||
|
||||
|
||||
def test_hexlify():
|
||||
assert util.hexlify(b'\x12\x34\xab\xcd') == '1234ABCD'
|
||||
|
||||
|
||||
def test_low_bits():
|
||||
assert util.low_bits(0x1234, 12) == 0x234
|
||||
assert util.low_bits(0x1234, 32) == 0x1234
|
||||
assert util.low_bits(0x1234, 0) == 0
|
||||
|
||||
|
||||
def test_readfmt():
|
||||
stream = io.BytesIO(b'ABC\x12\x34')
|
||||
assert util.readfmt(stream, 'B') == (65,)
|
||||
assert util.readfmt(stream, '>2sH') == (b'BC', 0x1234)
|
||||
|
||||
|
||||
def test_prefix_len():
|
||||
assert util.prefix_len('>H', b'ABCD') == b'\x00\x04ABCD'
|
||||
|
||||
|
||||
def test_reader():
|
||||
stream = io.BytesIO(b'ABC\x12\x34')
|
||||
r = util.Reader(stream)
|
||||
assert r.read(1) == b'A'
|
||||
assert r.readfmt('2s') == b'BC'
|
||||
|
||||
dst = io.BytesIO()
|
||||
with r.capture(dst):
|
||||
assert r.readfmt('>H') == 0x1234
|
||||
assert dst.getvalue() == b'\x12\x34'
|
||||
|
||||
with pytest.raises(EOFError):
|
||||
r.read(1)
|
||||
|
||||
|
||||
def test_setup_logging():
|
||||
util.setup_logging(verbosity=10, filename='/dev/null')
|
||||
|
||||
|
||||
def test_memoize():
|
||||
f = mock.Mock(side_effect=lambda x: x)
|
||||
|
||||
def func(x):
|
||||
# mock.Mock doesn't work with functools.wraps()
|
||||
return f(x)
|
||||
|
||||
g = util.memoize(func)
|
||||
assert g(1) == g(1)
|
||||
assert g(1) != g(2)
|
||||
assert f.mock_calls == [mock.call(1), mock.call(2)]
|
||||
|
||||
|
||||
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
|
@ -0,0 +1,280 @@
|
||||
"""Various I/O and serialization utilities."""
|
||||
import binascii
|
||||
import contextlib
|
||||
import functools
|
||||
import io
|
||||
import logging
|
||||
import struct
|
||||
import time
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def send(conn, data):
|
||||
"""Send data blob to connection socket."""
|
||||
conn.sendall(data)
|
||||
|
||||
|
||||
def recv(conn, size):
|
||||
"""
|
||||
Receive bytes from connection socket or stream.
|
||||
|
||||
If size is struct.calcsize()-compatible format, use it to unpack the data.
|
||||
Otherwise, return the plain blob as bytes.
|
||||
"""
|
||||
try:
|
||||
fmt = size
|
||||
size = struct.calcsize(fmt)
|
||||
except TypeError:
|
||||
fmt = None
|
||||
try:
|
||||
_read = conn.recv
|
||||
except AttributeError:
|
||||
_read = conn.read
|
||||
|
||||
res = io.BytesIO()
|
||||
while size > 0:
|
||||
buf = _read(size)
|
||||
if not buf:
|
||||
raise EOFError
|
||||
size = size - len(buf)
|
||||
res.write(buf)
|
||||
res = res.getvalue()
|
||||
if fmt:
|
||||
return struct.unpack(fmt, res)
|
||||
else:
|
||||
return res
|
||||
|
||||
|
||||
def read_frame(conn):
|
||||
"""Read size-prefixed frame from connection."""
|
||||
size, = recv(conn, '>L')
|
||||
return recv(conn, size)
|
||||
|
||||
|
||||
def bytes2num(s):
|
||||
"""Convert MSB-first bytes to an unsigned integer."""
|
||||
res = 0
|
||||
for i, c in enumerate(reversed(bytearray(s))):
|
||||
res += c << (i * 8)
|
||||
return res
|
||||
|
||||
|
||||
def num2bytes(value, size):
|
||||
"""Convert an unsigned integer to MSB-first bytes with specified size."""
|
||||
res = []
|
||||
for _ in range(size):
|
||||
res.append(value & 0xFF)
|
||||
value = value >> 8
|
||||
assert value == 0
|
||||
return bytes(bytearray(list(reversed(res))))
|
||||
|
||||
|
||||
def pack(fmt, *args):
|
||||
"""Serialize MSB-first message."""
|
||||
return struct.pack('>' + fmt, *args)
|
||||
|
||||
|
||||
def frame(*msgs):
|
||||
"""Serialize MSB-first length-prefixed frame."""
|
||||
res = io.BytesIO()
|
||||
for msg in msgs:
|
||||
res.write(msg)
|
||||
msg = res.getvalue()
|
||||
return pack('L', len(msg)) + msg
|
||||
|
||||
|
||||
def crc24(blob):
|
||||
"""See https://tools.ietf.org/html/rfc4880#section-6.1 for details."""
|
||||
CRC24_INIT = 0x0B704CE
|
||||
CRC24_POLY = 0x1864CFB
|
||||
|
||||
crc = CRC24_INIT
|
||||
for octet in bytearray(blob):
|
||||
crc ^= (octet << 16)
|
||||
for _ in range(8):
|
||||
crc <<= 1
|
||||
if crc & 0x1000000:
|
||||
crc ^= CRC24_POLY
|
||||
assert 0 <= crc < 0x1000000
|
||||
crc_bytes = struct.pack('>L', crc)
|
||||
assert crc_bytes[:1] == b'\x00'
|
||||
return crc_bytes[1:]
|
||||
|
||||
|
||||
def bit(value, i):
|
||||
"""Extract the i-th bit out of value."""
|
||||
return 1 if value & (1 << i) else 0
|
||||
|
||||
|
||||
def low_bits(value, n):
|
||||
"""Extract the lowest n bits out of value."""
|
||||
return value & ((1 << n) - 1)
|
||||
|
||||
|
||||
def split_bits(value, *bits):
|
||||
"""
|
||||
Split integer value into list of ints, according to `bits` list.
|
||||
|
||||
For example, split_bits(0x1234, 4, 8, 4) == [0x1, 0x23, 0x4]
|
||||
"""
|
||||
result = []
|
||||
for b in reversed(bits):
|
||||
mask = (1 << b) - 1
|
||||
result.append(value & mask)
|
||||
value = value >> b
|
||||
assert value == 0
|
||||
|
||||
result.reverse()
|
||||
return result
|
||||
|
||||
|
||||
def readfmt(stream, fmt):
|
||||
"""Read and unpack an object from stream, using a struct format string."""
|
||||
size = struct.calcsize(fmt)
|
||||
blob = stream.read(size)
|
||||
return struct.unpack(fmt, blob)
|
||||
|
||||
|
||||
def prefix_len(fmt, blob):
|
||||
"""Prefix `blob` with its size, serialized using `fmt` format."""
|
||||
return struct.pack(fmt, len(blob)) + blob
|
||||
|
||||
|
||||
def hexlify(blob):
|
||||
"""Utility for consistent hexadecimal formatting."""
|
||||
return binascii.hexlify(blob).decode('ascii').upper()
|
||||
|
||||
|
||||
class Reader:
|
||||
"""Read basic type objects out of given stream."""
|
||||
|
||||
def __init__(self, stream):
|
||||
"""Create a non-capturing reader."""
|
||||
self.s = stream
|
||||
self._captured = None
|
||||
|
||||
def readfmt(self, fmt):
|
||||
"""Read a specified object, using a struct format string."""
|
||||
size = struct.calcsize(fmt)
|
||||
blob = self.read(size)
|
||||
obj, = struct.unpack(fmt, blob)
|
||||
return obj
|
||||
|
||||
def read(self, size=None):
|
||||
"""Read `size` bytes from stream."""
|
||||
blob = self.s.read(size)
|
||||
if size is not None and len(blob) < size:
|
||||
raise EOFError
|
||||
if self._captured:
|
||||
self._captured.write(blob)
|
||||
return blob
|
||||
|
||||
@contextlib.contextmanager
|
||||
def capture(self, stream):
|
||||
"""Capture all data read during this context."""
|
||||
self._captured = stream
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
self._captured = None
|
||||
|
||||
|
||||
def setup_logging(verbosity, filename=None):
|
||||
"""Configure logging for this tool."""
|
||||
levels = [logging.WARNING, logging.INFO, logging.DEBUG]
|
||||
level = levels[min(verbosity, len(levels) - 1)]
|
||||
logging.root.setLevel(level)
|
||||
|
||||
fmt = logging.Formatter('%(asctime)s %(levelname)-12s %(message)-100s '
|
||||
'[%(filename)s:%(lineno)d]')
|
||||
hdlr = logging.StreamHandler() # stderr
|
||||
hdlr.setFormatter(fmt)
|
||||
logging.root.addHandler(hdlr)
|
||||
|
||||
if filename:
|
||||
hdlr = logging.FileHandler(filename, 'a')
|
||||
hdlr.setFormatter(fmt)
|
||||
logging.root.addHandler(hdlr)
|
||||
|
||||
|
||||
def memoize(func):
|
||||
"""Simple caching decorator."""
|
||||
cache = {}
|
||||
|
||||
@functools.wraps(func)
|
||||
def wrapper(*args, **kwargs):
|
||||
"""Caching wrapper."""
|
||||
key = (args, tuple(sorted(kwargs.items())))
|
||||
if key in cache:
|
||||
return cache[key]
|
||||
else:
|
||||
result = func(*args, **kwargs)
|
||||
cache[key] = result
|
||||
return result
|
||||
|
||||
return wrapper
|
||||
|
||||
|
||||
def memoize_method(method):
|
||||
"""Simple caching decorator."""
|
||||
cache = {}
|
||||
|
||||
@functools.wraps(method)
|
||||
def wrapper(self, *args, **kwargs):
|
||||
"""Caching wrapper."""
|
||||
key = (args, tuple(sorted(kwargs.items())))
|
||||
if key in cache:
|
||||
return cache[key]
|
||||
else:
|
||||
result = method(self, *args, **kwargs)
|
||||
cache[key] = result
|
||||
return result
|
||||
|
||||
return wrapper
|
||||
|
||||
|
||||
@memoize
|
||||
def which(cmd):
|
||||
"""Return full path to specified command, or raise OSError if missing."""
|
||||
try:
|
||||
# For Python 3
|
||||
from shutil import which as _which
|
||||
except ImportError:
|
||||
# For Python 2
|
||||
from backports.shutil_which import which as _which # pylint: disable=relative-import
|
||||
full_path = _which(cmd)
|
||||
if full_path is None:
|
||||
raise OSError('Cannot find {!r} in $PATH'.format(cmd))
|
||||
log.debug('which %r => %r', cmd, full_path)
|
||||
return full_path
|
||||
|
||||
|
||||
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
|
@ -0,0 +1,6 @@
|
||||
#!/bin/bash
|
||||
set -eux
|
||||
rm -rv dist/*
|
||||
python3 setup.py sdist
|
||||
gpg2 -v --detach-sign -a dist/*.tar.gz
|
||||
twine upload dist/*
|
@ -1,17 +1,24 @@
|
||||
[tox]
|
||||
envlist = py27,py34
|
||||
skipsdist = True
|
||||
envlist = py3
|
||||
[pycodestyle]
|
||||
max-line-length = 100
|
||||
[pep257]
|
||||
add-ignore = D401
|
||||
[testenv]
|
||||
deps=
|
||||
pytest
|
||||
mock
|
||||
pep8
|
||||
pycodestyle
|
||||
coverage
|
||||
pylint
|
||||
six
|
||||
ecdsa
|
||||
semver
|
||||
pydocstyle
|
||||
isort
|
||||
commands=
|
||||
pep8 trezor_agent
|
||||
pylint --report=no --rcfile .pylintrc trezor_agent
|
||||
coverage run --omit='trezor_agent/__main__.py,trezor_agent/trezor/_library.py' --source trezor_agent/ -m py.test -v
|
||||
pycodestyle libagent
|
||||
# isort --skip-glob .tox -c -r libagent
|
||||
pylint --reports=no --rcfile .pylintrc libagent
|
||||
pydocstyle libagent
|
||||
coverage run --source libagent -m py.test -v libagent
|
||||
coverage report
|
||||
coverage html
|
||||
|
@ -1,116 +0,0 @@
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import argparse
|
||||
import subprocess
|
||||
|
||||
from . import trezor
|
||||
from . import server
|
||||
|
||||
import logging
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def identity_from_gitconfig():
|
||||
out = subprocess.check_output(args='git config --list --local'.split())
|
||||
config = [line.split('=', 1) for line in out.strip().split('\n')]
|
||||
config_dict = dict(item for item in config if len(item) == 2)
|
||||
|
||||
name_regex = re.compile(r'^remote\..*\.trezor$')
|
||||
names = [item[0] for item in config if name_regex.match(item[0])]
|
||||
if len(names) != 1:
|
||||
log.error('please add "trezor" key '
|
||||
'to a single remote section at .git/config')
|
||||
sys.exit(1)
|
||||
key_name = names[0]
|
||||
identity_label = config_dict.get(key_name)
|
||||
if identity_label:
|
||||
return identity_label
|
||||
|
||||
# extract remote name marked as TREZOR's
|
||||
section_name, _ = key_name.rsplit('.', 1)
|
||||
|
||||
key_name = section_name + '.url'
|
||||
url = config_dict[key_name]
|
||||
log.debug('using "%s=%s" from git-config', key_name, url)
|
||||
|
||||
user, url = url.split('@', 1)
|
||||
host, path = url.split(':', 1)
|
||||
return 'ssh://{0}@{1}/{2}'.format(user, host, path)
|
||||
|
||||
|
||||
def create_agent_parser():
|
||||
p = argparse.ArgumentParser()
|
||||
p.add_argument('-v', '--verbose', default=0, action='count')
|
||||
|
||||
p.add_argument('identity', type=str, default=None,
|
||||
help='proto://[user@]host[:port][/path]')
|
||||
|
||||
g = p.add_mutually_exclusive_group()
|
||||
g.add_argument('-s', '--shell', default=False, action='store_true',
|
||||
help='run $SHELL as subprocess under SSH agent')
|
||||
g.add_argument('-c', '--connect', default=False, action='store_true',
|
||||
help='connect to specified host via SSH')
|
||||
p.add_argument('command', type=str, nargs='*', metavar='ARGUMENT',
|
||||
help='command to run under the SSH agent')
|
||||
return p
|
||||
|
||||
|
||||
def setup_logging(verbosity):
|
||||
fmt = ('%(asctime)s %(levelname)-12s %(message)-100s '
|
||||
'[%(filename)s:%(lineno)d]')
|
||||
levels = [logging.WARNING, logging.INFO, logging.DEBUG]
|
||||
level = levels[min(verbosity, len(levels) - 1)]
|
||||
logging.basicConfig(format=fmt, level=level)
|
||||
|
||||
|
||||
def ssh_command(identity):
|
||||
command = ['ssh', identity.host]
|
||||
if identity.user:
|
||||
command += ['-l', identity.user]
|
||||
if identity.port:
|
||||
command += ['-p', identity.port]
|
||||
return command
|
||||
|
||||
|
||||
def trezor_agent():
|
||||
args = create_agent_parser().parse_args()
|
||||
setup_logging(verbosity=args.verbose)
|
||||
|
||||
with trezor.Client() as client:
|
||||
|
||||
label = args.identity
|
||||
command = args.command
|
||||
|
||||
if label == 'git':
|
||||
label = identity_from_gitconfig()
|
||||
log.info('using identity %r for git command %r', label, command)
|
||||
if command:
|
||||
command = ['git'] + command
|
||||
|
||||
identity = client.get_identity(label=label)
|
||||
public_key = client.get_public_key(identity=identity)
|
||||
|
||||
use_shell = False
|
||||
if args.connect:
|
||||
command = ssh_command(identity) + args.command
|
||||
log.debug('SSH connect: %r', command)
|
||||
|
||||
if args.shell:
|
||||
command, use_shell = os.environ['SHELL'], True
|
||||
log.debug('using shell: %r', command)
|
||||
|
||||
if not command:
|
||||
sys.stdout.write(public_key)
|
||||
return
|
||||
|
||||
def signer(label, blob):
|
||||
identity = client.get_identity(label=label)
|
||||
return client.sign_ssh_challenge(identity=identity, blob=blob)
|
||||
|
||||
try:
|
||||
with server.serve(public_keys=[public_key], signer=signer) as env:
|
||||
return server.run_process(command=command, environ=env,
|
||||
use_shell=use_shell)
|
||||
except KeyboardInterrupt:
|
||||
log.info('server stopped')
|
@ -1,92 +0,0 @@
|
||||
import io
|
||||
import hashlib
|
||||
import base64
|
||||
import ecdsa
|
||||
|
||||
from . import util
|
||||
|
||||
import logging
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
DER_OCTET_STRING = b'\x04'
|
||||
ECDSA_KEY_PREFIX = b'ecdsa-sha2-'
|
||||
ECDSA_CURVE_NAME = b'nistp256'
|
||||
|
||||
hashfunc = hashlib.sha256
|
||||
|
||||
|
||||
def fingerprint(blob):
|
||||
digest = hashlib.md5(blob).digest()
|
||||
return ':'.join('{:02x}'.format(c) for c in bytearray(digest))
|
||||
|
||||
|
||||
def parse_pubkey(blob, curve=ecdsa.NIST256p):
|
||||
s = io.BytesIO(blob)
|
||||
key_type = util.read_frame(s)
|
||||
log.debug('key type: %s', key_type)
|
||||
curve_name = util.read_frame(s)
|
||||
log.debug('curve name: %s', curve_name)
|
||||
point = util.read_frame(s)
|
||||
assert s.read() == b''
|
||||
_type, point = point[:1], point[1:]
|
||||
assert _type == DER_OCTET_STRING
|
||||
size = len(point) // 2
|
||||
assert len(point) == 2 * size
|
||||
coords = (util.bytes2num(point[:size]), util.bytes2num(point[size:]))
|
||||
log.debug('coordinates: %s', coords)
|
||||
fp = fingerprint(blob)
|
||||
|
||||
point = ecdsa.ellipticcurve.Point(curve.curve, *coords)
|
||||
vk = ecdsa.VerifyingKey.from_public_point(point, curve, hashfunc)
|
||||
result = {
|
||||
'point': coords,
|
||||
'curve': curve_name,
|
||||
'fingerprint': fp,
|
||||
'type': key_type,
|
||||
'blob': blob,
|
||||
'size': size,
|
||||
'verifying_key': vk
|
||||
}
|
||||
return result
|
||||
|
||||
|
||||
def decompress_pubkey(pub, curve=ecdsa.NIST256p):
|
||||
P = curve.curve.p()
|
||||
A = curve.curve.a()
|
||||
B = curve.curve.b()
|
||||
x = util.bytes2num(pub[1:33])
|
||||
beta = pow(int(x*x*x+A*x+B), int((P+1)//4), int(P))
|
||||
|
||||
p0 = util.bytes2num(pub[:1])
|
||||
y = (P-beta) if ((beta + p0) % 2) else beta
|
||||
|
||||
point = ecdsa.ellipticcurve.Point(curve.curve, x, y)
|
||||
return ecdsa.VerifyingKey.from_public_point(point, curve=curve,
|
||||
hashfunc=hashfunc)
|
||||
|
||||
|
||||
def serialize_verifying_key(vk):
|
||||
key_type = ECDSA_KEY_PREFIX + ECDSA_CURVE_NAME
|
||||
curve_name = ECDSA_CURVE_NAME
|
||||
key_blob = DER_OCTET_STRING + vk.to_string()
|
||||
parts = [key_type, curve_name, key_blob]
|
||||
return b''.join([util.frame(p) for p in parts])
|
||||
|
||||
|
||||
def export_public_key(pubkey, label):
|
||||
blob = serialize_verifying_key(decompress_pubkey(pubkey))
|
||||
log.debug('fingerprint: %s', fingerprint(blob))
|
||||
b64 = base64.b64encode(blob).decode('ascii')
|
||||
key_type = ECDSA_KEY_PREFIX + ECDSA_CURVE_NAME
|
||||
return '{} {} {}\n'.format(key_type.decode('ascii'), b64, label)
|
||||
|
||||
|
||||
def import_public_key(line):
|
||||
''' Parse public key textual format, as saved at .pub file '''
|
||||
file_type, base64blob, name = line.split()
|
||||
blob = base64.b64decode(base64blob)
|
||||
result = parse_pubkey(blob)
|
||||
result['name'] = name.encode('ascii')
|
||||
assert result['type'] == file_type.encode('ascii')
|
||||
log.debug('loaded %s %s', file_type, result['fingerprint'])
|
||||
return result
|
@ -1,115 +0,0 @@
|
||||
import io
|
||||
|
||||
from . import util
|
||||
from . import formats
|
||||
|
||||
import logging
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
SSH_AGENTC_REQUEST_RSA_IDENTITIES = 1
|
||||
SSH_AGENT_RSA_IDENTITIES_ANSWER = 2
|
||||
|
||||
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
|
||||
|
||||
|
||||
class Error(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class BadSignature(Error):
|
||||
pass
|
||||
|
||||
|
||||
class MissingKey(Error):
|
||||
pass
|
||||
|
||||
|
||||
class Handler(object):
|
||||
|
||||
def __init__(self, keys, signer):
|
||||
self.public_keys = keys
|
||||
self.signer = signer
|
||||
|
||||
self.methods = {
|
||||
SSH_AGENTC_REQUEST_RSA_IDENTITIES: Handler.legacy_pubs,
|
||||
SSH2_AGENTC_REQUEST_IDENTITIES: self.list_pubs,
|
||||
SSH2_AGENTC_SIGN_REQUEST: self.sign_message,
|
||||
}
|
||||
|
||||
def handle(self, msg):
|
||||
log.debug('request: %d bytes', len(msg))
|
||||
buf = io.BytesIO(msg)
|
||||
code, = util.recv(buf, '>B')
|
||||
method = self.methods[code]
|
||||
log.debug('calling %s()', method.__name__)
|
||||
reply = method(buf=buf)
|
||||
log.debug('reply: %d bytes', len(reply))
|
||||
return reply
|
||||
|
||||
@staticmethod
|
||||
def legacy_pubs(buf):
|
||||
''' SSH v1 public keys are not supported '''
|
||||
assert not buf.read()
|
||||
code = util.pack('B', SSH_AGENT_RSA_IDENTITIES_ANSWER)
|
||||
num = util.pack('L', 0) # no SSH v1 keys
|
||||
return util.frame(code, num)
|
||||
|
||||
def list_pubs(self, buf):
|
||||
''' SSH v2 public keys are serialized and returned. '''
|
||||
assert not buf.read()
|
||||
keys = self.public_keys
|
||||
code = util.pack('B', SSH2_AGENT_IDENTITIES_ANSWER)
|
||||
num = util.pack('L', len(keys))
|
||||
log.debug('available keys: %s', [k['name'] for k in keys])
|
||||
for i, k in enumerate(keys):
|
||||
log.debug('%2d) %s', i+1, k['fingerprint'])
|
||||
pubs = [util.frame(k['blob']) + util.frame(k['name']) for k in keys]
|
||||
return util.frame(code, num, *pubs)
|
||||
|
||||
def sign_message(self, buf):
|
||||
''' SSH v2 public key authentication is performed. '''
|
||||
key = formats.parse_pubkey(util.read_frame(buf))
|
||||
log.debug('looking for %s', key['fingerprint'])
|
||||
blob = util.read_frame(buf)
|
||||
assert util.read_frame(buf) == b''
|
||||
assert not buf.read()
|
||||
|
||||
for k in self.public_keys:
|
||||
if (k['fingerprint']) == (key['fingerprint']):
|
||||
log.debug('using key %r (%s)', k['name'], k['fingerprint'])
|
||||
key = k
|
||||
break
|
||||
else:
|
||||
raise MissingKey('key not found')
|
||||
|
||||
log.debug('signing %d-byte blob', len(blob))
|
||||
r, s = self.signer(label=key['name'], blob=blob)
|
||||
signature = (r, s)
|
||||
log.debug('signature: %s', signature)
|
||||
|
||||
try:
|
||||
key['verifying_key'].verify(signature=signature, data=blob,
|
||||
sigdecode=lambda sig, _: sig)
|
||||
log.info('signature status: OK')
|
||||
except formats.ecdsa.BadSignatureError:
|
||||
log.exception('signature status: ERROR')
|
||||
raise BadSignature('invalid ECDSA signature')
|
||||
|
||||
sig_bytes = io.BytesIO()
|
||||
for x in signature:
|
||||
x_frame = util.frame(b'\x00' + util.num2bytes(x, key['size']))
|
||||
sig_bytes.write(x_frame)
|
||||
sig_bytes = sig_bytes.getvalue()
|
||||
log.debug('signature size: %d bytes', len(sig_bytes))
|
||||
|
||||
data = util.frame(util.frame(key['type']), util.frame(sig_bytes))
|
||||
code = util.pack('B', SSH2_AGENT_SIGN_RESPONSE)
|
||||
return util.frame(code, data)
|
@ -1,102 +0,0 @@
|
||||
import socket
|
||||
import os
|
||||
import subprocess
|
||||
import tempfile
|
||||
import contextlib
|
||||
import threading
|
||||
|
||||
from . import protocol
|
||||
from . import formats
|
||||
from . import util
|
||||
|
||||
import logging
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def remove_file(path, remove=os.remove, exists=os.path.exists):
|
||||
try:
|
||||
remove(path)
|
||||
except OSError:
|
||||
if exists(path):
|
||||
raise
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def unix_domain_socket_server(sock_path):
|
||||
log.debug('serving on SSH_AUTH_SOCK=%s', sock_path)
|
||||
remove_file(sock_path)
|
||||
|
||||
server = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||
server.bind(sock_path)
|
||||
server.listen(1)
|
||||
try:
|
||||
yield server
|
||||
finally:
|
||||
remove_file(sock_path)
|
||||
|
||||
|
||||
def handle_connection(conn, handler):
|
||||
try:
|
||||
log.debug('welcome agent')
|
||||
while True:
|
||||
msg = util.read_frame(conn)
|
||||
reply = handler.handle(msg=msg)
|
||||
util.send(conn, reply)
|
||||
except EOFError:
|
||||
log.debug('goodbye agent')
|
||||
except:
|
||||
log.exception('error')
|
||||
raise
|
||||
|
||||
|
||||
def server_thread(server, handler):
|
||||
log.debug('server thread started')
|
||||
while True:
|
||||
log.debug('waiting for connection on %s', server.getsockname())
|
||||
try:
|
||||
conn, _ = server.accept()
|
||||
except socket.error as e:
|
||||
log.debug('server stopped: %s', e)
|
||||
break
|
||||
with contextlib.closing(conn):
|
||||
handle_connection(conn, handler)
|
||||
log.debug('server thread stopped')
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def spawn(func, **kwargs):
|
||||
t = threading.Thread(target=func, kwargs=kwargs)
|
||||
t.start()
|
||||
yield
|
||||
t.join()
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def serve(public_keys, signer, sock_path=None):
|
||||
if sock_path is None:
|
||||
sock_path = tempfile.mktemp(prefix='ssh-agent-')
|
||||
|
||||
keys = [formats.import_public_key(k) for k in public_keys]
|
||||
environ = {'SSH_AUTH_SOCK': sock_path, 'SSH_AGENT_PID': str(os.getpid())}
|
||||
with unix_domain_socket_server(sock_path) as server:
|
||||
handler = protocol.Handler(keys=keys, signer=signer)
|
||||
with spawn(server_thread, server=server, handler=handler):
|
||||
try:
|
||||
yield environ
|
||||
finally:
|
||||
log.debug('closing server')
|
||||
server.shutdown(socket.SHUT_RD)
|
||||
|
||||
|
||||
def run_process(command, environ, use_shell=False):
|
||||
log.debug('running %r with %r', command, environ)
|
||||
env = dict(os.environ)
|
||||
env.update(environ)
|
||||
try:
|
||||
p = subprocess.Popen(args=command, env=env, shell=use_shell)
|
||||
except OSError as e:
|
||||
raise OSError('cannot run %r: %s' % (command, e))
|
||||
log.debug('subprocess %d is running', p.pid)
|
||||
ret = p.wait()
|
||||
log.debug('subprocess %d exited: %d', p.pid, ret)
|
||||
return ret
|
@ -1,39 +0,0 @@
|
||||
import binascii
|
||||
|
||||
from .. import formats
|
||||
|
||||
|
||||
def test_fingerprint():
|
||||
fp = '5d:41:40:2a:bc:4b:2a:76:b9:71:9d:91:10:17:c5:92'
|
||||
assert formats.fingerprint(b'hello') == fp
|
||||
|
||||
|
||||
_point = (
|
||||
44423495295951059636974944244307637263954375053872017334547086177777411863925, # nopep8
|
||||
111713194882028655451852320740440245619792555065469028846314891587105736340201 # nopep8
|
||||
)
|
||||
|
||||
_public_key = (
|
||||
'ecdsa-sha2-nistp256 '
|
||||
'AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTY'
|
||||
'AAABBBGI2zqveJSB+geQEWG46OvGs2h3+0qu7tIdsH8Wylr'
|
||||
'V19vttd7GR5rKvTWJt8b9ErthmnFALelAFKOB/u50jsuk= '
|
||||
'home\n'
|
||||
)
|
||||
|
||||
|
||||
def test_parse_public_key():
|
||||
key = formats.import_public_key(_public_key)
|
||||
assert key['name'] == b'home'
|
||||
assert key['point'] == _point
|
||||
|
||||
assert key['curve'] == b'nistp256'
|
||||
assert key['fingerprint'] == '4b:19:bc:0f:c8:7e:dc:fa:1a:e3:c2:ff:6f:e0:80:a2' # nopep8
|
||||
assert key['type'] == b'ecdsa-sha2-nistp256'
|
||||
assert key['size'] == 32
|
||||
|
||||
|
||||
def test_decompress():
|
||||
blob = '036236ceabde25207e81e404586e3a3af1acda1dfed2abbbb4876c1fc5b296b575'
|
||||
result = formats.export_public_key(binascii.unhexlify(blob), label='home')
|
||||
assert result == _public_key
|
@ -1,56 +0,0 @@
|
||||
from .. import protocol
|
||||
from .. import formats
|
||||
|
||||
import pytest
|
||||
|
||||
# pylint: disable=line-too-long
|
||||
|
||||
KEY = 'ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBEUksojS/qRlTKBKLQO7CBX7a7oqFkysuFn1nJ6gzlR3wNuQXEgd7qb2bjmiiBHsjNxyWvH5SxVi3+fghrqODWo= ssh://localhost' # nopep8
|
||||
BLOB = b'\x00\x00\x00 !S^\xe7\xf8\x1cKN\xde\xcbo\x0c\x83\x9e\xc48\r\xac\xeb,]"\xc1\x9bA\x0eit\xc1\x81\xd4E2\x00\x00\x00\x05roman\x00\x00\x00\x0essh-connection\x00\x00\x00\tpublickey\x01\x00\x00\x00\x13ecdsa-sha2-nistp256\x00\x00\x00h\x00\x00\x00\x13ecdsa-sha2-nistp256\x00\x00\x00\x08nistp256\x00\x00\x00A\x04E$\xb2\x88\xd2\xfe\xa4eL\xa0J-\x03\xbb\x08\x15\xfbk\xba*\x16L\xac\xb8Y\xf5\x9c\x9e\xa0\xceTw\xc0\xdb\x90\\H\x1d\xee\xa6\xf6n9\xa2\x88\x11\xec\x8c\xdcrZ\xf1\xf9K\x15b\xdf\xe7\xe0\x86\xba\x8e\rj' # nopep8
|
||||
SIG = (61640221631134565789126560951398335114074531708367858563384221818711312348703, 51535548700089687831159696283235534298026173963719263249292887877395159425513) # nopep8
|
||||
|
||||
LIST_MSG = b'\x0b'
|
||||
LIST_REPLY = b'\x00\x00\x00\x84\x0c\x00\x00\x00\x01\x00\x00\x00h\x00\x00\x00\x13ecdsa-sha2-nistp256\x00\x00\x00\x08nistp256\x00\x00\x00A\x04E$\xb2\x88\xd2\xfe\xa4eL\xa0J-\x03\xbb\x08\x15\xfbk\xba*\x16L\xac\xb8Y\xf5\x9c\x9e\xa0\xceTw\xc0\xdb\x90\\H\x1d\xee\xa6\xf6n9\xa2\x88\x11\xec\x8c\xdcrZ\xf1\xf9K\x15b\xdf\xe7\xe0\x86\xba\x8e\rj\x00\x00\x00\x0fssh://localhost' # nopep8
|
||||
|
||||
SIGN_MSG = b'\r\x00\x00\x00h\x00\x00\x00\x13ecdsa-sha2-nistp256\x00\x00\x00\x08nistp256\x00\x00\x00A\x04E$\xb2\x88\xd2\xfe\xa4eL\xa0J-\x03\xbb\x08\x15\xfbk\xba*\x16L\xac\xb8Y\xf5\x9c\x9e\xa0\xceTw\xc0\xdb\x90\\H\x1d\xee\xa6\xf6n9\xa2\x88\x11\xec\x8c\xdcrZ\xf1\xf9K\x15b\xdf\xe7\xe0\x86\xba\x8e\rj\x00\x00\x00\xd1\x00\x00\x00 !S^\xe7\xf8\x1cKN\xde\xcbo\x0c\x83\x9e\xc48\r\xac\xeb,]"\xc1\x9bA\x0eit\xc1\x81\xd4E2\x00\x00\x00\x05roman\x00\x00\x00\x0essh-connection\x00\x00\x00\tpublickey\x01\x00\x00\x00\x13ecdsa-sha2-nistp256\x00\x00\x00h\x00\x00\x00\x13ecdsa-sha2-nistp256\x00\x00\x00\x08nistp256\x00\x00\x00A\x04E$\xb2\x88\xd2\xfe\xa4eL\xa0J-\x03\xbb\x08\x15\xfbk\xba*\x16L\xac\xb8Y\xf5\x9c\x9e\xa0\xceTw\xc0\xdb\x90\\H\x1d\xee\xa6\xf6n9\xa2\x88\x11\xec\x8c\xdcrZ\xf1\xf9K\x15b\xdf\xe7\xe0\x86\xba\x8e\rj\x00\x00\x00\x00' # nopep8
|
||||
SIGN_REPLY = b'\x00\x00\x00j\x0e\x00\x00\x00e\x00\x00\x00\x13ecdsa-sha2-nistp256\x00\x00\x00J\x00\x00\x00!\x00\x88G!\x0c\n\x16:\xbeF\xbe\xb9\xd2\xa9&e\x89\xad\xc4}\x10\xf8\xbc\xdc\xef\x0e\x8d_\x8a6.\xb6\x1f\x00\x00\x00!\x00q\xf0\x16>,\x9a\xde\xe7(\xd6\xd7\x93\x1f\xed\xf9\x94ddw\xfe\xbdq\x13\xbb\xfc\xa9K\xea\x9dC\xa1\xe9' # nopep8
|
||||
|
||||
|
||||
def test_list():
|
||||
key = formats.import_public_key(KEY)
|
||||
h = protocol.Handler(keys=[key], signer=None)
|
||||
reply = h.handle(LIST_MSG)
|
||||
assert reply == LIST_REPLY
|
||||
|
||||
|
||||
def signer(label, blob):
|
||||
assert label == b'ssh://localhost'
|
||||
assert blob == BLOB
|
||||
return SIG
|
||||
|
||||
|
||||
def test_sign():
|
||||
key = formats.import_public_key(KEY)
|
||||
h = protocol.Handler(keys=[key], signer=signer)
|
||||
reply = h.handle(SIGN_MSG)
|
||||
assert reply == SIGN_REPLY
|
||||
|
||||
|
||||
def test_sign_missing():
|
||||
h = protocol.Handler(keys=[], signer=signer)
|
||||
|
||||
with pytest.raises(protocol.MissingKey):
|
||||
h.handle(SIGN_MSG)
|
||||
|
||||
|
||||
def test_sign_wrong():
|
||||
def wrong_signature(label, blob):
|
||||
assert label == b'ssh://localhost'
|
||||
assert blob == BLOB
|
||||
return (0, 0)
|
||||
|
||||
key = formats.import_public_key(KEY)
|
||||
h = protocol.Handler(keys=[key], signer=wrong_signature)
|
||||
|
||||
with pytest.raises(protocol.BadSignature):
|
||||
h.handle(SIGN_MSG)
|
@ -1,118 +0,0 @@
|
||||
import tempfile
|
||||
import socket
|
||||
import os
|
||||
import io
|
||||
import pytest
|
||||
|
||||
from .. import server
|
||||
from .. import protocol
|
||||
from .. import util
|
||||
|
||||
|
||||
def test_socket():
|
||||
path = tempfile.mktemp()
|
||||
with server.unix_domain_socket_server(path):
|
||||
pass
|
||||
assert not os.path.isfile(path)
|
||||
|
||||
|
||||
class SocketMock(object):
|
||||
|
||||
def __init__(self, data=b''):
|
||||
self.rx = io.BytesIO(data)
|
||||
self.tx = io.BytesIO()
|
||||
|
||||
def sendall(self, data):
|
||||
self.tx.write(data)
|
||||
|
||||
def recv(self, size):
|
||||
return self.rx.read(size)
|
||||
|
||||
def close(self):
|
||||
pass
|
||||
|
||||
|
||||
def test_handle():
|
||||
handler = protocol.Handler(keys=[], signer=None)
|
||||
conn = SocketMock()
|
||||
server.handle_connection(conn, handler)
|
||||
|
||||
msg = bytearray([protocol.SSH_AGENTC_REQUEST_RSA_IDENTITIES])
|
||||
conn = SocketMock(util.frame(msg))
|
||||
server.handle_connection(conn, handler)
|
||||
assert conn.tx.getvalue() == b'\x00\x00\x00\x05\x02\x00\x00\x00\x00'
|
||||
|
||||
msg = bytearray([protocol.SSH2_AGENTC_REQUEST_IDENTITIES])
|
||||
conn = SocketMock(util.frame(msg))
|
||||
server.handle_connection(conn, handler)
|
||||
assert conn.tx.getvalue() == b'\x00\x00\x00\x05\x0C\x00\x00\x00\x00'
|
||||
|
||||
with pytest.raises(AttributeError):
|
||||
server.handle_connection(conn=None, handler=None)
|
||||
|
||||
|
||||
class ServerMock(object):
|
||||
|
||||
def __init__(self, connections, name):
|
||||
self.connections = connections
|
||||
self.name = name
|
||||
|
||||
def getsockname(self):
|
||||
return self.name
|
||||
|
||||
def accept(self):
|
||||
if self.connections:
|
||||
return self.connections.pop(), 'address'
|
||||
raise socket.error('stop')
|
||||
|
||||
|
||||
def test_server_thread():
|
||||
s = ServerMock(connections=[SocketMock()], name='mock')
|
||||
h = protocol.Handler(keys=[], signer=None)
|
||||
server.server_thread(s, h)
|
||||
|
||||
|
||||
def test_spawn():
|
||||
obj = []
|
||||
|
||||
def thread(x):
|
||||
obj.append(x)
|
||||
|
||||
with server.spawn(thread, x=1):
|
||||
pass
|
||||
|
||||
assert obj == [1]
|
||||
|
||||
|
||||
def test_run():
|
||||
assert server.run_process(['true'], environ={}) == 0
|
||||
assert server.run_process(['false'], environ={}) == 1
|
||||
assert server.run_process(
|
||||
command='exit $X',
|
||||
environ={'X': '42'},
|
||||
use_shell=True) == 42
|
||||
|
||||
with pytest.raises(OSError):
|
||||
server.run_process([''], environ={})
|
||||
|
||||
|
||||
def test_serve_main():
|
||||
with server.serve(public_keys=[], signer=None, sock_path=None):
|
||||
pass
|
||||
|
||||
|
||||
def test_remove():
|
||||
path = 'foo.bar'
|
||||
|
||||
def remove(p):
|
||||
assert p == path
|
||||
|
||||
server.remove_file(path, remove=remove)
|
||||
|
||||
def remove_raise(_):
|
||||
raise OSError('boom')
|
||||
|
||||
server.remove_file(path, remove=remove_raise, exists=lambda _: False)
|
||||
|
||||
with pytest.raises(OSError):
|
||||
server.remove_file(path, remove=remove_raise, exists=lambda _: True)
|
@ -1,135 +0,0 @@
|
||||
from ..trezor import client
|
||||
from .. import formats
|
||||
|
||||
import mock
|
||||
import pytest
|
||||
|
||||
|
||||
ADDR = [2147483661, 2810943954, 3938368396, 3454558782, 3848009040]
|
||||
CURVE = 'nist256p1'
|
||||
|
||||
PUBKEY = (b'\x03\xd8(\xb5\xa6`\xbet0\x95\xac:[;]\xdc,\xbd\xdc?\xd7\xc0\xec'
|
||||
b'\xdd\xbc+\xfar~\x9dAis')
|
||||
PUBKEY_TEXT = ('ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzd'
|
||||
'HAyNTYAAABBBNgotaZgvnQwlaw6Wztd3Cy93D/XwOzdvCv6cn6dQWlzNMEQeW'
|
||||
'VUfhvrGljR2Z/CMRONY6ejB+9PnpUOPuzYqi8= ssh://localhost:22\n')
|
||||
|
||||
|
||||
class ConnectionMock(object):
|
||||
|
||||
def __init__(self, version):
|
||||
self.features = mock.Mock(spec=[])
|
||||
self.features.device_id = '123456789'
|
||||
self.features.label = 'mywallet'
|
||||
self.features.vendor = 'mock'
|
||||
self.features.major_version = version[0]
|
||||
self.features.minor_version = version[1]
|
||||
self.features.patch_version = version[2]
|
||||
self.features.revision = b'456'
|
||||
self.closed = False
|
||||
|
||||
def close(self):
|
||||
self.closed = True
|
||||
|
||||
def clear_session(self):
|
||||
self.closed = True
|
||||
|
||||
def get_public_node(self, n, ecdsa_curve_name='secp256k1'):
|
||||
assert not self.closed
|
||||
assert n == ADDR
|
||||
assert ecdsa_curve_name in {'secp256k1', 'nist256p1'}
|
||||
result = mock.Mock(spec=[])
|
||||
result.node = mock.Mock(spec=[])
|
||||
result.node.public_key = PUBKEY
|
||||
return result
|
||||
|
||||
def ping(self, msg):
|
||||
assert not self.closed
|
||||
return msg
|
||||
|
||||
|
||||
class FactoryMock(object):
|
||||
|
||||
@staticmethod
|
||||
def client():
|
||||
return ConnectionMock(version=(1, 3, 4))
|
||||
|
||||
@staticmethod
|
||||
def identity_type(**kwargs):
|
||||
result = mock.Mock(spec=[])
|
||||
result.index = 0
|
||||
result.proto = result.user = result.host = result.port = None
|
||||
result.path = None
|
||||
for k, v in kwargs.items():
|
||||
setattr(result, k, v)
|
||||
return result
|
||||
|
||||
|
||||
BLOB = (b'\x00\x00\x00 \xce\xe0\xc9\xd5\xceu/\xe8\xc5\xf2\xbfR+x\xa1\xcf\xb0'
|
||||
b'\x8e;R\xd3)m\x96\x1b\xb4\xd8s\xf1\x99\x16\xaa2\x00\x00\x00\x05roman'
|
||||
b'\x00\x00\x00\x0essh-connection\x00\x00\x00\tpublickey'
|
||||
b'\x01\x00\x00\x00\x13ecdsa-sha2-nistp256\x00\x00\x00h\x00\x00\x00'
|
||||
b'\x13ecdsa-sha2-nistp256\x00\x00\x00\x08nistp256\x00\x00\x00A'
|
||||
b'\x04\xd8(\xb5\xa6`\xbet0\x95\xac:[;]\xdc,\xbd\xdc?\xd7\xc0\xec'
|
||||
b'\xdd\xbc+\xfar~\x9dAis4\xc1\x10yeT~\x1b\xeb\x1aX\xd1\xd9\x9f\xc21'
|
||||
b'\x13\x8dc\xa7\xa3\x07\xefO\x9e\x95\x0e>\xec\xd8\xaa/')
|
||||
|
||||
SIG = (b'\x00R\x19T\xf2\x84$\xef#\x0e\xee\x04X\xc6\xc3\x99T`\xd1\xd8\xf7!'
|
||||
b'\x862@cx\xb8\xb9i@1\x1b3#\x938\x86]\x97*Y\xb2\x02Xa\xdf@\xecK'
|
||||
b'\xdc\xf0H\xab\xa8\xac\xa7? \x8f=C\x88N\xe2')
|
||||
|
||||
|
||||
def test_ssh_agent():
|
||||
c = client.Client(factory=FactoryMock)
|
||||
ident = c.get_identity(label='localhost:22')
|
||||
assert ident.host == 'localhost'
|
||||
assert ident.proto == 'ssh'
|
||||
assert ident.port == '22'
|
||||
assert ident.user is None
|
||||
assert ident.path is None
|
||||
|
||||
with c:
|
||||
assert c.get_public_key(ident) == PUBKEY_TEXT
|
||||
|
||||
def ssh_sign_identity(identity, challenge_hidden,
|
||||
challenge_visual, ecdsa_curve_name):
|
||||
assert identity is ident
|
||||
assert challenge_hidden == BLOB
|
||||
assert challenge_visual == identity.path
|
||||
assert ecdsa_curve_name == 'nist256p1'
|
||||
|
||||
result = mock.Mock(spec=[])
|
||||
result.public_key = PUBKEY
|
||||
result.signature = SIG
|
||||
return result
|
||||
|
||||
c.client.sign_identity = ssh_sign_identity
|
||||
signature = c.sign_ssh_challenge(identity=ident, blob=BLOB)
|
||||
|
||||
key = formats.import_public_key(PUBKEY_TEXT)
|
||||
assert key['verifying_key'].verify(signature=signature, data=BLOB,
|
||||
sigdecode=lambda sig, _: sig)
|
||||
|
||||
|
||||
def test_utils():
|
||||
identity = mock.Mock(spec=[])
|
||||
identity.proto = 'https'
|
||||
identity.user = 'user'
|
||||
identity.host = 'host'
|
||||
identity.port = '443'
|
||||
identity.path = '/path'
|
||||
|
||||
url = 'https://user@host:443/path'
|
||||
assert client.identity_to_string(identity) == url
|
||||
|
||||
|
||||
def test_old_version():
|
||||
|
||||
class OldFactoryMock(FactoryMock):
|
||||
|
||||
@staticmethod
|
||||
def client():
|
||||
return ConnectionMock(version=(1, 2, 3))
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
client.Client(factory=OldFactoryMock)
|
@ -1,47 +0,0 @@
|
||||
import io
|
||||
import pytest
|
||||
|
||||
from .. import util
|
||||
|
||||
|
||||
def test_bytes2num():
|
||||
assert util.bytes2num(b'\x12\x34') == 0x1234
|
||||
|
||||
|
||||
def test_num2bytes():
|
||||
assert util.num2bytes(0x1234, size=2) == b'\x12\x34'
|
||||
|
||||
|
||||
def test_pack():
|
||||
assert util.pack('BHL', 1, 2, 3) == b'\x01\x00\x02\x00\x00\x00\x03'
|
||||
|
||||
|
||||
def test_frames():
|
||||
msgs = [b'aaa', b'bb', b'c' * 0x12340]
|
||||
f = util.frame(*msgs)
|
||||
assert f == b'\x00\x01\x23\x45' + b''.join(msgs)
|
||||
assert util.read_frame(io.BytesIO(f)) == b''.join(msgs)
|
||||
|
||||
|
||||
class SocketMock(object):
|
||||
def __init__(self):
|
||||
self.buf = io.BytesIO()
|
||||
|
||||
def sendall(self, data):
|
||||
self.buf.write(data)
|
||||
|
||||
def recv(self, size):
|
||||
return self.buf.read(size)
|
||||
|
||||
|
||||
def test_send_recv():
|
||||
s = SocketMock()
|
||||
util.send(s, b'123')
|
||||
util.send(s, data=[42], fmt='B')
|
||||
assert s.buf.getvalue() == b'123*'
|
||||
|
||||
s.buf.seek(0)
|
||||
assert util.recv(s, 2) == b'12'
|
||||
assert util.recv(s, 2) == b'3*'
|
||||
|
||||
pytest.raises(EOFError, util.recv, s, 1)
|
@ -1 +0,0 @@
|
||||
from .client import Client
|
@ -1,18 +0,0 @@
|
||||
''' Thin wrapper around trezorlib. '''
|
||||
|
||||
|
||||
def client():
|
||||
# pylint: disable=import-error
|
||||
from trezorlib.client import TrezorClient
|
||||
from trezorlib.transport_hid import HidTransport
|
||||
devices = HidTransport.enumerate()
|
||||
if len(devices) != 1:
|
||||
msg = '{:d} Trezor devices found'.format(len(devices))
|
||||
raise IOError(msg)
|
||||
return TrezorClient(HidTransport(devices[0]))
|
||||
|
||||
|
||||
def identity_type(**kwargs):
|
||||
# pylint: disable=import-error
|
||||
from trezorlib.types_pb2 import IdentityType
|
||||
return IdentityType(**kwargs)
|
@ -1,152 +0,0 @@
|
||||
import io
|
||||
import re
|
||||
import struct
|
||||
import binascii
|
||||
|
||||
from .. import util
|
||||
from .. import formats
|
||||
from . import _factory as TrezorFactory
|
||||
|
||||
import logging
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Client(object):
|
||||
|
||||
MIN_VERSION = [1, 3, 4]
|
||||
|
||||
def __init__(self, factory=TrezorFactory):
|
||||
self.factory = factory
|
||||
self.client = self.factory.client()
|
||||
f = self.client.features
|
||||
log.debug('connected to Trezor %s', f.device_id)
|
||||
log.debug('label : %s', f.label)
|
||||
log.debug('vendor : %s', f.vendor)
|
||||
version = [f.major_version, f.minor_version, f.patch_version]
|
||||
version_str = '.'.join([str(v) for v in version])
|
||||
log.debug('version : %s', version_str)
|
||||
log.debug('revision : %s', binascii.hexlify(f.revision))
|
||||
if version < self.MIN_VERSION:
|
||||
fmt = 'Please upgrade your TREZOR to v{}+ firmware'
|
||||
version_str = '.'.join([str(v) for v in self.MIN_VERSION])
|
||||
raise ValueError(fmt.format(version_str))
|
||||
|
||||
def __enter__(self):
|
||||
msg = 'Hello World!'
|
||||
assert self.client.ping(msg) == msg
|
||||
return self
|
||||
|
||||
def __exit__(self, *args):
|
||||
log.info('disconnected from Trezor')
|
||||
self.client.clear_session() # forget PIN and shutdown screen
|
||||
self.client.close()
|
||||
|
||||
def get_identity(self, label):
|
||||
identity = string_to_identity(label, self.factory.identity_type)
|
||||
identity.proto = 'ssh'
|
||||
return identity
|
||||
|
||||
def get_public_key(self, identity):
|
||||
assert identity.proto == 'ssh'
|
||||
label = identity_to_string(identity)
|
||||
log.info('getting "%s" public key from Trezor...', label)
|
||||
addr = _get_address(identity)
|
||||
node = self.client.get_public_node(n=addr,
|
||||
ecdsa_curve_name='nist256p1')
|
||||
|
||||
pubkey = node.node.public_key
|
||||
return formats.export_public_key(pubkey=pubkey, label=label)
|
||||
|
||||
def sign_ssh_challenge(self, identity, blob):
|
||||
assert identity.proto == 'ssh'
|
||||
label = identity_to_string(identity)
|
||||
msg = _parse_ssh_blob(blob)
|
||||
|
||||
log.info('please confirm user "%s" login to "%s" using Trezor...',
|
||||
msg['user'], label)
|
||||
|
||||
visual = identity.path # not signed when proto='ssh'
|
||||
result = self.client.sign_identity(identity=identity,
|
||||
challenge_hidden=blob,
|
||||
challenge_visual=visual,
|
||||
ecdsa_curve_name='nist256p1')
|
||||
verifying_key = formats.decompress_pubkey(result.public_key)
|
||||
public_key_blob = formats.serialize_verifying_key(verifying_key)
|
||||
assert public_key_blob == msg['public_key']['blob']
|
||||
assert len(result.signature) == 65
|
||||
assert result.signature[:1] == bytearray([0])
|
||||
|
||||
return parse_signature(result.signature)
|
||||
|
||||
|
||||
def parse_signature(blob):
|
||||
sig = blob[1:]
|
||||
r = util.bytes2num(sig[:32])
|
||||
s = util.bytes2num(sig[32:])
|
||||
return (r, s)
|
||||
|
||||
|
||||
_identity_regexp = re.compile(''.join([
|
||||
'^'
|
||||
r'(?:(?P<proto>.*)://)?',
|
||||
r'(?:(?P<user>.*)@)?',
|
||||
r'(?P<host>.*?)',
|
||||
r'(?::(?P<port>\w*))?',
|
||||
r'(?P<path>/.*)?',
|
||||
'$'
|
||||
]))
|
||||
|
||||
|
||||
def string_to_identity(s, identity_type):
|
||||
m = _identity_regexp.match(s)
|
||||
result = m.groupdict()
|
||||
log.debug('parsed identity: %s', result)
|
||||
kwargs = {k: v for k, v in result.items() if v}
|
||||
return identity_type(**kwargs)
|
||||
|
||||
|
||||
def identity_to_string(identity):
|
||||
result = []
|
||||
if identity.proto:
|
||||
result.append(identity.proto + '://')
|
||||
if identity.user:
|
||||
result.append(identity.user + '@')
|
||||
result.append(identity.host)
|
||||
if identity.port:
|
||||
result.append(':' + identity.port)
|
||||
if identity.path:
|
||||
result.append(identity.path)
|
||||
return ''.join(result)
|
||||
|
||||
|
||||
def _get_address(identity):
|
||||
index = struct.pack('<L', identity.index)
|
||||
addr = index + identity_to_string(identity).encode('ascii')
|
||||
log.debug('address string: %r', addr)
|
||||
digest = formats.hashfunc(addr).digest()
|
||||
s = io.BytesIO(bytearray(digest))
|
||||
|
||||
hardened = 0x80000000
|
||||
address_n = [13] + list(util.recv(s, '<LLLL'))
|
||||
return [(hardened | value) for value in address_n]
|
||||
|
||||
|
||||
def _parse_ssh_blob(data):
|
||||
res = {}
|
||||
if data:
|
||||
i = io.BytesIO(data)
|
||||
res['nonce'] = util.read_frame(i)
|
||||
i.read(1) # TBD
|
||||
res['user'] = util.read_frame(i)
|
||||
res['conn'] = util.read_frame(i)
|
||||
res['auth'] = util.read_frame(i)
|
||||
i.read(1) # TBD
|
||||
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()
|
||||
log.debug('%s: user %r via %r (%r)',
|
||||
res['conn'], res['user'], res['auth'], res['key_type'])
|
||||
log.debug('nonce: %s', binascii.hexlify(res['nonce']))
|
||||
log.debug('fingerprint: %s', res['public_key']['fingerprint'])
|
||||
return res
|
@ -1,66 +0,0 @@
|
||||
import struct
|
||||
import io
|
||||
|
||||
|
||||
def send(conn, data, fmt=None):
|
||||
if fmt:
|
||||
data = struct.pack(fmt, *data)
|
||||
conn.sendall(data)
|
||||
|
||||
|
||||
def recv(conn, size):
|
||||
try:
|
||||
fmt = size
|
||||
size = struct.calcsize(fmt)
|
||||
except TypeError:
|
||||
fmt = None
|
||||
try:
|
||||
_read = conn.recv
|
||||
except AttributeError:
|
||||
_read = conn.read
|
||||
|
||||
res = io.BytesIO()
|
||||
while size > 0:
|
||||
buf = _read(size)
|
||||
if not buf:
|
||||
raise EOFError
|
||||
size = size - len(buf)
|
||||
res.write(buf)
|
||||
res = res.getvalue()
|
||||
if fmt:
|
||||
return struct.unpack(fmt, res)
|
||||
else:
|
||||
return res
|
||||
|
||||
|
||||
def read_frame(conn):
|
||||
size, = recv(conn, '>L')
|
||||
return recv(conn, size)
|
||||
|
||||
|
||||
def bytes2num(s):
|
||||
res = 0
|
||||
for i, c in enumerate(reversed(bytearray(s))):
|
||||
res += c << (i * 8)
|
||||
return res
|
||||
|
||||
|
||||
def num2bytes(value, size):
|
||||
res = []
|
||||
for _ in range(size):
|
||||
res.append(value & 0xFF)
|
||||
value = value >> 8
|
||||
assert value == 0
|
||||
return bytearray(list(reversed(res)))
|
||||
|
||||
|
||||
def pack(fmt, *args):
|
||||
return struct.pack('>' + fmt, *args)
|
||||
|
||||
|
||||
def frame(*msgs):
|
||||
res = io.BytesIO()
|
||||
for msg in msgs:
|
||||
res.write(msg)
|
||||
msg = res.getvalue()
|
||||
return pack('L', len(msg)) + msg
|
Loading…
Reference in New Issue