mirror of
https://github.com/romanz/amodem.git
synced 2026-05-03 08:27:26 +08:00
Compare commits
584 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
71a8930021 | ||
|
|
74e8f21a22 | ||
|
|
897236d556 | ||
|
|
5bec0e8382 | ||
|
|
3cb7f6fd21 | ||
|
|
cad2ec1239 | ||
|
|
604b2b7e99 | ||
|
|
159bd79b5f | ||
|
|
dde0b60e83 | ||
|
|
109bb3b47f | ||
|
|
0f20bfa239 | ||
|
|
798597c436 | ||
|
|
a13b1103f7 | ||
|
|
9fe1a235c1 | ||
|
|
f86aae9a40 | ||
|
|
fc070e3ca0 | ||
|
|
05fac995eb | ||
|
|
188b74b327 | ||
|
|
fc31847f8e | ||
|
|
0faf21a102 | ||
|
|
6b82f8b9b7 | ||
|
|
fabfcaaae2 | ||
|
|
f0f89310ac | ||
|
|
47ff7c5cb3 | ||
|
|
0440025083 | ||
|
|
c49fe97f63 | ||
|
|
7f8abcb5c5 | ||
|
|
e13039e52d | ||
|
|
c420571eb8 | ||
|
|
827119a18d | ||
|
|
9be6504658 | ||
|
|
07cbe65875 | ||
|
|
180120e787 | ||
|
|
f4ce81fa94 | ||
|
|
176bf4ef7c | ||
|
|
d22cd7512d | ||
|
|
83f17704cb | ||
|
|
92f6751ccb | ||
|
|
abe80533eb | ||
|
|
de51665c71 | ||
|
|
c30e5f5a67 | ||
|
|
2eab2a152c | ||
|
|
5e93d97be3 | ||
|
|
4c8fcd6714 | ||
|
|
ee593bc66e | ||
|
|
dbed773e54 | ||
|
|
ac4a86d312 | ||
|
|
021831073e | ||
|
|
6a5acba0b0 | ||
|
|
9123cef810 | ||
|
|
6f6e7c0bcc | ||
|
|
47ff081525 | ||
|
|
6d53baafe2 | ||
|
|
317b672add | ||
|
|
9964c200ff | ||
|
|
75405b4944 | ||
|
|
e74b9c77af | ||
|
|
c2158947c8 | ||
|
|
e39d5025d5 | ||
|
|
efdb9fcfb5 | ||
|
|
a20b1ed2a8 | ||
|
|
ca507126d6 | ||
|
|
0f79b5ff2e | ||
|
|
946ab633d4 | ||
|
|
4108c9287f | ||
|
|
d9cb75e95d | ||
|
|
2cecd2ed08 | ||
|
|
05f40085b2 | ||
|
|
c7346d621d | ||
|
|
0342b39465 | ||
|
|
fa6d8564b9 | ||
|
|
e09712c793 | ||
|
|
0cbb3bb9fa | ||
|
|
d7a6641ffa | ||
|
|
6fe89241c4 | ||
|
|
c5262d075b | ||
|
|
683d24f4eb | ||
|
|
921e2954c1 | ||
|
|
3f784289d8 | ||
|
|
04d790767d | ||
|
|
97efdf4a45 | ||
|
|
ee2f6b75dc | ||
|
|
a26f0ea034 | ||
|
|
a68f1e5c26 | ||
|
|
93e3c66a15 | ||
|
|
44eaaa6b9c | ||
|
|
b83d4960e7 | ||
|
|
75fe7b4e05 | ||
|
|
742136b22d | ||
|
|
513e99dd57 | ||
|
|
1bd6775c35 | ||
|
|
aaade1737f | ||
|
|
fe185c190e | ||
|
|
1bc0165368 | ||
|
|
0f841ffbc4 | ||
|
|
b2942035a3 | ||
|
|
215b64f253 | ||
|
|
79e68b29c2 | ||
|
|
8265515641 | ||
|
|
749799845d | ||
|
|
eaea35003e | ||
|
|
eefb38ce83 | ||
|
|
0730eb7223 | ||
|
|
5b61702205 | ||
|
|
0ad0ca3b9a | ||
|
|
2843cdcf41 | ||
|
|
c7bc78ebe7 | ||
|
|
a6d9edcb0b | ||
|
|
bc64205a85 | ||
|
|
34dc803856 | ||
|
|
f7ebb02799 | ||
|
|
0ba33a5bc4 | ||
|
|
13752ddcd5 | ||
|
|
487a8e56c4 | ||
|
|
ef56ee4602 | ||
|
|
ae381a38e5 | ||
|
|
446ec99bf4 | ||
|
|
80c6f10533 | ||
|
|
ff984c60e4 | ||
|
|
c9bc079dc9 | ||
|
|
65d2c04478 | ||
|
|
2d57bf4453 | ||
|
|
79b6d31dfe | ||
|
|
7de88a3980 | ||
|
|
6f8d0df116 | ||
|
|
b4a382d22e | ||
|
|
d236f4667e | ||
|
|
42813ddbb4 | ||
|
|
8f19690943 | ||
|
|
5047805385 | ||
|
|
915b326da7 | ||
|
|
e7b8379a97 | ||
|
|
26435130d7 | ||
|
|
dfde6dbee4 | ||
|
|
085a3e81c7 | ||
|
|
3082d61deb | ||
|
|
e3286a4510 | ||
|
|
fcd5671626 | ||
|
|
1454d2f4d7 | ||
|
|
9b395363a3 | ||
|
|
5bb9dd7770 | ||
|
|
51df023a23 | ||
|
|
d74f375637 | ||
|
|
1fd0659051 | ||
|
|
18be290bd6 | ||
|
|
a1ab496bf4 | ||
|
|
784e14647a | ||
|
|
7d2c649e83 | ||
|
|
cf27b345f6 | ||
|
|
386ed5a81f | ||
|
|
5a64954324 | ||
|
|
3aebd137b0 | ||
|
|
1fa35e7f1a | ||
|
|
aeda85275d | ||
|
|
e41206b350 | ||
|
|
03650550dd | ||
|
|
f7b07070da | ||
|
|
96eede9c83 | ||
|
|
91146303a3 | ||
|
|
bf598435fb | ||
|
|
459b882b89 | ||
|
|
998c9ee958 | ||
|
|
d408a592aa | ||
|
|
282e91ace3 | ||
|
|
23c37cf1e3 | ||
|
|
5c5c6f9cbb | ||
|
|
17c8bd0e92 | ||
|
|
016e864503 | ||
|
|
57e09248db | ||
|
|
0c4e67c837 | ||
|
|
adcbe6e7b2 | ||
|
|
73bdf417e4 | ||
|
|
ee347252b4 | ||
|
|
d63f048b78 | ||
|
|
05fada91d2 | ||
|
|
27a3fddfa2 | ||
|
|
030ae4c3f6 | ||
|
|
4897b70888 | ||
|
|
f4ecd47ed6 | ||
|
|
c4bbac0e77 | ||
|
|
5d0b0f65d3 | ||
|
|
33747592ca | ||
|
|
adb09cd8ca | ||
|
|
45f6f1a3d8 | ||
|
|
c4c56b9faf | ||
|
|
bc1d7a5448 | ||
|
|
8fe16d24c2 | ||
|
|
1704ae7683 | ||
|
|
a7190223fd | ||
|
|
220735c6ad | ||
|
|
82e08d073b | ||
|
|
8ab0908388 | ||
|
|
fd3183d71c | ||
|
|
295d52ef10 | ||
|
|
8a51099488 | ||
|
|
f4dd1eacdd | ||
|
|
024b5f131f | ||
|
|
b9b7b8dafd | ||
|
|
744696fdee | ||
|
|
ccdbc7abfc | ||
|
|
e70f0ec681 | ||
|
|
aeaf978d8e | ||
|
|
d60fff202a | ||
|
|
9171dd08c8 | ||
|
|
4c5004d838 | ||
|
|
a2e46048a1 | ||
|
|
e66b0f47ed | ||
|
|
db874ad98f | ||
|
|
ed2d71cc08 | ||
|
|
59b39ce81f | ||
|
|
75f879edbb | ||
|
|
45a85a317b | ||
|
|
7b3874e6f7 | ||
|
|
6c96cc37b9 | ||
|
|
c98cb22ba4 | ||
|
|
d9fbfccd35 | ||
|
|
fe4d9ed3c8 | ||
|
|
092445af71 | ||
|
|
602e867c7d | ||
|
|
16de8cdabc | ||
|
|
7bbf11b631 | ||
|
|
3e41fddcef | ||
|
|
8108e5400d | ||
|
|
a1659e0f0d | ||
|
|
3b139314b6 | ||
|
|
a05cff5079 | ||
|
|
694cee17ac | ||
|
|
bc281d4411 | ||
|
|
04af6b737b | ||
|
|
171c746c7e | ||
|
|
8b5ac14150 | ||
|
|
16090cebed | ||
|
|
d2167cd4ff | ||
|
|
10cbe67c9a | ||
|
|
29a984eebb | ||
|
|
a6660fd5c5 | ||
|
|
2acd0bf3b7 | ||
|
|
e9f7894d62 | ||
|
|
56e9d7c776 | ||
|
|
e7bacf829c | ||
|
|
c1c679b541 | ||
|
|
49c343df94 | ||
|
|
7da7f5c256 | ||
|
|
39cb5565bf | ||
|
|
f89c5bb125 | ||
|
|
92649b290f | ||
|
|
d9b07e2ac6 | ||
|
|
6975671cc1 | ||
|
|
f0ea568bb8 | ||
|
|
34c614db6e | ||
|
|
2bbd335f7e | ||
|
|
af8ad99c7a | ||
|
|
313271ac06 | ||
|
|
969e08140b | ||
|
|
39f00af65d | ||
|
|
272759e907 | ||
|
|
4be55156ed | ||
|
|
80a5ea0f2a | ||
|
|
87e50449e5 | ||
|
|
dcf35c4267 | ||
|
|
7570861765 | ||
|
|
339f61c071 | ||
|
|
3c4fb7a17b | ||
|
|
a6a0c05f57 | ||
|
|
4c036d2ce7 | ||
|
|
eaa91cfdbd | ||
|
|
fd61941d0f | ||
|
|
decd3ddf75 | ||
|
|
4c07b360cd | ||
|
|
0b0f60dd89 | ||
|
|
db6903eab7 | ||
|
|
171a0c2f6a | ||
|
|
a535b31a1b | ||
|
|
ee4bcddd22 | ||
|
|
f626d34e21 | ||
|
|
2cf081420f | ||
|
|
0e72e3b7ff | ||
|
|
ce61c8b2ae | ||
|
|
3192e570ed | ||
|
|
bf8f516ef4 | ||
|
|
51f7d6120b | ||
|
|
0cb7cf0746 | ||
|
|
b4ff31f816 | ||
|
|
6e9d6d6430 | ||
|
|
fa9391ede6 | ||
|
|
ad8eafe6f8 | ||
|
|
695079e4b9 | ||
|
|
9888ef971a | ||
|
|
04a878374f | ||
|
|
4270d8464f | ||
|
|
25a427081c | ||
|
|
939fdbe829 | ||
|
|
1f126f3002 | ||
|
|
78526d1379 | ||
|
|
7e3c3b4f77 | ||
|
|
513c19bf1f | ||
|
|
f1e75783c4 | ||
|
|
68637525ea | ||
|
|
fce45832c2 | ||
|
|
df001c4100 | ||
|
|
1a228a1af6 | ||
|
|
7a7c9efc47 | ||
|
|
859cee9757 | ||
|
|
2846c0bf1a | ||
|
|
b2147a8418 | ||
|
|
4cbf8a9f0a | ||
|
|
d9c4e930f3 | ||
|
|
6fd6fe6520 | ||
|
|
4a7fef3011 | ||
|
|
a0e476ea19 | ||
|
|
683aae7aa4 | ||
|
|
d369638c7b | ||
|
|
07c4100618 | ||
|
|
b9f139b74a | ||
|
|
3bf926620b | ||
|
|
ab192619f4 | ||
|
|
f982d785bd | ||
|
|
38c1acf4db | ||
|
|
31c3686fa4 | ||
|
|
87ca33c104 | ||
|
|
c3d23ea7f5 | ||
|
|
5c04d17c43 | ||
|
|
2d2d6efa93 | ||
|
|
131c30acca | ||
|
|
a7ef263954 | ||
|
|
d486c1ee7b | ||
|
|
f35b5be3ac | ||
|
|
9ed9781496 | ||
|
|
5d007260e1 | ||
|
|
7dfa3ab255 | ||
|
|
b8eba72d0b | ||
|
|
492285de1b | ||
|
|
cc326b1f7d | ||
|
|
169ff39b1a | ||
|
|
dcc7ef2600 | ||
|
|
ac2d12b354 | ||
|
|
f3b49ff553 | ||
|
|
12d640c66b | ||
|
|
32984d2d3f | ||
|
|
a45c6c1300 | ||
|
|
1d3ba7e9b7 | ||
|
|
673b1df648 | ||
|
|
e63f03354e | ||
|
|
3c9c1b4e14 | ||
|
|
5caf4728ee | ||
|
|
dde6dcdaeb | ||
|
|
1f3c989884 | ||
|
|
55dea41959 | ||
|
|
ed01c00d0c | ||
|
|
e09571151c | ||
|
|
340aae4fb8 | ||
|
|
9875c9927e | ||
|
|
d9862ae0e1 | ||
|
|
5fb8b0e047 | ||
|
|
324fc21a5c | ||
|
|
e2f5ccafdf | ||
|
|
a0b4776374 | ||
|
|
5abc3dc41b | ||
|
|
3c2eb64e0d | ||
|
|
67d58a5ae0 | ||
|
|
9a435ae23e | ||
|
|
d7913a84d5 | ||
|
|
a114242243 | ||
|
|
b6dbc4aa81 | ||
|
|
6cc3a629a8 | ||
|
|
0c94363595 | ||
|
|
40377fc66b | ||
|
|
489c8fe357 | ||
|
|
6f4f33bfa5 | ||
|
|
76ce25fab1 | ||
|
|
5506310239 | ||
|
|
9dc955aae8 | ||
|
|
80f29469d0 | ||
|
|
fb368d24eb | ||
|
|
8c0848b459 | ||
|
|
276dec5728 | ||
|
|
74f7ebf228 | ||
|
|
7ef0958c33 | ||
|
|
1402918bb3 | ||
|
|
b6cfa0c03f | ||
|
|
33ff9ba667 | ||
|
|
ab64505cdb | ||
|
|
5651452c0d | ||
|
|
af6d0caf33 | ||
|
|
96592269b6 | ||
|
|
b2d078eec6 | ||
|
|
01dafb0ebd | ||
|
|
447faf973c | ||
|
|
add90e3c51 | ||
|
|
34670c601d | ||
|
|
b9ba4a3082 | ||
|
|
4335740abe | ||
|
|
861401e89a | ||
|
|
335d050212 | ||
|
|
6e1b08c27a | ||
|
|
b3a6c76631 | ||
|
|
f056f1fac5 | ||
|
|
716dc82312 | ||
|
|
0e2a19f7ce | ||
|
|
2cdbc89d28 | ||
|
|
1022e54d6a | ||
|
|
ea88f425f5 | ||
|
|
000860feaf | ||
|
|
2a5196003e | ||
|
|
e10b42bbb5 | ||
|
|
b07d7e6535 | ||
|
|
4838030be5 | ||
|
|
c9f341a42b | ||
|
|
bdd2568b2c | ||
|
|
ae20ae4a04 | ||
|
|
f15c2c7236 | ||
|
|
e6ccc324a0 | ||
|
|
7c102e435e | ||
|
|
7f6bb12b24 | ||
|
|
98e875562e | ||
|
|
4384b93c19 | ||
|
|
8a90a8cd84 | ||
|
|
1e86983782 | ||
|
|
c63201c90c | ||
|
|
19b00dc427 | ||
|
|
aa35981980 | ||
|
|
8909b38107 | ||
|
|
6d9aa9cb8a | ||
|
|
d6532311b9 | ||
|
|
41b30b42b5 | ||
|
|
5b0e56697f | ||
|
|
0e6d998b4c | ||
|
|
2c7fabfa35 | ||
|
|
1adccdbfe6 | ||
|
|
04f4bbf2ac | ||
|
|
bbe963d0ff | ||
|
|
c49514754b | ||
|
|
2ebefff909 | ||
|
|
21e89014c9 | ||
|
|
566e4310e1 | ||
|
|
e1441518d4 | ||
|
|
5cb12a43de | ||
|
|
df607f3665 | ||
|
|
d712509a4e | ||
|
|
40e2d9fb2c | ||
|
|
cd4cc059d6 | ||
|
|
2b047f0525 | ||
|
|
64776fd294 | ||
|
|
231995bd1a | ||
|
|
ff76f17c02 | ||
|
|
963e80b49b | ||
|
|
dee13b75ea | ||
|
|
be86507e00 | ||
|
|
2f2663ef94 | ||
|
|
cafa218e19 | ||
|
|
50b627ed45 | ||
|
|
7f36097c15 | ||
|
|
a4b905cd6f | ||
|
|
2eff21f96c | ||
|
|
9afd07e867 | ||
|
|
b101281a5b | ||
|
|
8c6ac43cf4 | ||
|
|
5932a89dc5 | ||
|
|
2009160ff2 | ||
|
|
3d8072522c | ||
|
|
0c63aef719 | ||
|
|
c454114c4e | ||
|
|
f9133f7e05 | ||
|
|
33a6951a96 | ||
|
|
fb0d0a5f61 | ||
|
|
7ea20c7009 | ||
|
|
4247558166 | ||
|
|
fe1e1d2bb9 | ||
|
|
1a5b8118ad | ||
|
|
3a806c6d77 | ||
|
|
3b61f86c25 | ||
|
|
06d84c387c | ||
|
|
8347142a99 | ||
|
|
7dabe2c555 | ||
|
|
d6ee3d8995 | ||
|
|
c3fa79e450 | ||
|
|
15b10c9a7e | ||
|
|
e19d76398e | ||
|
|
535b4d50c7 | ||
|
|
461f38d599 | ||
|
|
60571e65dd | ||
|
|
34cecb276a | ||
|
|
903ba919b3 | ||
|
|
3184d34440 | ||
|
|
d7099cb863 | ||
|
|
e3f04f3389 | ||
|
|
e59404737d | ||
|
|
ca30707789 | ||
|
|
5449411d09 | ||
|
|
697d22fede | ||
|
|
4f94c9459c | ||
|
|
f5577e1c15 | ||
|
|
803e3bb738 | ||
|
|
c11245ea69 | ||
|
|
7b5dd3a51b | ||
|
|
4199c79074 | ||
|
|
38fd938fd4 | ||
|
|
ad35e03a9f | ||
|
|
dd6fded82d | ||
|
|
8547d00b33 | ||
|
|
a4a0c6a802 | ||
|
|
38228baba9 | ||
|
|
a8c6e71825 | ||
|
|
9a1e49190b | ||
|
|
e4a7d9aa06 | ||
|
|
5d510c4a60 | ||
|
|
783722edce | ||
|
|
592bc78391 | ||
|
|
bf6b58971a | ||
|
|
8d09d9e928 | ||
|
|
8d45e42d10 | ||
|
|
dd93dea0a9 | ||
|
|
5ba24c9386 | ||
|
|
ef8c190c6c | ||
|
|
d8489e6a5a | ||
|
|
0b8e7be629 | ||
|
|
396967433a | ||
|
|
b71c1e132c | ||
|
|
b33a281762 | ||
|
|
d47741e9ab | ||
|
|
88e60b4338 | ||
|
|
b7a1dd61ea | ||
|
|
0e28873e0a | ||
|
|
a09d8030f5 | ||
|
|
241b342f43 | ||
|
|
91d0905364 | ||
|
|
0db45cbd7e | ||
|
|
bc6bebc79e | ||
|
|
784357238a | ||
|
|
0a94134d8a | ||
|
|
fb89909146 | ||
|
|
838df004f0 | ||
|
|
211c989b1a | ||
|
|
6427ded731 | ||
|
|
b634d7cdb9 | ||
|
|
d403e58be0 | ||
|
|
622363c1f1 | ||
|
|
c367a1bd9d | ||
|
|
f462d49141 | ||
|
|
cf572c7417 | ||
|
|
362c2870ad | ||
|
|
524df20ad6 | ||
|
|
2ce448f736 | ||
|
|
38136e76d5 | ||
|
|
85e0cf903e | ||
|
|
e54fda332e | ||
|
|
afa42d80a3 | ||
|
|
55a0c3ed97 | ||
|
|
e7585170ae | ||
|
|
b6d0efef9f | ||
|
|
709782cb41 | ||
|
|
921b973075 | ||
|
|
1a42dbc46d | ||
|
|
86b166ba08 | ||
|
|
b7cc276259 | ||
|
|
b3ccb4c729 | ||
|
|
e1e4a6b93e | ||
|
|
00730069e7 | ||
|
|
0c21e33324 | ||
|
|
1c6b949fe9 | ||
|
|
7d2b9cf820 | ||
|
|
1296669512 | ||
|
|
71fde5e704 | ||
|
|
cdaf4cad36 | ||
|
|
63990bf698 | ||
|
|
ccdf37d371 | ||
|
|
df4f3a1b19 | ||
|
|
04afefcba2 | ||
|
|
bd22d7512b | ||
|
|
2142e36013 | ||
|
|
4562d64714 | ||
|
|
ee01781ebe | ||
|
|
8273cc89f8 | ||
|
|
93bac8b011 | ||
|
|
dcfc88c7e1 | ||
|
|
8c82480c34 | ||
|
|
7f0424d29a | ||
|
|
d0c5421a3d | ||
|
|
14b3b500cd | ||
|
|
333d1e8e20 | ||
|
|
5db8a068ef | ||
|
|
a77f9edb4e | ||
|
|
3057a3d7a9 | ||
|
|
58c650c84a |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -55,3 +55,6 @@ docs/_build/
|
||||
|
||||
# PyBuilder
|
||||
target/
|
||||
|
||||
# Sublime Text
|
||||
*.sublime-*
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
[MESSAGES CONTROL]
|
||||
disable=invalid-name, missing-docstring
|
||||
disable=invalid-name, missing-docstring, locally-disabled, unbalanced-tuple-unpacking
|
||||
|
||||
33
.travis.yml
Normal file
33
.travis.yml
Normal file
@@ -0,0 +1,33 @@
|
||||
sudo: false
|
||||
language: python
|
||||
python:
|
||||
- "2.7"
|
||||
- "3.4"
|
||||
- "3.5"
|
||||
|
||||
cache:
|
||||
directories:
|
||||
- $HOME/.cache/pip
|
||||
|
||||
addons:
|
||||
apt:
|
||||
packages:
|
||||
- libudev-dev
|
||||
- libusb-1.0-0-dev
|
||||
|
||||
before_install:
|
||||
- pip install -U setuptools pylint coverage pep8 pydocstyle "pip>=7.0" wheel
|
||||
- pip install -e git+https://github.com/keepkey/python-keepkey@6e8baa8b935e830d05f87b6dfd9bc7c927a96dc3#egg=keepkey
|
||||
|
||||
install:
|
||||
- pip install -e .
|
||||
|
||||
script:
|
||||
- pep8 trezor_agent
|
||||
- pylint --reports=no --rcfile .pylintrc trezor_agent
|
||||
- pydocstyle trezor_agent
|
||||
- coverage run --source trezor_agent/ -m py.test -v
|
||||
|
||||
after_success:
|
||||
- coverage report
|
||||
|
||||
51
README-GPG.md
Normal file
51
README-GPG.md
Normal file
@@ -0,0 +1,51 @@
|
||||
Note: the GPG-related code is still under development, so please try the current implementation
|
||||
and feel free to [report any issue](https://github.com/romanz/trezor-agent/issues) you have encountered.
|
||||
Thanks!
|
||||
|
||||
# Installation
|
||||
|
||||
First, verify that you have GPG 2.1.11+ installed
|
||||
([Debian](https://gist.github.com/vt0r/a2f8c0bcb1400131ff51),
|
||||
[macOS](https://sourceforge.net/p/gpgosx/docu/Download/)):
|
||||
|
||||
```
|
||||
$ gpg2 --version | head -n1
|
||||
gpg (GnuPG) 2.1.15
|
||||
```
|
||||
|
||||
This GPG version is included in [Ubuntu 16.04](https://launchpad.net/ubuntu/+source/gnupg2)
|
||||
and [Linux Mint 18](https://community.linuxmint.com/software/view/gnupg2).
|
||||
|
||||
Update you TREZOR firmware to the latest version (at least v1.4.0).
|
||||
|
||||
Install latest `trezor-agent` package from GitHub:
|
||||
```
|
||||
$ pip install --user git+https://github.com/romanz/trezor-agent.git
|
||||
```
|
||||
|
||||
# Quickstart
|
||||
|
||||
## Identity creation
|
||||
[](https://asciinema.org/a/c2yodst21h9obttkn9wgf3783)
|
||||
|
||||
## Sample usage (signature and decryption)
|
||||
[](https://asciinema.org/a/7x0h9tyoyu5ar6jc8y9oih0ba)
|
||||
|
||||
You can use GNU Privacy Assistant (GPA) in order to inspect the created keys
|
||||
and perform signature and decryption operations using:
|
||||
|
||||
```
|
||||
$ sudo apt install gpa
|
||||
$ ./scripts/gpg-shell gpa
|
||||
```
|
||||
[](https://www.gnupg.org/related_software/swlist.html#gpa)
|
||||
|
||||
## Git commit & tag signatures:
|
||||
Git can use GPG to sign and verify commits and tags (see [here](https://git-scm.com/book/en/v2/Git-Tools-Signing-Your-Work)):
|
||||
```
|
||||
$ git config --local gpg.program $(which gpg2)
|
||||
$ git commit --gpg-sign # create GPG-signed commit
|
||||
$ git log --show-signature -1 # verify commit signature
|
||||
$ git tag v1.2.3 --sign # create GPG-signed tag
|
||||
$ git tag v1.2.3 --verify # verify tag signature
|
||||
```
|
||||
87
README-SSH.md
Normal file
87
README-SSH.md
Normal file
@@ -0,0 +1,87 @@
|
||||
# Screencast demo usage
|
||||
|
||||
## Simple usage (single SSH session)
|
||||
[](https://asciinema.org/a/22959)
|
||||
|
||||
## Advanced usage (multiple SSH sessions from a sub-shell)
|
||||
[](https://asciinema.org/a/33240)
|
||||
|
||||
## Using for GitHub SSH authentication (via `trezor-git` utility)
|
||||
[](https://asciinema.org/a/38337)
|
||||
|
||||
## Loading multiple SSH identities from configuration file
|
||||
[](https://asciinema.org/a/bdxxtgctk5syu56yfz8lcp7ny)
|
||||
|
||||
# Public key generation
|
||||
|
||||
Run:
|
||||
|
||||
/tmp $ trezor-agent user@ssh.hostname.com -v > hostname.pub
|
||||
2015-09-02 15:03:18,929 INFO getting "ssh://user@ssh.hostname.com" public key from Trezor...
|
||||
2015-09-02 15:03:23,342 INFO disconnected from Trezor
|
||||
/tmp $ cat hostname.pub
|
||||
ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBGSevcDwmT+QaZPUEWUUjTeZRBICChxMKuJ7dRpBSF8+qt+8S1GBK5Zj8Xicc8SHG/SE/EXKUL2UU3kcUzE7ADQ= ssh://user@ssh.hostname.com
|
||||
|
||||
Append `hostname.pub` contents to `/home/user/.ssh/authorized_keys`
|
||||
configuration file at `ssh.hostname.com`, so the remote server
|
||||
would allow you to login using the corresponding private key signature.
|
||||
|
||||
# Usage
|
||||
|
||||
Run:
|
||||
|
||||
/tmp $ trezor-agent user@ssh.hostname.com -v -c
|
||||
2015-09-02 15:09:39,782 INFO getting "ssh://user@ssh.hostname.com" public key from Trezor...
|
||||
2015-09-02 15:09:44,430 INFO please confirm user "roman" login to "ssh://user@ssh.hostname.com" using Trezor...
|
||||
2015-09-02 15:09:46,152 INFO signature status: OK
|
||||
Linux lmde 3.16.0-4-amd64 #1 SMP Debian 3.16.7-ckt11-1+deb8u3 (2015-08-04) x86_64
|
||||
|
||||
The programs included with the Debian GNU/Linux system are free software;
|
||||
the exact distribution terms for each program are described in the
|
||||
individual files in /usr/share/doc/*/copyright.
|
||||
|
||||
Debian GNU/Linux comes with ABSOLUTELY NO WARRANTY, to the extent
|
||||
permitted by applicable law.
|
||||
Last login: Tue Sep 1 15:57:05 2015 from localhost
|
||||
~ $
|
||||
|
||||
Make sure to confirm SSH signature on the Trezor device when requested.
|
||||
|
||||
## Accessing remote Git/Mercurial repositories
|
||||
|
||||
Use your SSH public key to access your remote repository (e.g. [GitHub](https://help.github.com/articles/adding-a-new-ssh-key-to-your-github-account/)):
|
||||
|
||||
$ trezor-agent -v -e ed25519 git@github.com | xclip
|
||||
|
||||
Use the following Bash alias for convinient Git operations:
|
||||
|
||||
$ alias git_hub='trezor-agent -v -e ed25519 git@github.com -- git'
|
||||
|
||||
Replace `git` with `git_hub` for remote operations:
|
||||
|
||||
$ git_hub push origin master
|
||||
|
||||
The same works for Mercurial (e.g. on [BitBucket](https://confluence.atlassian.com/bitbucket/set-up-ssh-for-mercurial-728138122.html)):
|
||||
|
||||
$ trezor-agent -v -e ed25519 git@bitbucket.org -- hg push
|
||||
|
||||
|
||||
# Troubleshooting
|
||||
|
||||
If SSH connection fails to work, please open an [issue](https://github.com/romanz/trezor-agent/issues)
|
||||
with a verbose log attached (by running `trezor-agent -vv`) .
|
||||
|
||||
## Incompatible SSH options
|
||||
|
||||
Note that your local SSH configuration may ignore `trezor-agent`, if it has `IdentitiesOnly` option set to `yes`.
|
||||
|
||||
IdentitiesOnly
|
||||
Specifies that ssh(1) should only use the authentication identity files configured in
|
||||
the ssh_config files, even if ssh-agent(1) or a PKCS11Provider offers more identities.
|
||||
The argument to this keyword must be “yes” or “no”.
|
||||
This option is intended for situations where ssh-agent offers many different identities.
|
||||
The default is “no”.
|
||||
|
||||
If you are failing to connect, try running:
|
||||
|
||||
$ trezor-agent -vv user@host -- ssh -vv -oIdentitiesOnly=no user@host
|
||||
61
README.md
Normal file
61
README.md
Normal file
@@ -0,0 +1,61 @@
|
||||
# Using TREZOR as a hardware SSH/GPG agent
|
||||
|
||||
[](https://travis-ci.org/romanz/trezor-agent)
|
||||
[](https://pypi.python.org/pypi/trezor_agent/)
|
||||
[](https://pypi.python.org/pypi/trezor_agent/)
|
||||
[](https://pypi.python.org/pypi/trezor_agent/)
|
||||
[](https://pypi.python.org/pypi/trezor_agent/)
|
||||
|
||||
See SatoshiLabs' blog posts about this feature:
|
||||
|
||||
- [TREZOR Firmware 1.3.4 enables SSH login](https://medium.com/@satoshilabs/trezor-firmware-1-3-4-enables-ssh-login-86a622d7e609)
|
||||
- [TREZOR Firmware 1.3.6 — GPG Signing, SSH Login Updates and Advanced Transaction Features for Segwit](https://medium.com/@satoshilabs/trezor-firmware-1-3-6-20a7df6e692)
|
||||
- [TREZOR Firmware 1.4.0 — GPG decryption support](https://www.reddit.com/r/TREZOR/comments/50h8r9/new_trezor_firmware_fidou2f_and_initial_ethereum/d7420q7/)
|
||||
|
||||
## Installation
|
||||
|
||||
First, make sure that the latest [trezorlib](https://pypi.python.org/pypi/trezor) Python package
|
||||
is installed correctly (at least v0.6.6):
|
||||
|
||||
$ apt-get install python-dev libusb-1.0-0-dev libudev-dev
|
||||
$ pip install -U setuptools pip
|
||||
$ pip install Cython trezor
|
||||
|
||||
Make sure that your `udev` rules are configured [correctly](https://doc.satoshilabs.com/trezor-user/settingupchromeonlinux.html#manual-configuration-of-udev-rules).
|
||||
Then, install the latest [trezor_agent](https://pypi.python.org/pypi/trezor_agent) package:
|
||||
|
||||
$ pip install trezor_agent
|
||||
|
||||
Or, directly from the latest source code (if `pip` doesn't work for you):
|
||||
|
||||
$ git clone https://github.com/romanz/trezor-agent && cd trezor-agent
|
||||
$ python setup.py build && python setup.py install
|
||||
|
||||
Finally, verify that you are running the latest [TREZOR firmware](https://wallet.mytrezor.com/data/firmware/releases.json) version (at least v1.4.0):
|
||||
|
||||
$ trezorctl get_features | head
|
||||
vendor: "bitcointrezor.com"
|
||||
major_version: 1
|
||||
minor_version: 4
|
||||
patch_version: 0
|
||||
...
|
||||
|
||||
If you have an error regarding `protobuf` imports (after installing it), please see [this issue](https://github.com/romanz/trezor-agent/issues/28).
|
||||
|
||||
## Usage
|
||||
|
||||
For SSH, see the [following instructions](README-SSH.md) (for Windows support,
|
||||
see [trezor-ssh-agent](https://github.com/martin-lizner/trezor-ssh-agent) project (by Martin Lízner)).
|
||||
|
||||
For GPG, see the [following instructions](README-GPG.md).
|
||||
|
||||
See [here](https://github.com/romanz/python-trezor#pin-entering) for PIN entering instructions.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
If there is an import problem with the installed `protobuf` package,
|
||||
see [this issue](https://github.com/romanz/trezor-agent/issues/28) for fixing it.
|
||||
|
||||
### Gitter
|
||||
|
||||
Questions, suggestions and discussions are welcome: [](https://gitter.im/romanz/trezor-agent)
|
||||
20
README.rst
20
README.rst
@@ -1,20 +0,0 @@
|
||||
Using Trezor as a hardware SSH agent
|
||||
====================================
|
||||
|
||||
Sample usage::
|
||||
|
||||
~/Code/trezor/trezor-agent $ ./trezor_agent -k home
|
||||
ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBKJnIA4lKZ1hR2tNUOFmtc8MwAVR4oe0CP5QzSrviSi4joZSTzHcmazK0800w2aj132EEmf1kzl6Vf7h46iCeD8= home
|
||||
~/Code/trezor/trezor-agent $ ./trezor_agent -k home > ~/.ssh/authorized_keys
|
||||
~/Code/trezor/trezor-agent $ ./trezor_agent -k home ssh localhost
|
||||
Linux lmde 3.16.0-4-amd64 #1 SMP Debian 3.16.7-ckt9-3~deb8u1 (2015-04-24) x86_64
|
||||
|
||||
The programs included with the Debian GNU/Linux system are free software;
|
||||
the exact distribution terms for each program are described in the
|
||||
individual files in /usr/share/doc/*/copyright.
|
||||
|
||||
Debian GNU/Linux comes with ABSOLUTELY NO WARRANTY, to the extent
|
||||
permitted by applicable law.
|
||||
Last login: Sat Jun 6 16:24:12 2015 from localhost
|
||||
$
|
||||
|
||||
34
scripts/gpg-init
Executable file
34
scripts/gpg-init
Executable file
@@ -0,0 +1,34 @@
|
||||
#!/bin/bash
|
||||
set -eu
|
||||
|
||||
gpg2 --version >/dev/null # verify that GnuPG 2 is installed
|
||||
|
||||
USER_ID="${1}"
|
||||
HOMEDIR=~/.gnupg/trezor
|
||||
CURVE=${CURVE:="nist256p1"} # or "ed25519"
|
||||
TIMESTAMP=${TIMESTAMP:=`date +%s`} # key creation timestamp
|
||||
|
||||
# Prepare new GPG home directory for TREZOR-based identity
|
||||
rm -rf "${HOMEDIR}"
|
||||
mkdir -p "${HOMEDIR}"
|
||||
chmod 700 "${HOMEDIR}"
|
||||
|
||||
# Generate new GPG identity and import into GPG keyring
|
||||
trezor-gpg-create -v "${USER_ID}" -t "${TIMESTAMP}" -e "${CURVE}" > "${HOMEDIR}/pubkey.asc"
|
||||
gpg2 --homedir "${HOMEDIR}" --import < "${HOMEDIR}/pubkey.asc"
|
||||
rm -f "${HOMEDIR}/S.gpg-agent" # (otherwise, our agent won't be started automatically)
|
||||
|
||||
# Make new GPG identity with "ultimate" trust (via its fingerprint)
|
||||
FINGERPRINT=$(gpg2 --homedir "${HOMEDIR}" --list-public-keys --with-fingerprint --with-colons | sed -n -E 's/^fpr:::::::::([0-9A-F]+):$/\1/p' | head -n1)
|
||||
echo "${FINGERPRINT}:6" | gpg2 --homedir "${HOMEDIR}" --import-ownertrust
|
||||
|
||||
# Prepare GPG configuration file
|
||||
echo "# TREZOR-based GPG configuration
|
||||
agent-program $(which trezor-gpg-agent)
|
||||
personal-digest-preferences SHA512
|
||||
" | tee "${HOMEDIR}/gpg.conf"
|
||||
|
||||
echo "# TREZOR-based GPG agent emulator
|
||||
log-file ${HOMEDIR}/gpg-agent.log
|
||||
verbosity 2
|
||||
" | tee "${HOMEDIR}/gpg-agent.conf"
|
||||
28
scripts/gpg-shell
Executable file
28
scripts/gpg-shell
Executable file
@@ -0,0 +1,28 @@
|
||||
#!/bin/bash
|
||||
set -eu
|
||||
|
||||
gpg2 --version >/dev/null # verify that GnuPG 2 is installed
|
||||
|
||||
export GNUPGHOME=~/.gnupg/trezor
|
||||
|
||||
CONFIG_PATH="${GNUPGHOME}/gpg-agent.conf"
|
||||
if [ ! -f ${CONFIG_PATH} ]
|
||||
then
|
||||
echo "No configuration found: ${CONFIG_PATH}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Make sure that the device is unlocked before starting the shell
|
||||
trezor-gpg-unlock
|
||||
|
||||
# Make sure TREZOR-based gpg-agent is running
|
||||
gpg-connect-agent --agent-program "$(which trezor-gpg-agent)" </dev/null
|
||||
|
||||
COMMAND=$*
|
||||
if [ -z "${COMMAND}" ]
|
||||
then
|
||||
gpg2 --list-public-keys
|
||||
${SHELL}
|
||||
else
|
||||
${COMMAND}
|
||||
fi
|
||||
28
setup.py
28
setup.py
@@ -2,28 +2,40 @@
|
||||
from setuptools import setup
|
||||
|
||||
setup(
|
||||
name='sshagent',
|
||||
version='0.2',
|
||||
name='trezor_agent',
|
||||
version='0.8.2',
|
||||
description='Using Trezor as hardware SSH agent',
|
||||
author='Roman Zeyde',
|
||||
author_email='roman.zeyde@gmail.com',
|
||||
license='MIT',
|
||||
url='http://github.com/romanz/trezor-agent',
|
||||
packages=['sshagent'],
|
||||
install_requires=['ecdsa', 'trezor'],
|
||||
packages=['trezor_agent', 'trezor_agent.device', 'trezor_agent.gpg'],
|
||||
install_requires=[
|
||||
'ecdsa>=0.13', 'ed25519>=1.4', 'Cython>=0.23.4', 'protobuf>=3.0.0', 'semver>=2.2',
|
||||
'trezor>=0.7.6', 'keepkey>=0.7.3', 'ledgerblue>=0.1.8',
|
||||
'hidapi==0.7.99.post15' # until https://github.com/keepkey/python-keepkey/pull/8 is merged
|
||||
],
|
||||
platforms=['POSIX'],
|
||||
classifiers=[
|
||||
'Development Status :: 3 - Alpha',
|
||||
'Environment :: Console',
|
||||
'Development Status :: 4 - Beta',
|
||||
'Intended Audience :: Developers',
|
||||
'Intended Audience :: Information Technology',
|
||||
'License :: OSI Approved :: MIT License',
|
||||
'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',
|
||||
'Topic :: Software Development :: Libraries :: Python Modules',
|
||||
'Topic :: System :: Networking',
|
||||
'Topic :: Communications',
|
||||
'Topic :: Security',
|
||||
'Topic :: Utilities',
|
||||
],
|
||||
entry_points={'console_scripts': [
|
||||
'trezor_agent = sshagent.trezor_agent:main'
|
||||
'trezor-agent = trezor_agent.__main__:run_agent',
|
||||
'trezor-gpg-create = trezor_agent.gpg.__main__:main_create',
|
||||
'trezor-gpg-agent = trezor_agent.gpg.__main__:main_agent',
|
||||
'trezor-gpg-unlock = trezor_agent.gpg.__main__:auto_unlock',
|
||||
]},
|
||||
)
|
||||
|
||||
@@ -1,80 +0,0 @@
|
||||
import io
|
||||
import hashlib
|
||||
import base64
|
||||
import ecdsa
|
||||
|
||||
from . import util
|
||||
|
||||
import logging
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
DER_OCTET_STRING = b'\x04'
|
||||
|
||||
curve = ecdsa.NIST256p
|
||||
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):
|
||||
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)
|
||||
_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 parse_public_key(data):
|
||||
file_type, base64blob, name = data.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
|
||||
|
||||
|
||||
def decompress_pubkey(pub):
|
||||
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))
|
||||
y = (P-beta) if ((beta + ord(pub[0])) % 2) else beta
|
||||
return (x, y)
|
||||
|
||||
|
||||
def export_public_key(pubkey, label):
|
||||
x, y = decompress_pubkey(pubkey)
|
||||
point = ecdsa.ellipticcurve.Point(curve.curve, x, y)
|
||||
vk = ecdsa.VerifyingKey.from_public_point(point, curve=curve,
|
||||
hashfunc=hashfunc)
|
||||
key_type = 'ecdsa-sha2-nistp256'
|
||||
curve_name = 'nistp256'
|
||||
parts = [key_type, curve_name, '\x04' + vk.to_string()]
|
||||
b64 = base64.b64encode(''.join([util.frame(p) for p in parts]))
|
||||
return '{} {} {}\n'.format(key_type, b64, label)
|
||||
@@ -1,99 +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 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)
|
||||
|
||||
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 ValueError('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)
|
||||
|
||||
success = key['verifying_key'].verify(signature=signature, data=blob,
|
||||
sigdecode=lambda sig, _: sig)
|
||||
log.info('signature status: %s', 'OK' if success else 'ERROR')
|
||||
if not success:
|
||||
raise ValueError('invalid 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,98 +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__)
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def unix_domain_socket_server(sock_path):
|
||||
log.debug('serving on SSH_AUTH_SOCK=%s', sock_path)
|
||||
try:
|
||||
os.remove(sock_path)
|
||||
except OSError:
|
||||
if os.path.exists(sock_path):
|
||||
raise
|
||||
|
||||
server = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||
server.bind(sock_path)
|
||||
server.listen(1)
|
||||
try:
|
||||
yield server
|
||||
finally:
|
||||
os.remove(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 error: %s', e, exc_info=True)
|
||||
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(key_files, signer, sock_path=None):
|
||||
if sock_path is None:
|
||||
sock_path = tempfile.mktemp(prefix='ssh-agent-')
|
||||
|
||||
keys = [formats.parse_public_key(k) for k in key_files]
|
||||
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):
|
||||
log.debug('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
|
||||
@@ -1,105 +0,0 @@
|
||||
import io
|
||||
import binascii
|
||||
|
||||
from . import util
|
||||
from . import formats
|
||||
|
||||
import logging
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class TrezorLibrary(object):
|
||||
|
||||
@staticmethod
|
||||
def client():
|
||||
# pylint: disable=import-error
|
||||
from trezorlib.client import TrezorClient
|
||||
from trezorlib.transport_hid import HidTransport
|
||||
devices = HidTransport.enumerate()
|
||||
if len(devices) != 1:
|
||||
raise ValueError('{:d} Trezor devices found'.format(len(devices)))
|
||||
return TrezorClient(HidTransport(devices[0]))
|
||||
|
||||
@staticmethod
|
||||
def identity(label, proto='ssh'):
|
||||
# pylint: disable=import-error
|
||||
from trezorlib.types_pb2 import IdentityType
|
||||
return IdentityType(host=label, proto=proto)
|
||||
|
||||
|
||||
class Client(object):
|
||||
|
||||
curve_name = 'nist256p1'
|
||||
|
||||
def __init__(self, factory=TrezorLibrary):
|
||||
self.factory = factory
|
||||
self.client = self.factory.client()
|
||||
f = self.client.features
|
||||
log.info('connected to Trezor')
|
||||
log.debug('ID : %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]
|
||||
log.debug('version : %s', '.'.join([str(v) for v in version]))
|
||||
log.debug('revision : %s', binascii.hexlify(f.revision))
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *args):
|
||||
log.info('disconnected from Trezor')
|
||||
self.client.clear_session()
|
||||
self.client.close()
|
||||
|
||||
def get_public_key(self, label):
|
||||
addr = _get_address(self.factory.identity(label))
|
||||
log.info('getting %r SSH public key from Trezor...', label)
|
||||
node = self.client.get_public_node(addr, self.curve_name)
|
||||
return node.node.public_key
|
||||
|
||||
def sign_ssh_challenge(self, label, blob):
|
||||
ident = self.factory.identity(label)
|
||||
msg = _parse_ssh_blob(blob)
|
||||
request = 'user: "{user}"'.format(**msg)
|
||||
|
||||
log.info('confirm %s connection to %r using Trezor...',
|
||||
request, label)
|
||||
s = self.client.sign_identity(identity=ident,
|
||||
challenge_hidden=blob,
|
||||
challenge_visual=request,
|
||||
ecdsa_curve_name=self.curve_name)
|
||||
assert len(s.signature) == 65
|
||||
assert s.signature[0] == b'\x00'
|
||||
|
||||
sig = s.signature[1:]
|
||||
r = util.bytes2num(sig[:32])
|
||||
s = util.bytes2num(sig[32:])
|
||||
return (r, s)
|
||||
|
||||
|
||||
def _get_address(ident):
|
||||
index = '\x00' * 4
|
||||
addr = index + '{}://{}'.format(ident.proto, ident.host)
|
||||
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)
|
||||
res['pubkey'] = util.read_frame(i)
|
||||
log.debug('%s: user %r via %r (%r)',
|
||||
res['conn'], res['user'], res['auth'], res['key_type'])
|
||||
return res
|
||||
@@ -1,45 +0,0 @@
|
||||
import sys
|
||||
import argparse
|
||||
|
||||
from . import trezor
|
||||
from . import server
|
||||
from . import formats
|
||||
|
||||
import logging
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def main():
|
||||
fmt = '%(asctime)s %(levelname)-12s %(message)-100s [%(filename)s]'
|
||||
p = argparse.ArgumentParser()
|
||||
p.add_argument('-k', '--key-label',
|
||||
metavar='LABEL', dest='labels', action='append', default=[])
|
||||
p.add_argument('-v', '--verbose', action='count', default=0)
|
||||
p.add_argument('command', type=str, nargs='*')
|
||||
args = p.parse_args()
|
||||
|
||||
verbosity = [logging.WARNING, logging.INFO, logging.DEBUG]
|
||||
level = verbosity[min(args.verbose, len(verbosity) - 1)]
|
||||
logging.basicConfig(level=level, format=fmt)
|
||||
|
||||
with trezor.Client(factory=trezor.TrezorLibrary) as client:
|
||||
key_files = []
|
||||
for label in args.labels:
|
||||
pubkey = client.get_public_key(label=label)
|
||||
key_file = formats.export_public_key(pubkey=pubkey, label=label)
|
||||
key_files.append(key_file)
|
||||
|
||||
if not args.command:
|
||||
sys.stdout.write(''.join(key_files))
|
||||
return
|
||||
|
||||
signer = client.sign_ssh_challenge
|
||||
|
||||
try:
|
||||
with server.serve(key_files=key_files, signer=signer) as env:
|
||||
return server.run_process(command=args.command, environ=env)
|
||||
except KeyboardInterrupt:
|
||||
log.info('server stopped')
|
||||
|
||||
if __name__ == '__main__':
|
||||
sys.exit(main())
|
||||
@@ -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
|
||||
20
tox.ini
Normal file
20
tox.ini
Normal file
@@ -0,0 +1,20 @@
|
||||
[tox]
|
||||
envlist = py27,py3
|
||||
[pep8]
|
||||
max-line-length = 100
|
||||
[testenv]
|
||||
deps=
|
||||
pytest
|
||||
mock
|
||||
pep8
|
||||
coverage
|
||||
pylint
|
||||
semver
|
||||
pydocstyle
|
||||
commands=
|
||||
pep8 trezor_agent
|
||||
pylint --reports=no --rcfile .pylintrc trezor_agent
|
||||
pydocstyle trezor_agent
|
||||
coverage run --omit='trezor_agent/__main__.py' --source trezor_agent -m py.test -v trezor_agent
|
||||
coverage report
|
||||
coverage html
|
||||
@@ -1,2 +0,0 @@
|
||||
#!/bin/bash
|
||||
python -m sshagent.trezor_agent $*
|
||||
1
trezor_agent/__init__.py
Normal file
1
trezor_agent/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""SSH-agent implementation using hardware authentication devices."""
|
||||
183
trezor_agent/__main__.py
Normal file
183
trezor_agent/__main__.py
Normal file
@@ -0,0 +1,183 @@
|
||||
"""SSH-agent implementation using hardware authentication devices."""
|
||||
import argparse
|
||||
import functools
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
from . import client, device, formats, protocol, server, util
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def ssh_args(label):
|
||||
"""Create SSH command for connecting specified server."""
|
||||
identity = device.interface.string_to_identity(label)
|
||||
|
||||
args = []
|
||||
if 'port' in identity:
|
||||
args += ['-p', identity['port']]
|
||||
if 'user' in identity:
|
||||
args += ['-l', identity['user']]
|
||||
|
||||
return args + [identity['host']]
|
||||
|
||||
|
||||
def mosh_args(label):
|
||||
"""Create SSH command for connecting specified server."""
|
||||
identity = device.interface.string_to_identity(label)
|
||||
|
||||
args = []
|
||||
if 'port' in identity:
|
||||
args += ['-p', identity['port']]
|
||||
if 'user' in identity:
|
||||
args += [identity['user']+'@'+identity['host']]
|
||||
else:
|
||||
args += [identity['host']]
|
||||
|
||||
return args
|
||||
|
||||
|
||||
def create_parser():
|
||||
"""Create argparse.ArgumentParser for this tool."""
|
||||
p = argparse.ArgumentParser()
|
||||
p.add_argument('-v', '--verbose', default=0, action='count')
|
||||
|
||||
curve_names = [name for name in formats.SUPPORTED_CURVES]
|
||||
curve_names = ', '.join(sorted(curve_names))
|
||||
p.add_argument('-e', '--ecdsa-curve-name', metavar='CURVE',
|
||||
default=formats.CURVE_NIST256,
|
||||
help='specify ECDSA curve name: ' + curve_names)
|
||||
p.add_argument('--timeout',
|
||||
default=server.UNIX_SOCKET_TIMEOUT, type=float,
|
||||
help='Timeout for accepting SSH client connections')
|
||||
p.add_argument('--debug', default=False, action='store_true',
|
||||
help='Log SSH protocol messages for debugging.')
|
||||
return p
|
||||
|
||||
|
||||
def create_agent_parser():
|
||||
"""Specific parser for SSH connection."""
|
||||
p = create_parser()
|
||||
|
||||
g = p.add_mutually_exclusive_group()
|
||||
g.add_argument('-s', '--shell', default=False, action='store_true',
|
||||
help='run ${SHELL} as subprocess under SSH agent')
|
||||
g.add_argument('-c', '--connect', default=False, action='store_true',
|
||||
help='connect to specified host via SSH')
|
||||
g.add_argument('--mosh', default=False, action='store_true',
|
||||
help='connect to specified host via using Mosh')
|
||||
|
||||
p.add_argument('identity', type=str, default=None,
|
||||
help='proto://[user@]host[:port][/path]')
|
||||
p.add_argument('command', type=str, nargs='*', metavar='ARGUMENT',
|
||||
help='command to run under the SSH agent')
|
||||
return p
|
||||
|
||||
|
||||
def create_git_parser():
|
||||
"""Specific parser for git commands."""
|
||||
p = create_parser()
|
||||
|
||||
p.add_argument('-r', '--remote', default='origin',
|
||||
help='use this git remote URL to generate SSH identity')
|
||||
p.add_argument('-t', '--test', action='store_true',
|
||||
help='test connection using `ssh -T user@host` command')
|
||||
p.add_argument('command', type=str, nargs='*', metavar='ARGUMENT',
|
||||
help='Git command to run under the SSH agent')
|
||||
return p
|
||||
|
||||
|
||||
def git_host(remote_name, attributes):
|
||||
"""Extract git SSH host for specified remote name."""
|
||||
try:
|
||||
output = subprocess.check_output('git config --local --list'.split())
|
||||
except subprocess.CalledProcessError:
|
||||
return
|
||||
|
||||
for attribute in attributes:
|
||||
name = r'remote.{0}.{1}'.format(remote_name, attribute)
|
||||
matches = re.findall(re.escape(name) + '=(.*)', output)
|
||||
log.debug('%r: %r', name, matches)
|
||||
if not matches:
|
||||
continue
|
||||
|
||||
url = matches[0].strip()
|
||||
match = re.match('(?P<user>.*?)@(?P<host>.*?):(?P<path>.*)', url)
|
||||
if match:
|
||||
return '{user}@{host}'.format(**match.groupdict())
|
||||
|
||||
|
||||
def run_server(conn, public_keys, command, debug, timeout):
|
||||
"""Common code for run_agent and run_git below."""
|
||||
try:
|
||||
signer = conn.sign_ssh_challenge
|
||||
handler = protocol.Handler(keys=public_keys, signer=signer,
|
||||
debug=debug)
|
||||
with server.serve(handler=handler, timeout=timeout) as env:
|
||||
return server.run_process(command=command, environ=env)
|
||||
except KeyboardInterrupt:
|
||||
log.info('server stopped')
|
||||
|
||||
|
||||
def handle_connection_error(func):
|
||||
"""Fail with non-zero exit code."""
|
||||
@functools.wraps(func)
|
||||
def wrapper(*args, **kwargs):
|
||||
try:
|
||||
return func(*args, **kwargs)
|
||||
except IOError as e:
|
||||
log.error('Connection error: %s', e)
|
||||
return 1
|
||||
return wrapper
|
||||
|
||||
|
||||
def parse_config(fname):
|
||||
"""Parse config file into a list of Identity objects."""
|
||||
contents = open(fname).read()
|
||||
for identity_str, curve_name in re.findall(r'\<(.*?)\|(.*?)\>', contents):
|
||||
yield device.interface.Identity(identity_str=identity_str,
|
||||
curve_name=curve_name)
|
||||
|
||||
|
||||
@handle_connection_error
|
||||
def run_agent(client_factory=client.Client):
|
||||
"""Run ssh-agent using given hardware client factory."""
|
||||
args = create_agent_parser().parse_args()
|
||||
util.setup_logging(verbosity=args.verbose)
|
||||
|
||||
conn = client_factory(device=device.detect())
|
||||
if args.identity.startswith('/'):
|
||||
identities = list(parse_config(fname=args.identity))
|
||||
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'] = 'ssh'
|
||||
log.info('identity #%d: %s', index, identity)
|
||||
|
||||
public_keys = [conn.get_public_key(i) for i in identities]
|
||||
|
||||
if args.connect:
|
||||
command = ['ssh'] + ssh_args(args.identity) + args.command
|
||||
elif args.mosh:
|
||||
command = ['mosh'] + mosh_args(args.identity) + args.command
|
||||
else:
|
||||
command = args.command
|
||||
|
||||
use_shell = bool(args.shell)
|
||||
if use_shell:
|
||||
command = os.environ['SHELL']
|
||||
|
||||
if not command:
|
||||
for pk in public_keys:
|
||||
sys.stdout.write(pk)
|
||||
return
|
||||
|
||||
public_keys = [formats.import_public_key(pk) for pk in public_keys]
|
||||
for pk, identity in zip(public_keys, identities):
|
||||
pk['identity'] = identity
|
||||
return run_server(conn=conn, public_keys=public_keys, command=command,
|
||||
debug=args.debug, timeout=args.timeout)
|
||||
62
trezor_agent/client.py
Normal file
62
trezor_agent/client.py
Normal file
@@ -0,0 +1,62 @@
|
||||
"""
|
||||
Connection to hardware authentication device.
|
||||
|
||||
It is used for getting SSH public keys and ECDSA signing of server requests.
|
||||
"""
|
||||
import io
|
||||
import logging
|
||||
|
||||
from . import formats, util
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Client(object):
|
||||
"""Client wrapper for SSH authentication device."""
|
||||
|
||||
def __init__(self, device):
|
||||
"""Connect to hardware device."""
|
||||
self.device = device
|
||||
|
||||
def get_public_key(self, identity):
|
||||
"""Get SSH public key from the device."""
|
||||
with self.device:
|
||||
pubkey = self.device.pubkey(identity)
|
||||
|
||||
vk = formats.decompress_pubkey(pubkey=pubkey,
|
||||
curve_name=identity.curve_name)
|
||||
return formats.export_public_key(vk=vk,
|
||||
label=str(identity))
|
||||
|
||||
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,
|
||||
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
|
||||
27
trezor_agent/device/__init__.py
Normal file
27
trezor_agent/device/__init__.py
Normal file
@@ -0,0 +1,27 @@
|
||||
"""Cryptographic hardware device management."""
|
||||
|
||||
import logging
|
||||
|
||||
from . import trezor
|
||||
from . import keepkey
|
||||
from . import ledger
|
||||
from . import interface
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
DEVICE_TYPES = [
|
||||
trezor.Trezor,
|
||||
keepkey.KeepKey,
|
||||
ledger.LedgerNanoS,
|
||||
]
|
||||
|
||||
|
||||
def detect():
|
||||
"""Detect the first available device and return it to the user."""
|
||||
for device_type in DEVICE_TYPES:
|
||||
try:
|
||||
with device_type() as d:
|
||||
return d
|
||||
except interface.NotFoundError as e:
|
||||
log.debug('device not found: %s', e)
|
||||
raise IOError('No device found!')
|
||||
135
trezor_agent/device/interface.py
Normal file
135
trezor_agent/device/interface.py
Normal file
@@ -0,0 +1,135 @@
|
||||
"""Device abstraction layer."""
|
||||
|
||||
import hashlib
|
||||
import io
|
||||
import logging
|
||||
import re
|
||||
import struct
|
||||
|
||||
from .. import formats, util
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
_identity_regexp = re.compile(''.join([
|
||||
'^'
|
||||
r'(?:(?P<proto>.*)://)?',
|
||||
r'(?:(?P<user>.*)@)?',
|
||||
r'(?P<host>.*?)',
|
||||
r'(?::(?P<port>\w*))?',
|
||||
r'(?P<path>/.*)?',
|
||||
'$'
|
||||
]))
|
||||
|
||||
|
||||
def string_to_identity(identity_str):
|
||||
"""Parse string into Identity dictionary."""
|
||||
m = _identity_regexp.match(identity_str)
|
||||
result = m.groupdict()
|
||||
log.debug('parsed identity: %s', result)
|
||||
return {k: v for k, v in result.items() if v}
|
||||
|
||||
|
||||
def identity_to_string(identity_dict):
|
||||
"""Dump Identity dictionary into its string representation."""
|
||||
result = []
|
||||
if identity_dict.get('proto'):
|
||||
result.append(identity_dict['proto'] + '://')
|
||||
if identity_dict.get('user'):
|
||||
result.append(identity_dict['user'] + '@')
|
||||
result.append(identity_dict['host'])
|
||||
if identity_dict.get('port'):
|
||||
result.append(':' + identity_dict['port'])
|
||||
if identity_dict.get('path'):
|
||||
result.append(identity_dict['path'])
|
||||
log.debug('identity parts: %s', result)
|
||||
return ''.join(result)
|
||||
|
||||
|
||||
class Error(Exception):
|
||||
"""Device-related error."""
|
||||
|
||||
|
||||
class NotFoundError(Error):
|
||||
"""Device could not be found."""
|
||||
|
||||
|
||||
class DeviceError(Error):
|
||||
""""Error during device operation."""
|
||||
|
||||
|
||||
class Identity(object):
|
||||
"""Represent SLIP-0013 identity, together with a elliptic curve choice."""
|
||||
|
||||
def __init__(self, identity_str, curve_name):
|
||||
"""Configure for specific identity and elliptic curve usage."""
|
||||
self.identity_dict = string_to_identity(identity_str)
|
||||
self.curve_name = curve_name
|
||||
|
||||
def items(self):
|
||||
"""Return a copy of identity_dict items."""
|
||||
return self.identity_dict.items()
|
||||
|
||||
def __str__(self):
|
||||
"""Return identity serialized to string."""
|
||||
return '<{}|{}>'.format(identity_to_string(self.identity_dict), self.curve_name)
|
||||
|
||||
def get_bip32_address(self, ecdh=False):
|
||||
"""Compute BIP32 derivation address according to SLIP-0013/0017."""
|
||||
index = struct.pack('<L', self.identity_dict.get('index', 0))
|
||||
addr = index + identity_to_string(self.identity_dict).encode('ascii')
|
||||
log.debug('bip32 address string: %r', addr)
|
||||
digest = hashlib.sha256(addr).digest()
|
||||
s = io.BytesIO(bytearray(digest))
|
||||
|
||||
hardened = 0x80000000
|
||||
addr_0 = [13, 17][bool(ecdh)]
|
||||
address_n = [addr_0] + list(util.recv(s, '<LLLL'))
|
||||
return [(hardened | value) for value in address_n]
|
||||
|
||||
def get_curve_name(self, ecdh=False):
|
||||
"""Return correct curve name for device operations."""
|
||||
if ecdh:
|
||||
return formats.get_ecdh_curve_name(self.curve_name)
|
||||
else:
|
||||
return self.curve_name
|
||||
|
||||
|
||||
class Device(object):
|
||||
"""Abstract cryptographic hardware device interface."""
|
||||
|
||||
def __init__(self):
|
||||
"""C-tor."""
|
||||
self.conn = None
|
||||
|
||||
def connect(self):
|
||||
"""Connect to device, otherwise raise NotFoundError."""
|
||||
raise NotImplementedError()
|
||||
|
||||
def __enter__(self):
|
||||
"""Allow usage as context manager."""
|
||||
self.conn = self.connect()
|
||||
return self
|
||||
|
||||
def __exit__(self, *args):
|
||||
"""Close and mark as disconnected."""
|
||||
try:
|
||||
self.conn.close()
|
||||
except Exception as e: # pylint: disable=broad-except
|
||||
log.exception('close failed: %s', e)
|
||||
self.conn = None
|
||||
|
||||
def pubkey(self, identity, ecdh=False):
|
||||
"""Get public key (as bytes)."""
|
||||
raise NotImplementedError()
|
||||
|
||||
def sign(self, identity, blob):
|
||||
"""Sign given blob and return the signature (as bytes)."""
|
||||
raise NotImplementedError()
|
||||
|
||||
def ecdh(self, identity, pubkey):
|
||||
"""Get shared session key using Elliptic Curve Diffie-Hellman."""
|
||||
raise NotImplementedError()
|
||||
|
||||
def __str__(self):
|
||||
"""Human-readable representation."""
|
||||
return '{}'.format(self.__class__.__name__)
|
||||
37
trezor_agent/device/keepkey.py
Normal file
37
trezor_agent/device/keepkey.py
Normal file
@@ -0,0 +1,37 @@
|
||||
"""KeepKey-related code (see https://www.keepkey.com/)."""
|
||||
|
||||
from . import trezor
|
||||
from .. import formats
|
||||
|
||||
|
||||
def _verify_support(identity, ecdh):
|
||||
"""Make sure the device supports given configuration."""
|
||||
protocol = identity.identity_dict['proto']
|
||||
if protocol not in {'ssh'}:
|
||||
raise NotImplementedError(
|
||||
'Unsupported protocol: {}'.format(protocol))
|
||||
if ecdh:
|
||||
raise NotImplementedError('No support for ECDH')
|
||||
if identity.curve_name not in {formats.CURVE_NIST256}:
|
||||
raise NotImplementedError(
|
||||
'Unsupported elliptic curve: {}'.format(identity.curve_name))
|
||||
|
||||
|
||||
class KeepKey(trezor.Trezor):
|
||||
"""Connection to KeepKey device."""
|
||||
|
||||
@property
|
||||
def _defs(self):
|
||||
from . import keepkey_defs
|
||||
return keepkey_defs
|
||||
|
||||
required_version = '>=1.0.4'
|
||||
|
||||
def pubkey(self, identity, ecdh=False):
|
||||
"""Return public key."""
|
||||
_verify_support(identity, ecdh)
|
||||
return trezor.Trezor.pubkey(self, identity=identity, ecdh=ecdh)
|
||||
|
||||
def ecdh(self, identity, pubkey):
|
||||
"""No support for ECDH in KeepKey firmware."""
|
||||
_verify_support(identity, ecdh=True)
|
||||
8
trezor_agent/device/keepkey_defs.py
Normal file
8
trezor_agent/device/keepkey_defs.py
Normal file
@@ -0,0 +1,8 @@
|
||||
"""KeepKey-related definitions."""
|
||||
|
||||
# pylint: disable=unused-import
|
||||
from keepkeylib.client import KeepKeyClient as Client
|
||||
from keepkeylib.client import CallException
|
||||
from keepkeylib.transport_hid import HidTransport
|
||||
from keepkeylib.messages_pb2 import PassphraseAck
|
||||
from keepkeylib.types_pb2 import IdentityType
|
||||
111
trezor_agent/device/ledger.py
Normal file
111
trezor_agent/device/ledger.py
Normal file
@@ -0,0 +1,111 @@
|
||||
"""Ledger-related code (see https://www.ledgerwallet.com/)."""
|
||||
|
||||
import binascii
|
||||
import logging
|
||||
import struct
|
||||
|
||||
from ledgerblue import comm
|
||||
|
||||
from . import interface
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _expand_path(path):
|
||||
"""Convert BIP32 path into bytes."""
|
||||
return b''.join((struct.pack('>I', e) for e in path))
|
||||
|
||||
|
||||
def _convert_public_key(ecdsa_curve_name, result):
|
||||
"""Convert Ledger reply into PublicKey object."""
|
||||
if ecdsa_curve_name == 'nist256p1':
|
||||
if (result[64] & 1) != 0:
|
||||
result = bytearray([0x03]) + result[1:33]
|
||||
else:
|
||||
result = bytearray([0x02]) + result[1:33]
|
||||
else:
|
||||
result = result[1:]
|
||||
keyX = bytearray(result[0:32])
|
||||
keyY = bytearray(result[32:][::-1])
|
||||
if (keyX[31] & 1) != 0:
|
||||
keyY[31] |= 0x80
|
||||
result = b'\x00' + bytes(keyY)
|
||||
return bytes(result)
|
||||
|
||||
|
||||
class LedgerNanoS(interface.Device):
|
||||
"""Connection to Ledger Nano S device."""
|
||||
|
||||
def connect(self):
|
||||
"""Enumerate and connect to the first USB HID interface."""
|
||||
try:
|
||||
return comm.getDongle()
|
||||
except comm.CommException as e:
|
||||
raise interface.NotFoundError(
|
||||
'{} not connected: "{}"'.format(self, e))
|
||||
|
||||
def pubkey(self, identity, ecdh=False):
|
||||
"""Get PublicKey object for specified BIP32 address and elliptic curve."""
|
||||
curve_name = identity.get_curve_name(ecdh)
|
||||
path = _expand_path(identity.get_bip32_address(ecdh))
|
||||
if curve_name == 'nist256p1':
|
||||
p2 = '01'
|
||||
else:
|
||||
p2 = '02'
|
||||
apdu = '800200' + p2
|
||||
apdu = binascii.unhexlify(apdu)
|
||||
apdu += bytearray([len(path) + 1, len(path) // 4])
|
||||
apdu += path
|
||||
result = bytearray(self.conn.exchange(bytes(apdu)))[1:]
|
||||
return _convert_public_key(curve_name, result)
|
||||
|
||||
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
|
||||
result = bytearray(self.conn.exchange(bytes(apdu)))
|
||||
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
|
||||
result = bytearray(self.conn.exchange(bytes(apdu)))
|
||||
assert result[0] == 0x04
|
||||
return bytes(result)
|
||||
108
trezor_agent/device/trezor.py
Normal file
108
trezor_agent/device/trezor.py
Normal file
@@ -0,0 +1,108 @@
|
||||
"""TREZOR-related code (see http://bitcointrezor.com/)."""
|
||||
|
||||
import binascii
|
||||
import logging
|
||||
import semver
|
||||
|
||||
from . import interface
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Trezor(interface.Device):
|
||||
"""Connection to TREZOR device."""
|
||||
|
||||
@property
|
||||
def _defs(self):
|
||||
from . import trezor_defs
|
||||
return trezor_defs
|
||||
|
||||
required_version = '>=1.4.0'
|
||||
|
||||
def connect(self):
|
||||
"""Enumerate and connect to the first USB HID interface."""
|
||||
def empty_passphrase_handler(_):
|
||||
return self._defs.PassphraseAck(passphrase='')
|
||||
|
||||
for d in self._defs.HidTransport.enumerate():
|
||||
log.debug('endpoint: %s', d)
|
||||
transport = self._defs.HidTransport(d)
|
||||
connection = self._defs.Client(transport)
|
||||
connection.callback_PassphraseRequest = empty_passphrase_handler
|
||||
f = connection.features
|
||||
log.debug('connected to %s %s', self, f.device_id)
|
||||
log.debug('label : %s', f.label)
|
||||
log.debug('vendor : %s', f.vendor)
|
||||
current_version = '{}.{}.{}'.format(f.major_version,
|
||||
f.minor_version,
|
||||
f.patch_version)
|
||||
log.debug('version : %s', current_version)
|
||||
log.debug('revision : %s', binascii.hexlify(f.revision))
|
||||
if not semver.match(current_version, self.required_version):
|
||||
fmt = ('Please upgrade your {} firmware to {} version'
|
||||
' (current: {})')
|
||||
raise ValueError(fmt.format(self, self.required_version,
|
||||
current_version))
|
||||
connection.ping(msg='', pin_protection=True) # unlock PIN
|
||||
return connection
|
||||
raise interface.NotFoundError('{} not connected'.format(self))
|
||||
|
||||
def close(self):
|
||||
"""Close connection."""
|
||||
self.conn.close()
|
||||
|
||||
def pubkey(self, identity, ecdh=False):
|
||||
"""Return public key."""
|
||||
curve_name = identity.get_curve_name(ecdh=ecdh)
|
||||
log.debug('"%s" getting public key (%s) from %s',
|
||||
identity, curve_name, self)
|
||||
addr = identity.get_bip32_address(ecdh=ecdh)
|
||||
result = self.conn.get_public_node(n=addr,
|
||||
ecdsa_curve_name=curve_name)
|
||||
log.debug('result: %s', result)
|
||||
return result.node.public_key
|
||||
|
||||
def _identity_proto(self, identity):
|
||||
result = self._defs.IdentityType()
|
||||
for name, value in identity.items():
|
||||
setattr(result, name, value)
|
||||
return result
|
||||
|
||||
def sign(self, identity, blob):
|
||||
"""Sign given blob and return the signature (as bytes)."""
|
||||
curve_name = identity.get_curve_name(ecdh=False)
|
||||
log.debug('"%s" signing %r (%s) on %s',
|
||||
identity, blob, curve_name, self)
|
||||
try:
|
||||
result = self.conn.sign_identity(
|
||||
identity=self._identity_proto(identity),
|
||||
challenge_hidden=blob,
|
||||
challenge_visual='',
|
||||
ecdsa_curve_name=curve_name)
|
||||
log.debug('result: %s', result)
|
||||
assert len(result.signature) == 65
|
||||
assert result.signature[:1] == b'\x00'
|
||||
return result.signature[1:]
|
||||
except self._defs.CallException as e:
|
||||
msg = '{} error: {}'.format(self, e)
|
||||
log.debug(msg, exc_info=True)
|
||||
raise interface.DeviceError(msg)
|
||||
|
||||
def ecdh(self, identity, pubkey):
|
||||
"""Get shared session key using Elliptic Curve Diffie-Hellman."""
|
||||
curve_name = identity.get_curve_name(ecdh=True)
|
||||
log.debug('"%s" shared session key (%s) for %r from %s',
|
||||
identity, curve_name, pubkey, self)
|
||||
try:
|
||||
result = self.conn.get_ecdh_session_key(
|
||||
identity=self._identity_proto(identity),
|
||||
peer_public_key=pubkey,
|
||||
ecdsa_curve_name=curve_name)
|
||||
log.debug('result: %s', result)
|
||||
assert len(result.session_key) in {65, 33} # NIST256 or Curve25519
|
||||
assert result.session_key[:1] == b'\x04'
|
||||
return result.session_key
|
||||
except self._defs.CallException as e:
|
||||
msg = '{} error: {}'.format(self, e)
|
||||
log.debug(msg, exc_info=True)
|
||||
raise interface.DeviceError(msg)
|
||||
8
trezor_agent/device/trezor_defs.py
Normal file
8
trezor_agent/device/trezor_defs.py
Normal file
@@ -0,0 +1,8 @@
|
||||
"""TREZOR-related definitions."""
|
||||
|
||||
# pylint: disable=unused-import
|
||||
from trezorlib.client import TrezorClient as Client
|
||||
from trezorlib.client import CallException
|
||||
from trezorlib.transport_hid import HidTransport
|
||||
from trezorlib.messages_pb2 import PassphraseAck
|
||||
from trezorlib.types_pb2 import IdentityType
|
||||
208
trezor_agent/formats.py
Normal file
208
trezor_agent/formats.py
Normal file
@@ -0,0 +1,208 @@
|
||||
"""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:])
|
||||
|
||||
|
||||
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)
|
||||
|
||||
|
||||
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 '{} {} {}\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('ascii')
|
||||
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]
|
||||
9
trezor_agent/gpg/__init__.py
Normal file
9
trezor_agent/gpg/__init__.py
Normal file
@@ -0,0 +1,9 @@
|
||||
"""
|
||||
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
|
||||
"""
|
||||
126
trezor_agent/gpg/__main__.py
Executable file
126
trezor_agent/gpg/__main__.py
Executable file
@@ -0,0 +1,126 @@
|
||||
#!/usr/bin/env python
|
||||
"""Create signatures and export public keys for GPG using TREZOR."""
|
||||
import argparse
|
||||
import contextlib
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
|
||||
import semver
|
||||
|
||||
from . import agent, client, encode, keyring, protocol
|
||||
from .. import device, formats, server, util
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def run_create(args):
|
||||
"""Generate a new pubkey for a new/existing GPG identity."""
|
||||
log.warning('NOTE: in order to re-generate the exact same GPG key later, '
|
||||
'run this command with "--time=%d" commandline flag (to set '
|
||||
'the timestamp of the GPG key manually).', args.time)
|
||||
d = client.Client(user_id=args.user_id, curve_name=args.ecdsa_curve)
|
||||
verifying_key = d.pubkey(ecdh=False)
|
||||
decryption_key = d.pubkey(ecdh=True)
|
||||
|
||||
if 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=d.sign)
|
||||
result = encode.create_subkey(primary_bytes=result,
|
||||
subkey=encryption_key,
|
||||
signer_func=d.sign)
|
||||
else: # add as primary
|
||||
log.info('creating new %s GPG primary key for "%s"',
|
||||
args.ecdsa_curve, args.user_id)
|
||||
# primary key for signing
|
||||
primary = protocol.PublicKey(
|
||||
curve_name=args.ecdsa_curve, created=args.time,
|
||||
verifying_key=verifying_key, ecdh=False)
|
||||
# subkey for encryption
|
||||
subkey = protocol.PublicKey(
|
||||
curve_name=formats.get_ecdh_curve_name(args.ecdsa_curve),
|
||||
created=args.time, verifying_key=decryption_key, ecdh=True)
|
||||
|
||||
result = encode.create_primary(user_id=args.user_id,
|
||||
pubkey=primary,
|
||||
signer_func=d.sign)
|
||||
result = encode.create_subkey(primary_bytes=result,
|
||||
subkey=subkey,
|
||||
signer_func=d.sign)
|
||||
|
||||
sys.stdout.write(protocol.armor(result, 'PUBLIC KEY BLOCK'))
|
||||
|
||||
|
||||
def main_create():
|
||||
"""Main function for GPG identity creation."""
|
||||
p = argparse.ArgumentParser()
|
||||
p.add_argument('user_id')
|
||||
p.add_argument('-e', '--ecdsa-curve', default='nist256p1')
|
||||
p.add_argument('-t', '--time', type=int, default=int(time.time()))
|
||||
p.add_argument('-v', '--verbose', default=0, action='count')
|
||||
p.add_argument('-s', '--subkey', default=False, action='store_true')
|
||||
|
||||
args = p.parse_args()
|
||||
util.setup_logging(verbosity=args.verbose)
|
||||
log.warning('This GPG tool is still in EXPERIMENTAL mode, '
|
||||
'so please note that the API and features may '
|
||||
'change without backwards compatibility!')
|
||||
|
||||
existing_gpg = keyring.gpg_version().decode('ascii')
|
||||
required_gpg = '>=2.1.11'
|
||||
if semver.match(existing_gpg, required_gpg):
|
||||
run_create(args)
|
||||
else:
|
||||
log.error('Existing gpg2 has version "%s" (%s required)',
|
||||
existing_gpg, required_gpg)
|
||||
|
||||
|
||||
def main_agent():
|
||||
"""Run a simple GPG-agent server."""
|
||||
home_dir = os.environ.get('GNUPGHOME', os.path.expanduser('~/.gnupg/trezor'))
|
||||
config_file = os.path.join(home_dir, 'gpg-agent.conf')
|
||||
if not os.path.exists(config_file):
|
||||
msg = 'No configuration file found: {}'.format(config_file)
|
||||
raise IOError(msg)
|
||||
|
||||
lines = (line.strip() for line in open(config_file))
|
||||
lines = (line for line in lines if line and not line.startswith('#'))
|
||||
config = dict(line.split(' ', 1) for line in lines)
|
||||
|
||||
util.setup_logging(verbosity=int(config['verbosity']),
|
||||
filename=config['log-file'])
|
||||
sock_path = keyring.get_agent_sock_path()
|
||||
with server.unix_domain_socket_server(sock_path) as sock:
|
||||
for conn in agent.yield_connections(sock):
|
||||
with contextlib.closing(conn):
|
||||
try:
|
||||
agent.handle_connection(conn)
|
||||
except StopIteration:
|
||||
log.info('stopping gpg-agent')
|
||||
return
|
||||
except Exception as e: # pylint: disable=broad-except
|
||||
log.exception('gpg-agent failed: %s', e)
|
||||
|
||||
|
||||
def auto_unlock():
|
||||
"""Automatically unlock first found device (used for `gpg-shell`)."""
|
||||
p = argparse.ArgumentParser()
|
||||
p.add_argument('-v', '--verbose', default=0, action='count')
|
||||
|
||||
args = p.parse_args()
|
||||
util.setup_logging(verbosity=args.verbose)
|
||||
d = device.detect()
|
||||
log.info('unlocked %s device', d)
|
||||
152
trezor_agent/gpg/agent.py
Normal file
152
trezor_agent/gpg/agent.py
Normal file
@@ -0,0 +1,152 @@
|
||||
"""GPG-agent utilities."""
|
||||
import binascii
|
||||
import logging
|
||||
|
||||
from . import decode, client, keyring, protocol
|
||||
from .. import util
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def yield_connections(sock):
|
||||
"""Run a server on the specified socket."""
|
||||
while True:
|
||||
log.debug('waiting for connection on %s', sock.getsockname())
|
||||
try:
|
||||
conn, _ = sock.accept()
|
||||
except KeyboardInterrupt:
|
||||
return
|
||||
conn.settimeout(None)
|
||||
log.debug('accepted connection on %s', sock.getsockname())
|
||||
yield conn
|
||||
|
||||
|
||||
def serialize(data):
|
||||
"""Serialize data according to ASSUAN protocol."""
|
||||
for c in [b'%', b'\n', b'\r']:
|
||||
escaped = '%{:02X}'.format(ord(c)).encode('ascii')
|
||||
data = data.replace(c, escaped)
|
||||
return data
|
||||
|
||||
|
||||
def sig_encode(r, s):
|
||||
"""Serialize ECDSA signature data into GPG S-expression."""
|
||||
r = serialize(util.num2bytes(r, 32))
|
||||
s = serialize(util.num2bytes(s, 32))
|
||||
return b'(7:sig-val(5:ecdsa(1:r32:' + r + b')(1:s32:' + s + b')))'
|
||||
|
||||
|
||||
def open_connection(keygrip_bytes):
|
||||
"""
|
||||
Connect to the device for the specified keygrip.
|
||||
|
||||
Parse GPG public key to find the first user ID, which is used to
|
||||
specify the correct signature/decryption key on the device.
|
||||
"""
|
||||
pubkey_dict, user_ids = decode.load_by_keygrip(
|
||||
pubkey_bytes=keyring.export_public_keys(),
|
||||
keygrip=keygrip_bytes)
|
||||
# We assume the first user ID is used to generate TREZOR-based GPG keys.
|
||||
user_id = user_ids[0]['value'].decode('ascii')
|
||||
curve_name = protocol.get_curve_name_by_oid(pubkey_dict['curve_oid'])
|
||||
ecdh = (pubkey_dict['algo'] == protocol.ECDH_ALGO_ID)
|
||||
|
||||
conn = client.Client(user_id, curve_name=curve_name)
|
||||
pubkey = protocol.PublicKey(
|
||||
curve_name=curve_name, created=pubkey_dict['created'],
|
||||
verifying_key=conn.pubkey(ecdh=ecdh), ecdh=ecdh)
|
||||
assert pubkey.key_id() == pubkey_dict['key_id']
|
||||
assert pubkey.keygrip() == keygrip_bytes
|
||||
return conn
|
||||
|
||||
|
||||
def pksign(keygrip, digest, algo):
|
||||
"""Sign a message digest using a private EC key."""
|
||||
log.debug('signing %r digest (algo #%s)', digest, algo)
|
||||
keygrip_bytes = binascii.unhexlify(keygrip)
|
||||
conn = open_connection(keygrip_bytes)
|
||||
r, s = conn.sign(binascii.unhexlify(digest))
|
||||
result = sig_encode(r, s)
|
||||
log.debug('result: %r', result)
|
||||
return result
|
||||
|
||||
|
||||
def _serialize_point(data):
|
||||
prefix = '{}:'.format(len(data)).encode('ascii')
|
||||
# https://www.gnupg.org/documentation/manuals/assuan/Server-responses.html
|
||||
return b'(5:value' + serialize(prefix + data) + b')'
|
||||
|
||||
|
||||
def parse_ecdh(line):
|
||||
"""Parse ECDH request and return remote public key."""
|
||||
prefix, line = line.split(b' ', 1)
|
||||
assert prefix == b'D'
|
||||
exp, leftover = keyring.parse(keyring.unescape(line))
|
||||
log.debug('ECDH s-exp: %r', exp)
|
||||
assert not leftover
|
||||
label, exp = exp
|
||||
assert label == b'enc-val'
|
||||
assert exp[0] == b'ecdh'
|
||||
items = exp[1:]
|
||||
log.debug('ECDH parameters: %r', items)
|
||||
return dict(items)[b'e']
|
||||
|
||||
|
||||
def pkdecrypt(keygrip, conn):
|
||||
"""Handle decryption using ECDH."""
|
||||
for msg in [b'S INQUIRE_MAXLEN 4096', b'INQUIRE CIPHERTEXT']:
|
||||
keyring.sendline(conn, msg)
|
||||
|
||||
line = keyring.recvline(conn)
|
||||
assert keyring.recvline(conn) == b'END'
|
||||
remote_pubkey = parse_ecdh(line)
|
||||
|
||||
keygrip_bytes = binascii.unhexlify(keygrip)
|
||||
conn = open_connection(keygrip_bytes)
|
||||
return _serialize_point(conn.ecdh(remote_pubkey))
|
||||
|
||||
|
||||
def handle_connection(conn):
|
||||
"""Handle connection from GPG binary using the ASSUAN protocol."""
|
||||
keygrip = None
|
||||
digest = None
|
||||
algo = None
|
||||
version = keyring.gpg_version() # "Clone" existing GPG version
|
||||
|
||||
keyring.sendline(conn, b'OK')
|
||||
for line in keyring.iterlines(conn):
|
||||
parts = line.split(b' ')
|
||||
command = parts[0]
|
||||
args = parts[1:]
|
||||
if command in {b'RESET', b'OPTION', b'HAVEKEY', b'SETKEYDESC'}:
|
||||
pass # reply with OK
|
||||
elif command == b'GETINFO':
|
||||
keyring.sendline(conn, b'D ' + version)
|
||||
elif command == b'AGENT_ID':
|
||||
keyring.sendline(conn, b'D TREZOR') # "Fake" agent ID
|
||||
elif command in {b'SIGKEY', b'SETKEY'}:
|
||||
keygrip, = args
|
||||
elif command == b'SETHASH':
|
||||
algo, digest = args
|
||||
elif command == b'PKSIGN':
|
||||
sig = pksign(keygrip, digest, algo)
|
||||
keyring.sendline(conn, b'D ' + sig)
|
||||
elif command == b'PKDECRYPT':
|
||||
sec = pkdecrypt(keygrip, conn)
|
||||
keyring.sendline(conn, b'D ' + sec)
|
||||
elif command == b'KEYINFO':
|
||||
keygrip, = args
|
||||
# Dummy reply (mainly for 'gpg --edit' to succeed).
|
||||
# For details, see GnuPG agent KEYINFO command help.
|
||||
fmt = 'S KEYINFO {0} X - - - - - - -'
|
||||
keyring.sendline(conn, fmt.format(keygrip).encode('ascii'))
|
||||
elif command == b'BYE':
|
||||
return
|
||||
elif command == b'KILLAGENT':
|
||||
keyring.sendline(conn, b'OK')
|
||||
raise StopIteration
|
||||
else:
|
||||
log.error('unknown request: %r', line)
|
||||
return
|
||||
|
||||
keyring.sendline(conn, b'OK')
|
||||
44
trezor_agent/gpg/client.py
Normal file
44
trezor_agent/gpg/client.py
Normal file
@@ -0,0 +1,44 @@
|
||||
"""Device abstraction layer for GPG operations."""
|
||||
|
||||
import logging
|
||||
|
||||
from .. import device, formats, util
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Client(object):
|
||||
"""Sign messages and get public keys from a hardware device."""
|
||||
|
||||
def __init__(self, user_id, curve_name):
|
||||
"""Connect to the device and retrieve required public key."""
|
||||
self.device = device.detect()
|
||||
self.user_id = user_id
|
||||
self.identity = device.interface.Identity(
|
||||
identity_str='gpg://', curve_name=curve_name)
|
||||
self.identity.identity_dict['host'] = user_id
|
||||
|
||||
def pubkey(self, ecdh=False):
|
||||
"""Return public key as VerifyingKey object."""
|
||||
with self.device:
|
||||
pubkey = self.device.pubkey(ecdh=ecdh, identity=self.identity)
|
||||
return formats.decompress_pubkey(
|
||||
pubkey=pubkey, curve_name=self.identity.curve_name)
|
||||
|
||||
def sign(self, digest):
|
||||
"""Sign the digest and return a serialized signature."""
|
||||
log.info('please confirm GPG signature on %s for "%s"...',
|
||||
self.device, self.user_id)
|
||||
if self.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=self.identity)
|
||||
return (util.bytes2num(sig[:32]), util.bytes2num(sig[32:]))
|
||||
|
||||
def ecdh(self, pubkey):
|
||||
"""Derive shared secret using ECDH from remote public key."""
|
||||
log.info('please confirm GPG decryption on %s for "%s"...',
|
||||
self.device, self.user_id)
|
||||
with self.device:
|
||||
return self.device.ecdh(pubkey=pubkey, identity=self.identity)
|
||||
313
trezor_agent/gpg/decode.py
Normal file
313
trezor_agent/gpg/decode.py
Normal file
@@ -0,0 +1,313 @@
|
||||
"""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)
|
||||
assert prefix == 4
|
||||
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)
|
||||
assert prefix == 0x40
|
||||
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)
|
||||
|
||||
if packet_type is not None:
|
||||
p = packet_type(util.Reader(io.BytesIO(packet_data)))
|
||||
p['tag'] = tag
|
||||
else:
|
||||
p = {'type': 'unknown', 'tag': tag, 'raw': packet_data}
|
||||
|
||||
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
|
||||
|
||||
|
||||
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
|
||||
103
trezor_agent/gpg/encode.py
Normal file
103
trezor_agent/gpg/encode.py
Normal file
@@ -0,0 +1,103 @@
|
||||
"""Create GPG ECDSA signatures and public keys using TREZOR device."""
|
||||
import io
|
||||
import logging
|
||||
|
||||
from . import decode, keyring, protocol
|
||||
from .. import util
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def create_primary(user_id, pubkey, signer_func, secret_bytes=b''):
|
||||
"""Export new primary GPG public key, ready for "gpg2 --import"."""
|
||||
pubkey_packet = protocol.packet(tag=(5 if secret_bytes else 6),
|
||||
blob=(pubkey.data() + secret_bytes))
|
||||
user_id_packet = protocol.packet(tag=13,
|
||||
blob=user_id.encode('ascii'))
|
||||
|
||||
data_to_sign = (pubkey.data_to_hash() +
|
||||
user_id_packet[:1] +
|
||||
util.prefix_len('>L', user_id.encode('ascii')))
|
||||
hashed_subpackets = [
|
||||
protocol.subpacket_time(pubkey.created), # signature time
|
||||
# https://tools.ietf.org/html/rfc4880#section-5.2.3.7
|
||||
protocol.subpacket_byte(0x0B, 9), # preferred symmetric algo (AES-256)
|
||||
# https://tools.ietf.org/html/rfc4880#section-5.2.3.4
|
||||
protocol.subpacket_byte(0x1B, 1 | 2), # key flags (certify & sign)
|
||||
# https://tools.ietf.org/html/rfc4880#section-5.2.3.21
|
||||
protocol.subpacket_byte(0x15, 8), # preferred hash (SHA256)
|
||||
# https://tools.ietf.org/html/rfc4880#section-5.2.3.8
|
||||
protocol.subpacket_byte(0x16, 0), # preferred compression (none)
|
||||
# https://tools.ietf.org/html/rfc4880#section-5.2.3.9
|
||||
protocol.subpacket_byte(0x17, 0x80) # key server prefs (no-modify)
|
||||
# https://tools.ietf.org/html/rfc4880#section-5.2.3.17
|
||||
]
|
||||
unhashed_subpackets = [
|
||||
protocol.subpacket(16, pubkey.key_id()), # issuer key id
|
||||
protocol.CUSTOM_SUBPACKET]
|
||||
|
||||
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
|
||||
229
trezor_agent/gpg/keyring.py
Normal file
229
trezor_agent/gpg/keyring.py
Normal file
@@ -0,0 +1,229 @@
|
||||
"""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 get_agent_sock_path(sp=subprocess):
|
||||
"""Parse gpgconf output to find out GPG agent UNIX socket path."""
|
||||
lines = sp.check_output(['gpgconf', '--list-dirs']).strip().split(b'\n')
|
||||
dirs = dict(line.split(b':', 1) for line in lines)
|
||||
return dirs[b'agent-socket']
|
||||
|
||||
|
||||
def connect_to_agent(sp=subprocess):
|
||||
"""Connect to GPG agent's UNIX socket."""
|
||||
sock_path = get_agent_sock_path(sp=sp)
|
||||
sp.check_call(['gpg-connect-agent', '/bye']) # Make sure it's running
|
||||
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||
sock.connect(sock_path)
|
||||
return sock
|
||||
|
||||
|
||||
def communicate(sock, msg):
|
||||
"""Send a message and receive a single line."""
|
||||
sendline(sock, msg.encode('ascii'))
|
||||
return recvline(sock)
|
||||
|
||||
|
||||
def sendline(sock, msg):
|
||||
"""Send a binary message, followed by EOL."""
|
||||
log.debug('<- %r', 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:]
|
||||
else:
|
||||
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 = sp.check_output(['tty']).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 gpg_command(args, env=None):
|
||||
"""Prepare common GPG command line arguments."""
|
||||
if env is None:
|
||||
env = os.environ
|
||||
cmd = ['gpg2']
|
||||
homedir = env.get('GNUPGHOME')
|
||||
if homedir:
|
||||
cmd.extend(['--homedir', homedir])
|
||||
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 = sp.check_output(args).decode('ascii')
|
||||
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 = sp.check_output(args)
|
||||
line = output.split(b'\n')[0] # b'gpg (GnuPG) 2.1.11'
|
||||
return line.split(b' ')[-1] # b'2.1.11'
|
||||
|
||||
|
||||
def export_public_key(user_id, sp=subprocess):
|
||||
"""Export GPG public key for specified `user_id`."""
|
||||
args = gpg_command(['--export', user_id])
|
||||
result = sp.check_output(args=args)
|
||||
if not result:
|
||||
log.error('could not find public key %r in local GPG keyring', user_id)
|
||||
raise KeyError(user_id)
|
||||
return result
|
||||
|
||||
|
||||
def export_public_keys(sp=subprocess):
|
||||
"""Export all GPG public keys."""
|
||||
args = gpg_command(['--export'])
|
||||
return sp.check_output(args=args)
|
||||
|
||||
|
||||
def create_agent_signer(user_id):
|
||||
"""Sign digest with existing GPG keys using gpg-agent tool."""
|
||||
sock = connect_to_agent()
|
||||
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
|
||||
271
trezor_agent/gpg/protocol.py
Normal file
271
trezor_agent/gpg/protocol.py
Normal file
@@ -0,0 +1,271 @@
|
||||
"""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_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(object):
|
||||
"""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.
1
trezor_agent/gpg/tests/__init__.py
Normal file
1
trezor_agent/gpg/tests/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Tests for GPG module."""
|
||||
58
trezor_agent/gpg/tests/test_decode.py
Normal file
58
trezor_agent/gpg/tests/test_decode.py
Normal file
@@ -0,0 +1,58 @@
|
||||
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:
|
||||
packets = list(decode.parse_packets(f))
|
||||
assert len(packets) > 0
|
||||
|
||||
|
||||
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)
|
||||
101
trezor_agent/gpg/tests/test_keyring.py
Normal file
101
trezor_agent/gpg/tests/test_keyring.py
Normal file
@@ -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(object):
|
||||
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
|
||||
107
trezor_agent/gpg/tests/test_protocol.py
Normal file
107
trezor_agent/gpg/tests/test_protocol.py
Normal file
@@ -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')
|
||||
159
trezor_agent/protocol.py
Normal file
159
trezor_agent/protocol.py
Normal file
@@ -0,0 +1,159 @@
|
||||
"""
|
||||
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."""
|
||||
assert not buf.read()
|
||||
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(object):
|
||||
"""ssh-agent protocol handler."""
|
||||
|
||||
def __init__(self, keys, signer, debug=False):
|
||||
"""
|
||||
Create a protocol handler with specified public keys.
|
||||
|
||||
Use specified signer function to sign SSH authentication requests.
|
||||
"""
|
||||
self.public_keys = keys
|
||||
self.signer = signer
|
||||
self.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.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.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('ascii') # label should be a string
|
||||
log.debug('signing %d-byte blob with "%s" key', len(blob), label)
|
||||
try:
|
||||
signature = self.signer(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)
|
||||
166
trezor_agent/server.py
Normal file
166
trezor_agent/server.py
Normal file
@@ -0,0 +1,166 @@
|
||||
"""UNIX-domain socket server for ssh-agent implementation."""
|
||||
import contextlib
|
||||
import functools
|
||||
import logging
|
||||
import os
|
||||
import socket
|
||||
import subprocess
|
||||
import tempfile
|
||||
import threading
|
||||
|
||||
from . import util
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
UNIX_SOCKET_TIMEOUT = 0.1
|
||||
|
||||
|
||||
def remove_file(path, remove=os.remove, exists=os.path.exists):
|
||||
"""Remove file, and raise OSError if still exists."""
|
||||
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)
|
||||
|
||||
|
||||
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()
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def serve(handler, sock_path=None, timeout=UNIX_SOCKET_TIMEOUT):
|
||||
"""
|
||||
Start the ssh-agent server on a UNIX-domain socket.
|
||||
|
||||
If no connection is made during the specified timeout,
|
||||
retry until the context is over.
|
||||
"""
|
||||
ssh_version = subprocess.check_output(['ssh', '-V'],
|
||||
stderr=subprocess.STDOUT)
|
||||
log.debug('local SSH version: %r', ssh_version)
|
||||
if sock_path is None:
|
||||
sock_path = tempfile.mktemp(prefix='trezor-ssh-agent-')
|
||||
|
||||
environ = {'SSH_AUTH_SOCK': sock_path, 'SSH_AGENT_PID': str(os.getpid())}
|
||||
device_mutex = threading.Lock()
|
||||
with unix_domain_socket_server(sock_path) as sock:
|
||||
sock.settimeout(timeout)
|
||||
quit_event = threading.Event()
|
||||
handle_conn = functools.partial(handle_connection,
|
||||
handler=handler,
|
||||
mutex=device_mutex)
|
||||
kwargs = dict(sock=sock,
|
||||
handle_conn=handle_conn,
|
||||
quit_event=quit_event)
|
||||
with spawn(server_thread, kwargs):
|
||||
try:
|
||||
yield environ
|
||||
finally:
|
||||
log.debug('closing server')
|
||||
quit_event.set()
|
||||
|
||||
|
||||
def run_process(command, environ):
|
||||
"""
|
||||
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
|
||||
1
trezor_agent/tests/__init__.py
Normal file
1
trezor_agent/tests/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Unit-tests for this package."""
|
||||
72
trezor_agent/tests/test_client.py
Normal file
72
trezor_agent/tests/test_client.py
Normal file
@@ -0,0 +1,72 @@
|
||||
import io
|
||||
|
||||
import mock
|
||||
import pytest
|
||||
|
||||
from .. import client, device, formats, util
|
||||
|
||||
ADDR = [2147483661, 2810943954, 3938368396, 3454558782, 3848009040]
|
||||
CURVE = 'nist256p1'
|
||||
|
||||
PUBKEY = (b'\x03\xd8(\xb5\xa6`\xbet0\x95\xac:[;]\xdc,\xbd\xdc?\xd7\xc0\xec'
|
||||
b'\xdd\xbc+\xfar~\x9dAis')
|
||||
PUBKEY_TEXT = ('ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzd'
|
||||
'HAyNTYAAABBBNgotaZgvnQwlaw6Wztd3Cy93D/XwOzdvCv6cn6dQWlzNMEQeW'
|
||||
'VUfhvrGljR2Z/CMRONY6ejB+9PnpUOPuzYqi8= <localhost:22|nist256p1>\n')
|
||||
|
||||
|
||||
class MockDevice(device.interface.Device): # pylint: disable=abstract-method
|
||||
|
||||
def connect(self): # pylint: disable=no-self-use
|
||||
return mock.Mock()
|
||||
|
||||
def pubkey(self, identity, ecdh=False): # pylint: disable=unused-argument
|
||||
assert self.conn
|
||||
return PUBKEY
|
||||
|
||||
def sign(self, identity, blob):
|
||||
"""Sign given blob and return the signature (as bytes)."""
|
||||
assert self.conn
|
||||
assert blob == BLOB
|
||||
return SIG
|
||||
|
||||
|
||||
BLOB = (b'\x00\x00\x00 \xce\xe0\xc9\xd5\xceu/\xe8\xc5\xf2\xbfR+x\xa1\xcf\xb0'
|
||||
b'\x8e;R\xd3)m\x96\x1b\xb4\xd8s\xf1\x99\x16\xaa2\x00\x00\x00\x05roman'
|
||||
b'\x00\x00\x00\x0essh-connection\x00\x00\x00\tpublickey'
|
||||
b'\x01\x00\x00\x00\x13ecdsa-sha2-nistp256\x00\x00\x00h\x00\x00\x00'
|
||||
b'\x13ecdsa-sha2-nistp256\x00\x00\x00\x08nistp256\x00\x00\x00A'
|
||||
b'\x04\xd8(\xb5\xa6`\xbet0\x95\xac:[;]\xdc,\xbd\xdc?\xd7\xc0\xec'
|
||||
b'\xdd\xbc+\xfar~\x9dAis4\xc1\x10yeT~\x1b\xeb\x1aX\xd1\xd9\x9f\xc21'
|
||||
b'\x13\x8dc\xa7\xa3\x07\xefO\x9e\x95\x0e>\xec\xd8\xaa/')
|
||||
|
||||
SIG = (b'R\x19T\xf2\x84$\xef#\x0e\xee\x04X\xc6\xc3\x99T`\xd1\xd8\xf7!'
|
||||
b'\x862@cx\xb8\xb9i@1\x1b3#\x938\x86]\x97*Y\xb2\x02Xa\xdf@\xecK'
|
||||
b'\xdc\xf0H\xab\xa8\xac\xa7? \x8f=C\x88N\xe2')
|
||||
|
||||
|
||||
def test_ssh_agent():
|
||||
identity = device.interface.Identity(identity_str='localhost:22',
|
||||
curve_name=CURVE)
|
||||
c = client.Client(device=MockDevice())
|
||||
assert c.get_public_key(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)
|
||||
103
trezor_agent/tests/test_formats.py
Normal file
103
trezor_agent/tests/test_formats.py
Normal file
@@ -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))
|
||||
94
trezor_agent/tests/test_protocol.py
Normal file
94
trezor_agent/tests/test_protocol.py
Normal file
@@ -0,0 +1,94 @@
|
||||
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 test_list():
|
||||
key = formats.import_public_key(NIST256_KEY)
|
||||
key['identity'] = device.interface.Identity('ssh://localhost', 'nist256p1')
|
||||
h = protocol.Handler(keys=[key], signer=None)
|
||||
reply = h.handle(LIST_MSG)
|
||||
assert reply == LIST_NIST256_REPLY
|
||||
|
||||
|
||||
def test_unsupported():
|
||||
h = protocol.Handler(keys=[], signer=None)
|
||||
reply = h.handle(b'\x09')
|
||||
assert reply == b'\x00\x00\x00\x01\x05'
|
||||
|
||||
|
||||
def ecdsa_signer(identity, blob):
|
||||
assert str(identity) == '<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(keys=[key], signer=ecdsa_signer)
|
||||
reply = h.handle(NIST256_SIGN_MSG)
|
||||
assert reply == NIST256_SIGN_REPLY
|
||||
|
||||
|
||||
def test_sign_missing():
|
||||
h = protocol.Handler(keys=[], signer=ecdsa_signer)
|
||||
with pytest.raises(KeyError):
|
||||
h.handle(NIST256_SIGN_MSG)
|
||||
|
||||
|
||||
def test_sign_wrong():
|
||||
def wrong_signature(identity, blob):
|
||||
assert str(identity) == '<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(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(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 str(identity) == '<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(keys=[key], signer=ed25519_signer)
|
||||
reply = h.handle(ED25519_SIGN_MSG)
|
||||
assert reply == ED25519_SIGN_REPLY
|
||||
133
trezor_agent/tests/test_server.py
Normal file
133
trezor_agent/tests/test_server.py
Normal file
@@ -0,0 +1,133 @@
|
||||
import functools
|
||||
import io
|
||||
import os
|
||||
import socket
|
||||
import tempfile
|
||||
import threading
|
||||
|
||||
import mock
|
||||
import pytest
|
||||
|
||||
from .. import protocol, server, util
|
||||
|
||||
|
||||
def test_socket():
|
||||
path = tempfile.mktemp()
|
||||
with server.unix_domain_socket_server(path):
|
||||
pass
|
||||
assert not os.path.isfile(path)
|
||||
|
||||
|
||||
class FakeSocket(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 settimeout(self, value):
|
||||
pass
|
||||
|
||||
|
||||
def test_handle():
|
||||
mutex = threading.Lock()
|
||||
|
||||
handler = protocol.Handler(keys=[], signer=None)
|
||||
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():
|
||||
|
||||
connections = [FakeSocket()]
|
||||
quit_event = threading.Event()
|
||||
|
||||
class FakeServer(object):
|
||||
def accept(self): # pylint: disable=no-self-use
|
||||
if connections:
|
||||
return connections.pop(), 'address'
|
||||
quit_event.set()
|
||||
raise socket.timeout()
|
||||
|
||||
def getsockname(self): # pylint: disable=no-self-use
|
||||
return 'fake_server'
|
||||
|
||||
handler = protocol.Handler(keys=[], signer=None),
|
||||
handle_conn = functools.partial(server.handle_connection, handler=handler)
|
||||
server.server_thread(sock=FakeServer(),
|
||||
handle_conn=handle_conn,
|
||||
quit_event=quit_event)
|
||||
|
||||
|
||||
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_serve_main():
|
||||
handler = protocol.Handler(keys=[], signer=None)
|
||||
with server.serve(handler=handler, 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)
|
||||
103
trezor_agent/tests/test_util.py
Normal file
103
trezor_agent/tests/test_util.py
Normal file
@@ -0,0 +1,103 @@
|
||||
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 FakeSocket(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 = 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)
|
||||
187
trezor_agent/util.py
Normal file
187
trezor_agent/util.py
Normal file
@@ -0,0 +1,187 @@
|
||||
"""Various I/O and serialization utilities."""
|
||||
import binascii
|
||||
import contextlib
|
||||
import io
|
||||
import logging
|
||||
import struct
|
||||
|
||||
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(object):
|
||||
"""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, **kwargs):
|
||||
"""Configure logging for this tool."""
|
||||
fmt = ('%(asctime)s %(levelname)-12s %(message)-100s '
|
||||
'[%(filename)s:%(lineno)d]')
|
||||
levels = [logging.WARNING, logging.INFO, logging.DEBUG]
|
||||
level = levels[min(verbosity, len(levels) - 1)]
|
||||
logging.basicConfig(format=fmt, level=level, **kwargs)
|
||||
Reference in New Issue
Block a user