Compare commits
1917 commits
legacy_pro
...
port
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9ad7eedbfe | ||
|
|
8e63bbead4 | ||
|
|
a724504b2e | ||
|
|
7908ea0b86 | ||
|
|
4b8651274f | ||
|
|
fe3c3be675 | ||
|
|
7a79915f0c | ||
|
|
61e19c9882 | ||
|
|
f3538cd114 | ||
|
|
33d7f205fa | ||
|
|
4543f7956c | ||
|
|
50729e6ff3 | ||
|
|
9252b1b269 | ||
|
|
2b573ce24a | ||
|
|
fcc470d0a2 | ||
|
|
d618a66c2e | ||
|
|
dc9fbfa4bb | ||
|
|
b09f750a85 | ||
|
|
0d63c94b8e | ||
|
|
5a15af5abe | ||
|
|
d5a2638f22 | ||
|
|
96f12fe668 | ||
|
|
3d8ecabf09 | ||
|
|
56ac7e8830 | ||
|
|
d7eccd5a1a | ||
|
|
9522d847d7 | ||
|
|
b943fab35a | ||
|
|
d7d98d79ce | ||
|
|
bfe6adf215 | ||
|
|
deacfc8a74 | ||
|
|
c546dddd7f | ||
|
|
9f31976928 | ||
|
|
01481aabd9 | ||
|
|
e8a00a1018 | ||
|
|
88c824c0ec | ||
|
|
1f552cab74 | ||
|
|
86c8fb1410 | ||
|
|
c2a8195f19 | ||
|
|
2b49443e86 | ||
|
|
ccc6a3212b | ||
|
|
7f46e69454 | ||
|
|
f37b919210 | ||
|
|
160a96052b | ||
|
|
5859e87ced | ||
|
|
596132292a | ||
|
|
7bc1cd4454 | ||
|
|
d361937b67 | ||
|
|
12720a2252 | ||
|
|
5d6d901655 | ||
|
|
c0ba26776a | ||
|
|
a876a664df | ||
|
|
802b9d4a43 | ||
|
|
2f2137ef6b | ||
|
|
10e993331c | ||
|
|
33224e7d77 | ||
|
|
04c12a5e38 | ||
|
|
c85fa02495 | ||
|
|
a462e38cab | ||
|
|
120a94f84f | ||
|
|
b7d3710579 | ||
|
|
0b4a85c145 | ||
|
|
ff8925d92f | ||
|
|
2ae6ac2bfd | ||
|
|
db1e078c06 | ||
|
|
2e9cf9a5d5 | ||
|
|
b9df69af9f | ||
|
|
2f9cd68807 | ||
|
|
b0c72410ba | ||
|
|
0e474436c4 | ||
|
|
19c73249ca | ||
|
|
cb3863b5fd | ||
|
|
c57cd239c3 | ||
|
|
4ebc20402b | ||
|
|
1d35946b4e | ||
|
|
15f443dced | ||
|
|
56d8c60df6 | ||
|
|
0c46f80fdd | ||
|
|
30a02d4487 | ||
|
|
f4d5ce1986 | ||
|
|
f24479ebfc | ||
|
|
581875bde3 | ||
|
|
b66c60731f | ||
|
|
517c9bd736 | ||
|
|
55b897537d | ||
|
|
35c21d4cf4 | ||
|
|
5e450e879c | ||
|
|
7aa749174b | ||
|
|
0d24f52f6e | ||
|
|
3e2d1c8abc | ||
|
|
1bc9e7cb64 | ||
|
|
7dc64e0387 | ||
|
|
d850be2d73 | ||
|
|
0d087788da | ||
|
|
7470bc8db6 | ||
|
|
97b09ea1c6 | ||
|
|
bb831206b5 | ||
|
|
36eafde213 | ||
|
|
a7817de4ab | ||
|
|
0955012569 | ||
|
|
90b27ff9cf | ||
|
|
9ab40444b6 | ||
|
|
20a08b50f2 | ||
|
|
9a9f91b4ee | ||
|
|
e05b10974c | ||
|
|
302b50db5e | ||
|
|
d9d620180b | ||
|
|
e276f2aa6b | ||
|
|
02e7154c0d | ||
|
|
61aa29d28c | ||
|
|
9197fa6b5c | ||
|
|
2c6ef0feef | ||
|
|
25f1e45d94 | ||
|
|
e0293d81f3 | ||
|
|
7c3c52c2b1 | ||
|
|
c639673de5 | ||
|
|
4407210e01 | ||
|
|
2b32b94c0b | ||
|
|
108bd22ca3 | ||
|
|
1bd49cef82 | ||
|
|
d0f26132bc | ||
|
|
e140eca4f3 | ||
|
|
5d575e78b2 | ||
|
|
dc9ffdbb7f | ||
|
|
8b3a766dc1 | ||
|
|
e682ae2503 | ||
|
|
13913fd8e0 | ||
|
|
a7a863e1f2 | ||
|
|
85b5bc0cb2 | ||
|
|
a248980952 | ||
|
|
abe1aa999a | ||
|
|
1ec90a6c5b | ||
|
|
e0fd6ee018 | ||
|
|
b316cd2caa | ||
|
|
2ec451d00b | ||
|
|
29e56d442f | ||
|
|
87f197afb2 | ||
|
|
ce32de023d | ||
|
|
36752a3dab | ||
|
|
a0acf7c703 | ||
|
|
60478e213b | ||
|
|
bfba30701e | ||
|
|
051a351a43 | ||
|
|
46707406b5 | ||
|
|
7abff038dc | ||
|
|
cfbd33809e | ||
|
|
4fbb5cb80f | ||
|
|
096920b8b3 | ||
|
|
98844a196c | ||
|
|
c96fc7e33a | ||
|
|
49b02e7740 | ||
|
|
9652656e14 | ||
|
|
c459102a04 | ||
|
|
30dfb6a1a9 | ||
|
|
be889d3794 | ||
|
|
b7a19486ed | ||
|
|
3ab5addb81 | ||
|
|
62e7eb236d | ||
|
|
fd333d39bb | ||
|
|
88507fea87 | ||
|
|
4e221eb9bd | ||
|
|
83e6208c3b | ||
|
|
95a1432476 | ||
|
|
3f1c6e4a9c | ||
|
|
554139cb1a | ||
|
|
07642a4a96 | ||
|
|
d6246ae309 | ||
|
|
d10186214b | ||
|
|
e3cd6fc709 | ||
|
|
7665e8c8c1 | ||
|
|
e97719b367 | ||
|
|
93b736a224 | ||
|
|
f99af51de3 | ||
|
|
79252e7940 | ||
|
|
91f20e9a4a | ||
|
|
3705594628 | ||
|
|
aea74f3c46 | ||
|
|
1a63c774d1 | ||
|
|
5027d70002 | ||
|
|
93a9a8b268 | ||
|
|
36161c306a | ||
|
|
fdfd395707 | ||
|
|
9d6cf6c6a2 | ||
|
|
abbd2483a5 | ||
|
|
b1b3befd04 | ||
|
|
c8dc9af7bf | ||
|
|
7d775ae9f3 | ||
|
|
3383dedbcf | ||
|
|
7a24499f15 | ||
|
|
bda7c9f5ce | ||
|
|
fbb0058bcd | ||
|
|
c54f154ea6 | ||
|
|
1973bdbc2f | ||
|
|
99fcb4f230 | ||
|
|
f080aae332 | ||
|
|
a96a7e2641 | ||
|
|
4ce4e2f930 | ||
|
|
865d9074e4 | ||
|
|
eb149b9129 | ||
|
|
5b9a2f9637 | ||
|
|
6ba133fc7c | ||
|
|
60b6834ab0 | ||
|
|
3ade1d13f3 | ||
|
|
f312bdac22 | ||
|
|
bf69cf1f05 | ||
|
|
3f2e427a71 | ||
|
|
3789922c0b | ||
|
|
040f434a61 | ||
|
|
d543716adf | ||
|
|
d804853958 | ||
|
|
4057734c33 | ||
|
|
dc561ea3df | ||
|
|
db1558a16c | ||
|
|
e51f300ee6 | ||
|
|
e0bc6aeb50 | ||
|
|
8bde250ff2 | ||
|
|
434699031b | ||
|
|
77ddc68d8e | ||
|
|
376bf9de6d | ||
|
|
38b497ef73 | ||
|
|
4de60f68ab | ||
|
|
b9f2823d6b | ||
|
|
f5def6b6d6 | ||
|
|
7d2023c64e | ||
|
|
b606bdf749 | ||
|
|
f35d8a5228 | ||
|
|
d15a386f92 | ||
|
|
138ebc6d4e | ||
|
|
04440179f4 | ||
|
|
81c7b30926 | ||
|
|
76d12e5e34 | ||
|
|
9637142c4c | ||
|
|
67f67fea02 | ||
|
|
06d7e51f22 | ||
|
|
e2be3a5fa9 | ||
|
|
271ba9a7e7 | ||
|
|
fba2de706e | ||
|
|
8f760cf828 | ||
|
|
512e02c837 | ||
|
|
f8384127c0 | ||
|
|
15258cc50a | ||
|
|
7fe5b8233b | ||
|
|
4f3753b644 | ||
|
|
d4bdba9726 | ||
|
|
a1f025980a | ||
|
|
fc4c599187 | ||
|
|
0069f408b1 | ||
|
|
079eb93e53 | ||
|
|
02f3bb4f05 | ||
|
|
457279adb2 | ||
|
|
4fb1685b55 | ||
|
|
e82f605c22 | ||
|
|
f1e747ac1a | ||
|
|
278bd8deb2 | ||
|
|
8e582dfff8 | ||
|
|
1cd47d4af3 | ||
|
|
a1e750f432 | ||
|
|
57f67c4109 | ||
|
|
cfa7e28106 | ||
|
|
655331c9cf | ||
|
|
62d26f1dcd | ||
|
|
3448490734 | ||
|
|
8e39a3a0ef | ||
|
|
28f5c7d666 | ||
|
|
e17bd684bb | ||
|
|
b6ef25e911 | ||
|
|
c5bf273024 | ||
|
|
bff89936af | ||
|
|
cd7c6d8220 | ||
|
|
e0a1e8f4e8 | ||
|
|
d970987b79 | ||
|
|
52d6e9b674 | ||
|
|
ac8d9e4ded | ||
|
|
1720864b44 | ||
|
|
e6dede6560 | ||
|
|
cf76593fa7 | ||
|
|
f408ac7296 | ||
|
|
90dae9fd88 | ||
|
|
fa09882892 | ||
|
|
b4d63cbbb3 | ||
|
|
a278d53f52 | ||
|
|
71a01d139c | ||
|
|
aa6faadb5c | ||
|
|
c208e810ee | ||
|
|
c5d4f91bf7 | ||
|
|
960b070c22 | ||
|
|
e503601d3b | ||
|
|
061282fa66 | ||
|
|
8f4280c2df | ||
|
|
5dcac29e3b | ||
|
|
d6d8bf250b | ||
|
|
50e509604c | ||
|
|
99ff33e581 | ||
|
|
048fd8d249 | ||
|
|
c89925ad04 | ||
|
|
f3e5757297 | ||
|
|
b61e57c5fc | ||
|
|
23c5a1fd90 | ||
|
|
4d39699bef | ||
|
|
e176ad8f43 | ||
|
|
4040fd0204 | ||
|
|
b8ebb0ab16 | ||
|
|
8292eab3f7 | ||
|
|
ca6b326371 | ||
|
|
1a013eae6e | ||
|
|
673a6d4f37 | ||
|
|
a1f5ea8e8c | ||
|
|
3382dac793 | ||
|
|
f46e2aeedd | ||
|
|
dbd024f77c | ||
|
|
6d2c81138e | ||
|
|
ed7ba28211 | ||
|
|
5f41c9f191 | ||
|
|
b3be239061 | ||
|
|
8afc26a736 | ||
|
|
a71ef63c18 | ||
|
|
25f25c63b4 | ||
|
|
79c06102e6 | ||
|
|
dee1b7f049 | ||
|
|
b1bb5a6843 | ||
|
|
a873e8ea33 | ||
|
|
c97573ec7c | ||
|
|
a11364458a | ||
|
|
5b2f74c92a | ||
|
|
a41c0f7590 | ||
|
|
db6f9ecf86 | ||
|
|
9b4cff8cd6 | ||
|
|
67768641cc | ||
|
|
bb61db97f0 | ||
|
|
85d9047f4e | ||
|
|
8d0334d2de | ||
|
|
63d5273ed1 | ||
|
|
52e20769bb | ||
|
|
40871d4c29 | ||
|
|
9855ac81bb | ||
|
|
88a03afe7b | ||
|
|
8dc9cc67d9 | ||
|
|
8a9759bf88 | ||
|
|
60cc025658 | ||
|
|
635e585226 | ||
|
|
d4b9499e2b | ||
|
|
896d2be1db | ||
|
|
41defbe5e7 | ||
|
|
5f01a90ce6 | ||
|
|
848528599e | ||
|
|
ad79d7c8b6 | ||
|
|
3dbfab7b9f | ||
|
|
d585b44680 | ||
|
|
2c96ea68b8 | ||
|
|
6b9d5c1daa | ||
|
|
8ba5166563 | ||
|
|
d53120f25f | ||
|
|
8883bd76fd | ||
|
|
bc9373929a | ||
|
|
7f5abba83e | ||
|
|
922f8e5dad | ||
|
|
b94c3614b9 | ||
|
|
196884652e | ||
|
|
4147993b4d | ||
|
|
50de40e780 | ||
|
|
c02db78417 | ||
|
|
e346bfafd6 | ||
|
|
eeb477d85e | ||
|
|
fb4bd1a013 | ||
|
|
30545e1c54 | ||
|
|
2be914f0d5 | ||
|
|
2139971212 | ||
|
|
b44014b06e | ||
|
|
19aea37203 | ||
|
|
4ed1a1c2d6 | ||
|
|
c4b495aa37 | ||
|
|
ad5c3ff1b2 | ||
|
|
0543f040bf | ||
|
|
22fd9d4cd7 | ||
|
|
fd39479810 | ||
|
|
887f91bdac | ||
|
|
c70bce8b7a | ||
|
|
2d65026d6d | ||
|
|
1fa5a64abd | ||
|
|
bce01419da | ||
|
|
8198e6c105 | ||
|
|
3457a01da2 | ||
|
|
8cf9a152de | ||
|
|
bd130a8cd8 | ||
|
|
2c40cc77fb | ||
|
|
3f37e3a241 | ||
|
|
9f37a46c92 | ||
|
|
34a66b1bff | ||
|
|
9394c26a9b | ||
|
|
a417d881f1 | ||
|
|
a53efb1737 | ||
|
|
06a989e5a3 | ||
|
|
e04f52368d | ||
|
|
925904f54b | ||
|
|
986ca6562f | ||
|
|
59c5a77731 | ||
|
|
05ad602b43 | ||
|
|
7b23147daf | ||
|
|
4646d8d6bf | ||
|
|
f82aa5836e | ||
|
|
814ab083bd | ||
|
|
f06c0017db | ||
|
|
9a7ade7cba | ||
|
|
73e9956a48 | ||
|
|
34ed54079d | ||
|
|
8c6d1ad50a | ||
|
|
0b57daf473 | ||
|
|
d3ad5f8b56 | ||
|
|
9e5bd7c90d | ||
|
|
efde7d4eff | ||
|
|
9ecfecbc7a | ||
|
|
2ae9bf3d49 | ||
|
|
b420e2a1da | ||
|
|
cd7a5ec24a | ||
|
|
8fed612fef | ||
|
|
ed5e8df8d7 | ||
|
|
c440b0354d | ||
|
|
8fe6584336 | ||
|
|
3fefd28080 | ||
|
|
325f2ae4ad | ||
|
|
97489f85fa | ||
|
|
171e37894b | ||
|
|
5b957f27d6 | ||
|
|
f5613ee074 | ||
|
|
2929039cf4 | ||
|
|
3ba41de2ba | ||
|
|
c4195c6cdf | ||
|
|
e5dd4ba70c | ||
|
|
2027110512 | ||
|
|
569a70f6aa | ||
|
|
d92a795b09 | ||
|
|
4f7d83d274 | ||
|
|
0558aa1c01 | ||
|
|
8bcc8f8024 | ||
|
|
e520efe41e | ||
|
|
9ea5e20b44 | ||
|
|
e15b7b4deb | ||
|
|
71aa1a5348 | ||
|
|
eef1ce6cf5 | ||
|
|
3dbf47eb3f | ||
|
|
a3d452c112 | ||
|
|
5547d13f12 | ||
|
|
d01b17f1e2 | ||
|
|
a7df8abb78 | ||
|
|
8d1135c049 | ||
|
|
c813efdce7 | ||
|
|
cc969fc406 | ||
|
|
2178315f8a | ||
|
|
3809a817ea | ||
|
|
f798c117f7 | ||
|
|
da8f3e19a4 | ||
|
|
a00dc88ad1 | ||
|
|
81ac25f89c | ||
|
|
b75df9f4de | ||
|
|
74f089ae3b | ||
|
|
31fbcd46e0 | ||
|
|
0e1f6b24f3 | ||
|
|
b617305927 | ||
|
|
9bc9abecbe | ||
|
|
b2b70279c2 | ||
|
|
f7718d14aa | ||
|
|
cdd365af9a | ||
|
|
6283ced29c | ||
|
|
1cddd0fba1 | ||
|
|
958b6b1048 | ||
|
|
1a5d6ba90d | ||
|
|
8562496f82 | ||
|
|
5c6d833fc1 | ||
|
|
149cd56c0b | ||
|
|
93c09f19be | ||
|
|
53d65d005e | ||
|
|
f00b674131 | ||
|
|
ae6c7cb936 | ||
|
|
969c2c052d | ||
|
|
d2425e5283 | ||
|
|
ef8daba47f | ||
|
|
7495145563 | ||
|
|
8dac7daf87 | ||
|
|
e1e6135313 | ||
|
|
233d1fb7f6 | ||
|
|
424acfe16e | ||
|
|
eebe710884 | ||
|
|
b8a024b65b | ||
|
|
536e99dd40 | ||
|
|
fcd8670baf | ||
|
|
1ca6016bb0 | ||
|
|
73021673ff | ||
|
|
ead60d8f4b | ||
|
|
2fe8a75200 | ||
|
|
58156a29d3 | ||
|
|
1681fbea3a | ||
|
|
e4e4c69f36 | ||
|
|
6a7e3438a9 | ||
|
|
1b1763b011 | ||
|
|
ea667744f5 | ||
|
|
36bb03df63 | ||
|
|
e7cf4792b3 | ||
|
|
5a22ff17d0 | ||
|
|
9571c7288c | ||
|
|
3c0ce923b2 | ||
|
|
ac0868b6de | ||
|
|
cd6b9bc9c7 | ||
|
|
0018bc0500 | ||
|
|
41115bfc77 | ||
|
|
2cadfdaae1 | ||
|
|
c0acf8239d | ||
|
|
083bd8701b | ||
|
|
42b3e2180a | ||
|
|
8f25241170 | ||
|
|
83b0e0a20e | ||
|
|
24f9da5275 | ||
|
|
1ada2cab15 | ||
|
|
064ec73903 | ||
|
|
8b8ce85c59 | ||
|
|
7c1248dba1 | ||
|
|
7416f3fbbc | ||
|
|
77d4b5a28a | ||
|
|
b0d98edcfe | ||
|
|
3fd4f2a94a | ||
|
|
cd74e7f20d | ||
|
|
6f4212dcf1 | ||
|
|
52fefad693 | ||
|
|
0f64082f1d | ||
|
|
dd18040e47 | ||
|
|
74d6d4e0b3 | ||
|
|
a941cf61b7 | ||
|
|
6db7cb5210 | ||
|
|
834691278e | ||
|
|
e166c4159e | ||
|
|
4a100490a1 | ||
|
|
9a144b46bc | ||
|
|
64d4e6249c | ||
|
|
2201e7944d | ||
|
|
ca4bece393 | ||
|
|
018201170c | ||
|
|
183b49fbc2 | ||
|
|
0bc5791a55 | ||
|
|
c951429895 | ||
|
|
866d236249 | ||
|
|
82bd808ab3 | ||
|
|
484fd8fe9e | ||
|
|
a2767d30a1 | ||
|
|
87592d64a9 | ||
|
|
fb8b2f1415 | ||
|
|
e612d52693 | ||
|
|
9556203ae9 | ||
|
|
e469ae4ed8 | ||
|
|
ff41397ccf | ||
|
|
b8fef70bf5 | ||
|
|
c5a44f3e39 | ||
|
|
56b71e3b32 | ||
|
|
d443fd9074 | ||
|
|
fc81f18864 | ||
|
|
995759abad | ||
|
|
93953604a2 | ||
|
|
35308bfc7d | ||
|
|
af767f917b | ||
|
|
0a62bd6ebe | ||
|
|
2c6cfe3f81 | ||
|
|
6f6ee897f8 | ||
|
|
5d22925108 | ||
|
|
82a4c61eff | ||
|
|
c57443812b | ||
|
|
e06c0cbbf7 | ||
|
|
787c64c546 | ||
|
|
514d319662 | ||
|
|
125ba6449e | ||
|
|
aeef964fb6 | ||
|
|
da53813770 | ||
|
|
ac9f97100c | ||
|
|
38d2f8613d | ||
|
|
bb94952830 | ||
|
|
d6e554b1fd | ||
|
|
cf9a4b6012 | ||
|
|
55b619853b | ||
|
|
eb2f019144 | ||
|
|
f4144f0017 | ||
|
|
587c81ebb4 | ||
|
|
aed3c7f877 | ||
|
|
27511d4822 | ||
|
|
de974ca51b | ||
|
|
a496179f74 | ||
|
|
b3029a5436 | ||
|
|
8229c72b9c | ||
|
|
5a85e1e46e | ||
|
|
db93b7b3c6 | ||
|
|
38cdd422d2 | ||
|
|
6f56dc1418 | ||
|
|
06a3f54c92 | ||
|
|
2fb684d784 | ||
|
|
3fe8f4ce03 | ||
|
|
1e39b72ab7 | ||
|
|
b235dc4c80 | ||
|
|
65cda7efbd | ||
|
|
c7aff67132 | ||
|
|
53fbedda53 | ||
|
|
b4c747a283 | ||
|
|
99b2003e61 | ||
|
|
5fbc5cee2e | ||
|
|
30a4f72649 | ||
|
|
f0ee6f1257 | ||
|
|
edce116fed | ||
|
|
734868b015 | ||
|
|
1ce1363288 | ||
|
|
a5e927c657 | ||
|
|
771ddf0aaf | ||
|
|
973672761d | ||
|
|
2bfd4d7b77 | ||
|
|
0604a393f0 | ||
|
|
6f3b03db77 | ||
|
|
b103aae808 | ||
|
|
41e86df252 | ||
|
|
d408ec5a95 | ||
|
|
3a6bd7123d | ||
|
|
81e8ba7daf | ||
|
|
e8eae2dab2 | ||
|
|
dc0f78dd15 | ||
|
|
532d155b1c | ||
|
|
a9d6735bce | ||
|
|
eaa0bae45f | ||
|
|
29146d1225 | ||
|
|
b30a8fbe90 | ||
|
|
d98edd1394 | ||
|
|
87c00ab259 | ||
|
|
5914b7a59a | ||
|
|
42e7ec4bd2 | ||
|
|
29fdde5f5f | ||
|
|
a3c8753dcf | ||
|
|
2141126608 | ||
|
|
e415a63d1f | ||
|
|
75a7b7605b | ||
|
|
e197f156a7 | ||
|
|
e3d95c5a68 | ||
|
|
ae6b13cd5b | ||
|
|
dfb94613bf | ||
|
|
2ee1ab05b3 | ||
|
|
fce08ea30c | ||
|
|
f7116a7388 | ||
|
|
1533eea351 | ||
|
|
fbab449694 | ||
|
|
4dc566a871 | ||
|
|
953b57453e | ||
|
|
28438dd111 | ||
|
|
3b2ffff154 | ||
|
|
ae31b81e7a | ||
|
|
32b7705080 | ||
|
|
9a0073fff5 | ||
|
|
ca00cce22c | ||
|
|
cf48791686 | ||
|
|
6f4af62f61 | ||
|
|
23d27f4659 | ||
|
|
a0b410f460 | ||
|
|
94d8a1ca01 | ||
|
|
394e18c4c6 | ||
|
|
73679a4e85 | ||
|
|
5fe9c079a0 | ||
|
|
84cae41b38 | ||
|
|
6580d3a85b | ||
|
|
3c645a9916 | ||
|
|
b6b86e44ce | ||
|
|
d0b63a3500 | ||
|
|
64a00d37bb | ||
|
|
b0dfea33c6 | ||
|
|
45541a3a23 | ||
|
|
3a0db7efa1 | ||
|
|
4c871b57c8 | ||
|
|
5863323eb0 | ||
|
|
aa6984e310 | ||
|
|
c185480ae9 | ||
|
|
803bb9595c | ||
|
|
73ac2e36cc | ||
|
|
f38911eb10 | ||
|
|
a2df245567 | ||
|
|
8ae6a60fba | ||
|
|
1c75100f79 | ||
|
|
9e7918fc75 | ||
|
|
b4f99df798 | ||
|
|
e4ac29ef81 | ||
|
|
0216ea3f26 | ||
|
|
138a9e9f01 | ||
|
|
1878db3416 | ||
|
|
b600e2d270 | ||
|
|
595230fd8e | ||
|
|
2ffc3c37cb | ||
|
|
93cdad120d | ||
|
|
8a5be3ee3a | ||
|
|
bd45d5ceea | ||
|
|
9c6bb5e63c | ||
|
|
fc059b6269 | ||
|
|
5672916cb2 | ||
|
|
140ca2b4d0 | ||
|
|
c9a7172388 | ||
|
|
7a4d37e320 | ||
|
|
19cff00835 | ||
|
|
bdcb94a2b5 | ||
|
|
40091cf60e | ||
|
|
eeb625063b | ||
|
|
17e8ddcb5c | ||
|
|
cc32eeb7cf | ||
|
|
828e09543b | ||
|
|
963c95dfe0 | ||
|
|
c838faffd8 | ||
|
|
57ae12745d | ||
|
|
fcb774f71b | ||
|
|
996b598eb3 | ||
|
|
1e62579688 | ||
|
|
5b762e51be | ||
|
|
0845d82f65 | ||
|
|
8c43404015 | ||
|
|
9b5ea88abd | ||
|
|
0cb012a9fd | ||
|
|
54955009eb | ||
|
|
4d7f67684d | ||
|
|
5ba23a6047 | ||
|
|
f11f5bca73 | ||
|
|
84fd90841c | ||
|
|
18a1d01d8f | ||
|
|
3ba8acc57e | ||
|
|
79b0ed5cd3 | ||
|
|
ff569084f8 | ||
|
|
659e07c5b3 | ||
|
|
106d2bfbbe | ||
|
|
1ea5fbdf9e | ||
|
|
319932bed5 | ||
|
|
bf5d132582 | ||
|
|
5efdda0922 | ||
|
|
752b3b687f | ||
|
|
bdcf8fc91e | ||
|
|
d6026d7573 | ||
|
|
0371838a91 | ||
|
|
ee2e0948f4 | ||
|
|
016e10f415 | ||
|
|
1d8281d15d | ||
|
|
cdfdee2ebc | ||
|
|
4288ece394 | ||
|
|
36f3cdeddc | ||
|
|
cb0aca6727 | ||
|
|
12899d0c38 | ||
|
|
95ba2730f1 | ||
|
|
e4f59e60e1 | ||
|
|
cdf8ccbd34 | ||
|
|
b13b6de79f | ||
|
|
e301c71fea | ||
|
|
b0fe02f732 | ||
|
|
f26549dc58 | ||
|
|
b76e8b6c41 | ||
|
|
cfe95323f6 | ||
|
|
4f0d0936af | ||
|
|
88dc56186e | ||
|
|
bad3a959d4 | ||
|
|
0e3504cecc | ||
|
|
048876a1df | ||
|
|
1001b027cb | ||
|
|
975fe1c65b | ||
|
|
aabbee3608 | ||
|
|
64eaa1b1a4 | ||
|
|
5180bda7e1 | ||
|
|
9463de3367 | ||
|
|
4c09b9882f | ||
|
|
ea88fc6401 | ||
|
|
ba8052250a | ||
|
|
6a0dc2b960 | ||
|
|
18582e8ca0 | ||
|
|
cbee249c38 | ||
|
|
849415f71b | ||
|
|
98ecc5d25a | ||
|
|
408f0141fa | ||
|
|
4bcc18d9d3 | ||
|
|
f8843c64e1 | ||
|
|
636f5aa313 | ||
|
|
33ba8b9dac | ||
|
|
aca82c1771 | ||
|
|
82dded9128 | ||
|
|
5b0efa2e64 | ||
|
|
c67f1c11b4 | ||
|
|
f3126e77a7 | ||
|
|
995b5622f8 | ||
|
|
1505f5e7bc | ||
|
|
03e5f3c6c6 | ||
|
|
78458b1348 | ||
|
|
f05771b704 | ||
|
|
f83a77d8ad | ||
|
|
f050fcfa58 | ||
|
|
eeb81cbf1f | ||
|
|
154db5a757 | ||
|
|
d2588de4fd | ||
|
|
fe1ba9dad6 | ||
|
|
d9c8c13f9a | ||
|
|
e5a027ce30 | ||
|
|
2a9c707dbd | ||
|
|
7b72262811 | ||
|
|
3c806b120a | ||
|
|
2a86936410 | ||
|
|
0ba12c9f46 | ||
|
|
efe1f2b2ff | ||
|
|
4f53c7a3c0 | ||
|
|
d8ac31acae | ||
|
|
4f3bf3d720 | ||
|
|
eaef4065e3 | ||
|
|
b245394355 | ||
|
|
13232452f8 | ||
|
|
61b98210f9 | ||
|
|
8caf03dcbb | ||
|
|
d36d0eeb30 | ||
|
|
932edbaf75 | ||
|
|
fcf4f69279 | ||
|
|
495e1adaca | ||
|
|
ab616a598f | ||
|
|
3d9d0627d7 | ||
|
|
1e97c0c598 | ||
|
|
2460f85dbe | ||
|
|
33ff366171 | ||
|
|
37649966c2 | ||
|
|
46db59d774 | ||
|
|
214c0e9355 | ||
|
|
55188c52e8 | ||
|
|
31915c5a01 | ||
|
|
cd90df8920 | ||
|
|
990049bdd1 | ||
|
|
578680285f | ||
|
|
a3164ae2d9 | ||
|
|
7dd72ceac1 | ||
|
|
fc52600c4d | ||
|
|
1672e07b2c | ||
|
|
5298d8123d | ||
|
|
458a61a177 | ||
|
|
8857cb738b | ||
|
|
75578a1906 | ||
|
|
e9ffaf5793 | ||
|
|
37620ebe39 | ||
|
|
d4aa97cf3e | ||
|
|
0d7f32fa98 | ||
|
|
79f5ebe734 | ||
|
|
5d2abc30f0 | ||
|
|
73824c859a | ||
|
|
f5d25c392b | ||
|
|
f36d143094 | ||
|
|
8a60b87081 | ||
|
|
6a47bc66d1 | ||
|
|
99c5b7e290 | ||
|
|
9a09ac8872 | ||
|
|
a45dab35bf | ||
|
|
d6d0c76f42 | ||
|
|
7eee393b80 | ||
|
|
b76542afb3 | ||
|
|
e41acf72a0 | ||
|
|
7a4c7acdfb | ||
|
|
d638221e55 | ||
|
|
c0dc49b192 | ||
|
|
bbcfda59e4 | ||
|
|
495a090503 | ||
|
|
dd68d98ac6 | ||
|
|
ce3cabfd2f | ||
|
|
162f41d45e | ||
|
|
2bd451a964 | ||
|
|
f7619c6204 | ||
|
|
0c50e02c6d | ||
|
|
dd3f4acbd0 | ||
|
|
26074c1399 | ||
|
|
176c1a8b93 | ||
|
|
d4336b3ca1 | ||
|
|
5b0c9f2c11 | ||
|
|
5c3b192433 | ||
|
|
1c254d64ef | ||
|
|
9ffcbf9934 | ||
|
|
846cbc728c | ||
|
|
114bd19377 | ||
|
|
8561de7e73 | ||
|
|
8bc55899e6 | ||
|
|
8faa877c45 | ||
|
|
cd0b99ae5d | ||
|
|
d4594f02ed | ||
|
|
da14e149b1 | ||
|
|
b67068e986 | ||
|
|
567cec1824 | ||
|
|
4b829603d0 | ||
|
|
fadad74d48 | ||
|
|
60a52943f6 | ||
|
|
fa79e3c5ef | ||
|
|
989b8f059b | ||
|
|
1349b5241c | ||
|
|
fbd3b8f06d | ||
|
|
5763da07ff | ||
|
|
13157c75b1 | ||
|
|
0a1687eed5 | ||
|
|
bb350b0b5f | ||
|
|
8fa2204afe | ||
|
|
61edede7a6 | ||
|
|
3762622ee9 | ||
|
|
c985a8987b | ||
|
|
b12e4ba357 | ||
|
|
ffff84ee55 | ||
|
|
32c33e64df | ||
|
|
2d848994f4 | ||
|
|
6b78789ea3 | ||
|
|
8e32ae8246 | ||
|
|
1b206e495b | ||
|
|
df70b327e9 | ||
|
|
3caa0f8453 | ||
|
|
d9871b59f0 | ||
|
|
03383c3824 | ||
|
|
ff532469a5 | ||
|
|
d304f53895 | ||
|
|
ede635ad99 | ||
|
|
d128e42f76 | ||
|
|
1c964c865b | ||
|
|
78f84441ff | ||
|
|
8a9bd1ee0b | ||
|
|
dd03623065 | ||
|
|
761994a5f8 | ||
|
|
197125bdda | ||
|
|
7d834a0ae8 | ||
|
|
34466f745b | ||
|
|
0b557a0b8c | ||
|
|
073dea2624 | ||
|
|
a204574b02 | ||
|
|
4eef08911a | ||
|
|
920152bb17 | ||
|
|
e77d9026e1 | ||
|
|
23e92da0b5 | ||
|
|
f5a6bb389e | ||
|
|
9b33c9a685 | ||
|
|
eeb4f632bf | ||
|
|
9c8a6f7b04 | ||
|
|
a4704d72bd | ||
|
|
81ef0b1ce2 | ||
|
|
3786170a89 | ||
|
|
66bc89f186 | ||
|
|
1e45edd548 | ||
|
|
4dfc7ea358 | ||
|
|
5e1d5fc499 | ||
|
|
00558c3421 | ||
|
|
1d70018cf4 | ||
|
|
80cdf13f6c | ||
|
|
a1fe6039d8 | ||
|
|
8e3a496b8b | ||
|
|
26ec3cfcea | ||
|
|
0c30bcbf3e | ||
|
|
f29fe21ddd | ||
|
|
3676a6d87a | ||
|
|
3dcf81dbb6 | ||
|
|
d9176d4267 | ||
|
|
c242091b4e | ||
|
|
7e5ccddf7e | ||
|
|
2fb3bd8728 | ||
|
|
9832e5c6d6 | ||
|
|
5c40daaf1c | ||
|
|
b24487a14b | ||
|
|
227d947d4c | ||
|
|
144baf64fe | ||
|
|
2b4b86a41b | ||
|
|
a6e1c3ed17 | ||
|
|
62cdf4a2f8 | ||
|
|
c9589d33d3 | ||
|
|
57bb4513c3 | ||
|
|
82440cac30 | ||
|
|
71e17df03a | ||
|
|
79a511226f | ||
|
|
12346b368a | ||
|
|
533cfa42c7 | ||
|
|
53bdf5e246 | ||
|
|
1a0af51f6f | ||
|
|
08b60115e3 | ||
|
|
e25619e23d | ||
|
|
6d38b1b09e | ||
|
|
55a80e768a | ||
|
|
3206eb674a | ||
|
|
37a7ef2160 | ||
|
|
0a981e02f4 | ||
|
|
79f6e61c37 | ||
|
|
12287e70fc | ||
|
|
0b68fa4038 | ||
|
|
f1081a3d68 | ||
|
|
4e21703503 | ||
|
|
d06cd7aa39 | ||
|
|
ce2a6b7c5a | ||
|
|
121e3c2f7f | ||
|
|
006fcbbf46 | ||
|
|
71d8999e7c | ||
|
|
4ee70a9ee4 | ||
|
|
cbd8c0d1e7 | ||
|
|
0374ae0a74 | ||
|
|
603f891a37 | ||
|
|
5eb007cc31 | ||
|
|
615b2c789d | ||
|
|
55494fd9cf | ||
|
|
147f198d7c | ||
|
|
c5210d9fab | ||
|
|
a863a8f9ac | ||
|
|
9d0ef35d25 | ||
|
|
95090974e9 | ||
|
|
089ffa73dc | ||
|
|
ec8f54d1ee | ||
|
|
7e820b093d | ||
|
|
e6b07f66d5 | ||
|
|
028179de25 | ||
|
|
039a6d79e6 | ||
|
|
a7059e6818 | ||
|
|
44eb178fd7 | ||
|
|
a15b02983e | ||
|
|
2a2e4d2e8d | ||
|
|
75e4e5d48b | ||
|
|
dee212fc90 | ||
|
|
b288bcf4a6 | ||
|
|
fb12b715bd | ||
|
|
2f2e973552 | ||
|
|
7def7df897 | ||
|
|
8208a7f4d5 | ||
|
|
77eb5f7625 | ||
|
|
ef72b147ae | ||
|
|
d74ca1bbaa | ||
|
|
2d9bce8a7f | ||
|
|
b6203d512c | ||
|
|
b47cc8eb8f | ||
|
|
7d41cadc99 | ||
|
|
afd7db3a69 | ||
|
|
97165b8711 | ||
|
|
2f85606d08 | ||
|
|
1c31b35d8c | ||
|
|
3ee1f0b402 | ||
|
|
f69f9809a1 | ||
|
|
528bcc286e | ||
|
|
b9c1694dad | ||
|
|
61662553b5 | ||
|
|
2b8f2cc113 | ||
|
|
636e05e1c4 | ||
|
|
d230a210f8 | ||
|
|
ce1e43c603 | ||
|
|
c83f7c2473 | ||
|
|
5720cdc8c9 | ||
|
|
cde829fa7c | ||
|
|
a4e4d98598 | ||
|
|
8f41aa69cd | ||
|
|
f7dda7fcc2 | ||
|
|
2e0b72f005 | ||
|
|
3dac629f2b | ||
|
|
0fb9fc6565 | ||
|
|
8e8bc3dc0d | ||
|
|
38dc276b22 | ||
|
|
654897510d | ||
|
|
1a00be0fdf | ||
|
|
a9f47cda39 | ||
|
|
ca2bbc13a3 | ||
|
|
67314e9f15 | ||
|
|
ba98b5cb22 | ||
|
|
97bf10120c | ||
|
|
9ba69f8878 | ||
|
|
0b8767f990 | ||
|
|
52257c4d6b | ||
|
|
76085f0bb0 | ||
|
|
3d883fd77f | ||
|
|
05cdb821dc | ||
|
|
a358feb82e | ||
|
|
3160df3ede | ||
|
|
e1a723586c | ||
|
|
ba8604a3df | ||
|
|
048d253120 | ||
|
|
50ac3cb712 | ||
|
|
34de7a1747 | ||
|
|
2920e797c9 | ||
|
|
31fb733e05 | ||
|
|
8e66c58ec9 | ||
|
|
ad1dfc4701 | ||
|
|
05ac2bde9a | ||
|
|
21d210b8ce | ||
|
|
a5551604c6 | ||
|
|
31f444e4ba | ||
|
|
00af984bac | ||
|
|
edce44024b | ||
|
|
ed99d809f1 | ||
|
|
4bb9914fa3 | ||
|
|
d824002f01 | ||
|
|
e7bddb5a87 | ||
|
|
17741a0fbf | ||
|
|
f5c30b383a | ||
|
|
fa0c3d2b9f | ||
|
|
53b9ec145c | ||
|
|
e278bb7c91 | ||
|
|
76976493f1 | ||
|
|
e17b9907f8 | ||
|
|
6e0049fded | ||
|
|
daf7c81e5d | ||
|
|
84f0685929 | ||
|
|
1382c0c969 | ||
|
|
b8ba6fe4e1 | ||
|
|
5d76c0feb1 | ||
|
|
119863d1ea | ||
|
|
fc1617531e | ||
|
|
5ab9b7c331 | ||
|
|
e14beb2fd7 | ||
|
|
36d26de746 | ||
|
|
1cb2ddaa26 | ||
|
|
6c8eb8be17 | ||
|
|
08fc0852d7 | ||
|
|
d24c95da16 | ||
|
|
b4c327be38 | ||
|
|
f77307c28b | ||
|
|
04ecf813bd | ||
|
|
8ff99300e4 | ||
|
|
d4c1ceccac | ||
|
|
dd3e10eb42 | ||
|
|
4af0c197e1 | ||
|
|
9a990ccfaf | ||
|
|
9d090017b5 | ||
|
|
02d5775aff | ||
|
|
c6ec49e90d | ||
|
|
6922124927 | ||
|
|
8070b917a3 | ||
|
|
7d74125936 | ||
|
|
61e654b852 | ||
|
|
f81e936a49 | ||
|
|
5f59ca6a2f | ||
|
|
fbd9e6f0db | ||
|
|
67627c19d7 | ||
|
|
827c07a046 | ||
|
|
ab8a76048f | ||
|
|
a50e8593ca | ||
|
|
5c5313ba73 | ||
|
|
2001d180af | ||
|
|
42865fbc92 | ||
|
|
89ac11c309 | ||
|
|
e1cdc79bdc | ||
|
|
bde345766f | ||
|
|
a3eedc294d | ||
|
|
77a637b7f0 | ||
|
|
a2e807debf | ||
|
|
3a79607103 | ||
|
|
a57574cbba | ||
|
|
23ba0166fe | ||
|
|
d976de4104 | ||
|
|
c28a945295 | ||
|
|
050c29912d | ||
|
|
1e22b49a49 | ||
|
|
b7cfed9600 | ||
|
|
982418e9c0 | ||
|
|
d93e586d9b | ||
|
|
0bc5c8a162 | ||
|
|
e399f7927f | ||
|
|
71dc5435c9 | ||
|
|
0f3d8d17a0 | ||
|
|
9dc14c0bce | ||
|
|
75b4add949 | ||
|
|
95fb2146c4 | ||
|
|
2484d2e192 | ||
|
|
a09e4c11e6 | ||
|
|
bdabdb519f | ||
|
|
a773c264c0 | ||
|
|
30486f4445 | ||
|
|
2912a9f99b | ||
|
|
6fbdf9c2b3 | ||
|
|
9476e7039b | ||
|
|
03726b9956 | ||
|
|
dad799d428 | ||
|
|
b80ac49a04 | ||
|
|
9cc7b0945b | ||
|
|
4d7a673887 | ||
|
|
edd207fef9 | ||
|
|
d9e4a5ab86 | ||
|
|
a31500eb96 | ||
|
|
c09cf0fa07 | ||
|
|
92dae39383 | ||
|
|
523c59d329 | ||
|
|
9beded8cfb | ||
|
|
cadd9ec028 | ||
|
|
82617c79b2 | ||
|
|
627302e060 | ||
|
|
35ea205ea1 | ||
|
|
e04ab25642 | ||
|
|
b4418f72ff | ||
|
|
f476a49387 | ||
|
|
356ede0c8e | ||
|
|
b5f0cf9adc | ||
|
|
2591abd535 | ||
|
|
d8fd3e4e61 | ||
|
|
93bc90203a | ||
|
|
314dea3f5a | ||
|
|
a543925e64 | ||
|
|
3f250084b0 | ||
|
|
52ae977fb7 | ||
|
|
275d3e3da5 | ||
|
|
470cad14ad | ||
|
|
6d0a14a0e5 | ||
|
|
56a60d0acf | ||
|
|
1c6865c329 | ||
|
|
9e46fcb219 | ||
|
|
5fb9cc4c39 | ||
|
|
a311df9c2d | ||
|
|
f23b61d164 | ||
|
|
8b184ca82c | ||
|
|
0cf4329936 | ||
|
|
b324f9d912 | ||
|
|
3453db0985 | ||
|
|
72813ec1fa | ||
|
|
01899f387e | ||
|
|
58ba7095f3 | ||
|
|
f6e192bfaf | ||
|
|
0849e2d21e | ||
|
|
bbd5c8ef2a | ||
|
|
30c11920d9 | ||
|
|
76fb3b54e2 | ||
|
|
304ffab112 | ||
|
|
74c02363e7 | ||
|
|
7be419a2ca | ||
|
|
bcb9224301 | ||
|
|
b1552052bb | ||
|
|
9d44b10aff | ||
|
|
168a70c273 | ||
|
|
33ff8225d7 | ||
|
|
a0c184f292 | ||
|
|
4b6baae8b8 | ||
|
|
ec3bb4cf13 | ||
|
|
12d3493812 | ||
|
|
9d52cb6adc | ||
|
|
50a6a28e73 | ||
|
|
4b8eae1084 | ||
|
|
52d1989850 | ||
|
|
337974e675 | ||
|
|
31b1369752 | ||
|
|
5a9e394827 | ||
|
|
65de5fa71e | ||
|
|
b61708c47f | ||
|
|
13128464aa | ||
|
|
ee8e4343bc | ||
|
|
ec4dc6905f | ||
|
|
900b50642a | ||
|
|
a990b0ff77 | ||
|
|
753b9ca15c | ||
|
|
fe98a4ca48 | ||
|
|
53f3f19f6d | ||
|
|
8997248abb | ||
|
|
db3c98b45a | ||
|
|
7c39e36c5a | ||
|
|
79af38cd1b | ||
|
|
ef34c06c8f | ||
|
|
41284ffc0a | ||
|
|
ff3c0c6689 | ||
|
|
3d2f564478 | ||
|
|
f9b0d8d0bf | ||
|
|
d68e4d564d | ||
|
|
a22f8b09ef | ||
|
|
cff337723e | ||
|
|
7515a9800c | ||
|
|
ebd9bbed90 | ||
|
|
e73e207b57 | ||
|
|
f910b7ee6b | ||
|
|
e9512e5a46 | ||
|
|
e9b67ff6f9 | ||
|
|
63bf55a748 | ||
|
|
191b0d7be4 | ||
|
|
dcf4b1d8f2 | ||
|
|
207bd6c31c | ||
|
|
43e207f9d0 | ||
|
|
9fb56f31d3 | ||
|
|
e000cfd7c6 | ||
|
|
5698bc3e20 | ||
|
|
4540b85ade | ||
|
|
42cd153ac4 | ||
|
|
361478eca7 | ||
|
|
220c61f124 | ||
|
|
867b719de5 | ||
|
|
c43ecf924c | ||
|
|
21147a8163 | ||
|
|
a740d530c3 | ||
|
|
1d45b466a3 | ||
|
|
4357c625c4 | ||
|
|
7828853e8c | ||
|
|
da9d1c0fec | ||
|
|
860a9a77b0 | ||
|
|
687668ec75 | ||
|
|
4d247fa6a1 | ||
|
|
9580a763e1 | ||
|
|
a6addfa55a | ||
|
|
143b002d7e | ||
|
|
c42f512c2a | ||
|
|
787f791a4e | ||
|
|
0f22318c2f | ||
|
|
098f779a79 | ||
|
|
b6de602b5b | ||
|
|
687541505b | ||
|
|
c59dc61cf0 | ||
|
|
f082aa3186 | ||
|
|
ef29b6e8dc | ||
|
|
44c2b38cde | ||
|
|
5431c931bb | ||
|
|
eadf169f41 | ||
|
|
f890dabb7e | ||
|
|
1725829477 | ||
|
|
2409aa6475 | ||
|
|
29531bf414 | ||
|
|
e6a88c4e27 | ||
|
|
94c3eb0533 | ||
|
|
2cb4ab936d | ||
|
|
bd8b908f50 | ||
|
|
e56737de0b | ||
|
|
4c7b2d202c | ||
|
|
47e49215c3 | ||
|
|
af8edbc21c | ||
|
|
219b25cd98 | ||
|
|
382386159c | ||
|
|
f6f6b79221 | ||
|
|
9c98e1e7e5 | ||
|
|
a5f65dcfd8 | ||
|
|
e9a6d6039b | ||
|
|
c5db07cf0a | ||
|
|
79bb3cc80d | ||
|
|
0a44bbb7a1 | ||
|
|
5a46a1e32e | ||
|
|
6baeede302 | ||
|
|
d43b1dbf92 | ||
|
|
624e59d381 | ||
|
|
8a46c39fec | ||
|
|
03a5750d90 | ||
|
|
8c80d0bedf | ||
|
|
eeb58d9232 | ||
|
|
1831062529 | ||
|
|
fb2d8061c8 | ||
|
|
243cc4f9fb | ||
|
|
2db2060f85 | ||
|
|
73b95c4307 | ||
|
|
ae4c160654 | ||
|
|
f9d968071e | ||
|
|
3d26cfca90 | ||
|
|
a281a88f49 | ||
|
|
343f25cc89 | ||
|
|
7577894d2a | ||
|
|
248407c70e | ||
|
|
7e7093e821 | ||
|
|
0a14007db2 | ||
|
|
71c4a07687 | ||
|
|
241533d123 | ||
|
|
6fbc6b2750 | ||
|
|
98c47162da | ||
|
|
ffbccd1ff1 | ||
|
|
4ad01f07f7 | ||
|
|
208e7ec34b | ||
|
|
89c7eae2c8 | ||
|
|
8a7c6340e8 | ||
|
|
df62f36c5f | ||
|
|
3734710f1c | ||
|
|
8c6cfaded0 | ||
|
|
9478d14817 | ||
|
|
9b0b7b2e28 | ||
|
|
967047cb04 | ||
|
|
1d570541e0 | ||
|
|
ca0ea80192 | ||
|
|
55754c6697 | ||
|
|
f12ef3b933 | ||
|
|
6a4164a293 | ||
|
|
982fa19628 | ||
|
|
e9ed8c86e4 | ||
|
|
a507293bd8 | ||
|
|
8ef9e13e09 | ||
|
|
1107c5a971 | ||
|
|
ceebb09d89 | ||
|
|
b9723b6cad | ||
|
|
844d83cc0e | ||
|
|
48f1497af6 | ||
|
|
23cab4e694 | ||
|
|
f30bf2ca87 | ||
|
|
2ff3c8396e | ||
|
|
4e3f4f8f24 | ||
|
|
f67db5ef43 | ||
|
|
5fa42b200a | ||
|
|
002f98c720 | ||
|
|
ea5015be14 | ||
|
|
6a501c1380 | ||
|
|
47e11a5824 | ||
|
|
b72897677e | ||
|
|
aadf6da7bf | ||
|
|
e58c344649 | ||
|
|
93b247c483 | ||
|
|
88d016b8ca | ||
|
|
00371ad5eb | ||
|
|
417183165e | ||
|
|
8070b78e3d | ||
|
|
8d32a9a4d8 | ||
|
|
b0becec26e | ||
|
|
7a238bd0de | ||
|
|
cd347990c5 | ||
|
|
bedbd2e315 | ||
|
|
16e9d887ab | ||
|
|
9729450d2c | ||
|
|
6927c1b910 | ||
|
|
55e13a906d | ||
|
|
a29984f049 | ||
|
|
269d49b759 | ||
|
|
8a3eb4f2ba | ||
|
|
1dc7bfccc4 | ||
|
|
66884e05cf | ||
|
|
f28bc32129 | ||
|
|
488859d03a | ||
|
|
1b8edd7448 | ||
|
|
c9a3d8b0c2 | ||
|
|
7f6837a105 | ||
|
|
b23499de12 | ||
|
|
5a31176ea6 | ||
|
|
91e8b0cf81 | ||
|
|
d15e0f9e43 | ||
|
|
3cf59a2876 | ||
|
|
dfae3f6d9e | ||
|
|
a0969b1f29 | ||
|
|
35aeef417e | ||
|
|
31cfe7cfe6 | ||
|
|
4b44befefa | ||
|
|
55694f82b1 | ||
|
|
d22ce3128c | ||
|
|
2114f61164 | ||
|
|
32f628d3d2 | ||
|
|
ce13ead0cd | ||
|
|
d0c9e4fc07 | ||
|
|
f547d761f4 | ||
|
|
ddbe8e7b29 | ||
|
|
0dddcd1ffa | ||
|
|
ed94479775 | ||
|
|
97a4f27af6 | ||
|
|
0e2a4984b1 | ||
|
|
c091b9ac63 | ||
|
|
541e006ad0 | ||
|
|
ae20f2fd7d | ||
|
|
be5ae7ae9a | ||
|
|
8f062ddc54 | ||
|
|
ca7628caae | ||
|
|
0bef6769ba | ||
|
|
8eaeb1bc5e | ||
|
|
21b8e10560 | ||
|
|
73ec1311c0 | ||
|
|
a2a64e9410 | ||
|
|
7c23a2f2aa | ||
|
|
f089449bf2 | ||
|
|
759e233aaa | ||
|
|
96f288861b | ||
|
|
59ec8aa280 | ||
|
|
bb6d84c255 | ||
|
|
2f6bbe7593 | ||
|
|
3cc15ee909 | ||
|
|
3efca70a56 | ||
|
|
22e4d2ff34 | ||
|
|
992830f2c2 | ||
|
|
5584bb4e6f | ||
|
|
52e8761dea | ||
|
|
05d803ddd3 | ||
|
|
2893b25db1 | ||
|
|
c025f25367 | ||
|
|
eb99571a98 | ||
|
|
684a9aae9d | ||
|
|
7860db63cf | ||
|
|
1b2896aff2 | ||
|
|
99d3ba3c16 | ||
|
|
5958aee5d1 | ||
|
|
ce9f4b0849 | ||
|
|
b12614e5e1 | ||
|
|
580dd952af | ||
|
|
a4020c76df | ||
|
|
4de3b6a340 | ||
|
|
e78a2884e2 | ||
|
|
be64ba35cf | ||
|
|
fe1b858dff | ||
|
|
a3bee91e35 | ||
|
|
bfa04c1942 | ||
|
|
1d9f208cdd | ||
|
|
e2957797b5 | ||
|
|
d7a3ea443c | ||
|
|
cc999d3654 | ||
|
|
6d0d439acf | ||
|
|
083024cb86 | ||
|
|
37a015f983 | ||
|
|
7c3abe7ba7 | ||
|
|
aabe83c444 | ||
|
|
3a459c95c9 | ||
|
|
49914f3307 | ||
|
|
20efe7b533 | ||
|
|
1ef2167254 | ||
|
|
606584cb14 | ||
|
|
5e05638fef | ||
|
|
edbe0f451d | ||
|
|
323aa350dc | ||
|
|
6cd67e652b | ||
|
|
7b6081ac29 | ||
|
|
d51c01e5ad | ||
|
|
986387d9a2 | ||
|
|
097dd8180c | ||
|
|
31c87cdbe5 | ||
|
|
b8902c272d | ||
|
|
24a8c3dd40 | ||
|
|
30c0cc9479 | ||
|
|
89ee6971eb | ||
|
|
8f45b5e78b | ||
|
|
d53889b617 | ||
|
|
7297a6d462 | ||
|
|
67fca03246 | ||
|
|
a272a0a6e9 | ||
|
|
52555c2337 | ||
|
|
a74bc582ff | ||
|
|
00eedfbfda | ||
|
|
308ec688b1 | ||
|
|
44a050a061 | ||
|
|
ccfeef3e8e | ||
|
|
0b7121341f | ||
|
|
40837e9d56 | ||
|
|
a6da3f2c89 | ||
|
|
cc73f09745 | ||
|
|
053c6992ef | ||
|
|
d628afab06 | ||
|
|
b3062f7bc3 | ||
|
|
6b9c2a5c5e | ||
|
|
99e1ba9c89 | ||
|
|
417d398a13 | ||
|
|
a0a81bf533 | ||
|
|
2322266b98 | ||
|
|
d102c8be12 | ||
|
|
3f9ddd03d2 | ||
|
|
7e0cee00b2 | ||
|
|
3a29e55bf7 | ||
|
|
ec71dbcc0f | ||
|
|
e9871fe88b | ||
|
|
4c55cdc255 | ||
|
|
37bda00e36 | ||
|
|
8171b5e9fb | ||
|
|
cc85f22133 | ||
|
|
4f1f851a8d | ||
|
|
cdc8d602a0 | ||
|
|
3da7fba0bb | ||
|
|
622a1c3154 | ||
|
|
feee8da8b6 | ||
|
|
610e1dbc7f | ||
|
|
28f7c78bf8 | ||
|
|
707258519c | ||
|
|
37a2235f57 | ||
|
|
23feeafe0b | ||
|
|
44131f9b01 | ||
|
|
b31740ac5c | ||
|
|
1b270451b2 | ||
|
|
8bf677ff3b | ||
|
|
b5a51de16b | ||
|
|
36edbf379c | ||
|
|
e107b9259a | ||
|
|
f7784f2023 | ||
|
|
8ea085553a | ||
|
|
7dad983959 | ||
|
|
2cec7ddcc2 | ||
|
|
f6207cc62a | ||
|
|
14b8f427a2 | ||
|
|
acc55650d4 | ||
|
|
4fc62cb62b | ||
|
|
54d67e2378 | ||
|
|
19501d297d | ||
|
|
c59cadd252 | ||
|
|
7c855be3ab | ||
|
|
0d988c3aa9 | ||
|
|
cc6aca6aa1 | ||
|
|
1fdf9b39c9 | ||
|
|
0673a966c5 | ||
|
|
779dd755ca | ||
|
|
48ae79e8c4 | ||
|
|
8a8eb0d617 | ||
|
|
30641db85f | ||
|
|
231f3d5c61 | ||
|
|
a234cdf90e | ||
|
|
a19333d84b | ||
|
|
afd67d10f0 | ||
|
|
600dd4264e | ||
|
|
2d6c9489c8 | ||
|
|
fbe0b23070 | ||
|
|
8f9c4194bc | ||
|
|
14c6e94c56 | ||
|
|
bcd423aaa3 | ||
|
|
e0f91b06b2 | ||
|
|
5a2143290a | ||
|
|
6886680a62 | ||
|
|
b54db8c447 | ||
|
|
795b6fcdc6 | ||
|
|
9474a44b12 | ||
|
|
a62db935c3 | ||
|
|
6385d0fed1 | ||
|
|
cb9263cc4a | ||
|
|
cf9c861331 | ||
|
|
8f445a6136 | ||
|
|
e49cf1899b | ||
|
|
30d4aeb463 | ||
|
|
3184dba80c | ||
|
|
8885f608a2 | ||
|
|
64f011c8bb | ||
|
|
06bb6b344b | ||
|
|
a88e3f8fc1 | ||
|
|
924531341e | ||
|
|
49b50538ab | ||
|
|
b354c9caf6 | ||
|
|
72218f3dd3 | ||
|
|
b8b4730535 | ||
|
|
9f05a3ee15 | ||
|
|
8a62587a6e | ||
|
|
384e01a3d8 | ||
|
|
238fdcb4c6 | ||
|
|
27d9f5f374 | ||
|
|
e3893df5ef | ||
|
|
44749f09ec | ||
|
|
d47a06cfd2 | ||
|
|
8f767d4a5a | ||
|
|
fca214ad12 | ||
|
|
3952c11d66 | ||
|
|
5b4d70c234 | ||
|
|
59a7559c05 | ||
|
|
56b75e93ce | ||
|
|
af298b35a1 | ||
|
|
78c3f161d7 | ||
|
|
9b1ad63077 | ||
|
|
24769c2a8a | ||
|
|
27f0ba2633 | ||
|
|
a013b8153e | ||
|
|
a615130b19 | ||
|
|
8f6d4b4344 | ||
|
|
add0351d20 | ||
|
|
fb712064d2 | ||
|
|
1d6a451bb8 | ||
|
|
d2a1c969e4 | ||
|
|
f116a8fc8e | ||
|
|
49413cb9d2 | ||
|
|
9bc369f5a9 | ||
|
|
1a25b3d7cd | ||
|
|
9581c363b1 | ||
|
|
9efec15d0a | ||
|
|
5854d42672 | ||
|
|
c39e85c17e | ||
|
|
f3b7839d31 | ||
|
|
c5bbb30e5d | ||
|
|
99a81ba269 | ||
|
|
23cdee4fa1 | ||
|
|
b6422d74ad | ||
|
|
c80f9bed97 | ||
|
|
73eb8f8546 | ||
|
|
01a06be091 | ||
|
|
73137b404a | ||
|
|
e7ee8f9d1c | ||
|
|
ad5c2d0a4d | ||
|
|
0c11418caf | ||
|
|
aa7ca5f03d | ||
|
|
90aab1ab7e | ||
|
|
c1988d6b74 | ||
|
|
3cfeac6f3b | ||
|
|
fa94a4f57a | ||
|
|
86f8dad264 | ||
|
|
1e235e3bde | ||
|
|
0d64f5a358 | ||
|
|
c0c731b3e6 | ||
|
|
87d7ed1750 | ||
|
|
89e86606ea | ||
|
|
016bf5d269 | ||
|
|
6957d1d109 | ||
|
|
3ecf8659a1 | ||
|
|
8464ce30d5 | ||
|
|
3de9ff387d | ||
|
|
1d59dd1275 | ||
|
|
36acd164da | ||
|
|
122e6b2ca1 | ||
|
|
6254038ea3 | ||
|
|
e56ee5fa4e | ||
|
|
6e01530684 | ||
|
|
5665ea96d4 | ||
|
|
58196f046e | ||
|
|
f4a8ee56ab | ||
|
|
89ea288859 | ||
|
|
d40a7acf8a | ||
|
|
f2c1c30ac4 | ||
|
|
93291feb58 | ||
|
|
dfd7e142c1 | ||
|
|
9d5ab7dbcc | ||
|
|
f62ab0e351 | ||
|
|
c2413ba542 | ||
|
|
3f9a708789 | ||
|
|
d8fcfc9607 | ||
|
|
5631ed0a97 | ||
|
|
69fc4aed1d | ||
|
|
e36fe2ced5 | ||
|
|
d9c9cdd6be | ||
|
|
884ab38334 | ||
|
|
ddca02cb7b | ||
|
|
6d9edda822 | ||
|
|
bcb788fe0b | ||
|
|
da29a92729 | ||
|
|
d548d7427f | ||
|
|
9e1e7b235d | ||
|
|
ba91c489a6 | ||
|
|
76a1012370 | ||
|
|
083a194084 | ||
|
|
d47b615d5f | ||
|
|
f5a622cdf1 | ||
|
|
09df8e6125 | ||
|
|
13b9a63e7f | ||
|
|
13f1286e66 | ||
|
|
6794a57e26 | ||
|
|
259cf47169 | ||
|
|
716156e5a0 | ||
|
|
14c81099af | ||
|
|
6b7d3d6dd4 | ||
|
|
c9f9417d4b | ||
|
|
1288b4986d | ||
|
|
d7b33656b0 | ||
|
|
f046db1d17 | ||
|
|
6eaf9f3459 | ||
|
|
a83781f70f | ||
|
|
3aee61f427 | ||
|
|
f52556becd | ||
|
|
5ca95a55ca | ||
|
|
5e335f9a5f | ||
|
|
9b1ab949c7 | ||
|
|
8d1d9b0734 | ||
|
|
506c603e30 | ||
|
|
316e686ed5 | ||
|
|
b245bbb10e | ||
|
|
4453d3fab8 | ||
|
|
b4492042d9 | ||
|
|
c741d969de | ||
|
|
35c34ec6d4 | ||
|
|
2e8cdd071a | ||
|
|
7e71bccf28 | ||
|
|
539b9b12a7 | ||
|
|
b8ee00bddd | ||
|
|
43ae01b4c8 | ||
|
|
018ebd4087 | ||
|
|
0dd530a4d1 | ||
|
|
882170559d | ||
|
|
aef18c4413 | ||
|
|
0e6e85cf19 | ||
|
|
ee726d2c23 | ||
|
|
ea6bec851b | ||
|
|
0d6a482d32 | ||
|
|
285761465b | ||
|
|
1c1d9221c8 | ||
|
|
c2d0acb063 | ||
|
|
26e5535d21 | ||
|
|
83c267faa1 | ||
|
|
421f541271 | ||
|
|
22bea4c975 | ||
|
|
af4d955806 | ||
|
|
6f32c41da3 | ||
|
|
0dc4ef1670 | ||
|
|
c22d243728 | ||
|
|
725d05e8ac | ||
|
|
cdde731aa4 | ||
|
|
d2f4644b4f | ||
|
|
f7e415b368 | ||
|
|
034393c362 | ||
|
|
628475e639 | ||
|
|
ab183511b7 | ||
|
|
c3897466b7 | ||
|
|
cd9e30ad7e | ||
|
|
891622c4f5 | ||
|
|
faaf101f08 | ||
|
|
2cf5fbab3b | ||
|
|
6a2e6f6828 | ||
|
|
560d436536 | ||
|
|
06a6969a24 | ||
|
|
7b7f1c5d9c | ||
|
|
c8fc86a404 | ||
|
|
e432d00e0a | ||
|
|
b901a84f2b | ||
|
|
10707868db | ||
|
|
1f844153fe | ||
|
|
569f004f09 | ||
|
|
300a4f0b45 | ||
|
|
1c643d483e | ||
|
|
8b75058783 | ||
|
|
0ab8e4e6d4 | ||
|
|
385115cc4e | ||
|
|
9896274478 | ||
|
|
976560b0e9 | ||
|
|
c260232f61 | ||
|
|
1343cfa465 | ||
|
|
d2ef9ea552 | ||
|
|
731727cb38 | ||
|
|
c7ac555a43 | ||
|
|
4c771ae32f | ||
|
|
032eb5ec1d | ||
|
|
45ef583a3c | ||
|
|
6fa555788e | ||
|
|
3dbfa9b4cd | ||
|
|
bc294cbe2c | ||
|
|
233804aec2 | ||
|
|
6032eb6393 | ||
|
|
9ff88c1c98 | ||
|
|
6b7d0eaa9e | ||
|
|
a449a8917e | ||
|
|
e70c6cc65c | ||
|
|
bc6f0b4b6b | ||
|
|
30c6c16fa8 | ||
|
|
b5462f8f88 | ||
|
|
0fcb2a056f | ||
|
|
de555cec87 | ||
|
|
123e64ff03 | ||
|
|
9cdb7dbae2 | ||
|
|
a62c02a9cf | ||
|
|
d88eb92fbe | ||
|
|
de53f8e940 | ||
|
|
aafcaa4ef8 | ||
|
|
e4102e985b | ||
|
|
2c818c6597 | ||
|
|
cc693fbc20 | ||
|
|
d5b9ea2ce2 | ||
|
|
14a01ad168 | ||
|
|
124af9c22b | ||
|
|
2a5364b3ce | ||
|
|
3ce1679717 | ||
|
|
3cfb34af16 | ||
|
|
ecff61a193 | ||
|
|
971441fe2f | ||
|
|
ad2078df37 | ||
|
|
3853fa28c7 | ||
|
|
5026e92142 | ||
|
|
c1c6d24c32 | ||
|
|
c1f3a55d04 | ||
|
|
36027477b1 | ||
|
|
b779bb0461 | ||
|
|
f64570c5db | ||
|
|
95fbf69206 | ||
|
|
e79b379e67 | ||
|
|
bf393f78ab | ||
|
|
d57dd9faee | ||
|
|
3b26f6c526 | ||
|
|
c238a6a36f | ||
|
|
0093684f93 | ||
|
|
a480107a4f | ||
|
|
35ef6b7048 | ||
|
|
6a6e5088c1 | ||
|
|
091ce0029b | ||
|
|
a802651513 | ||
|
|
d01a959ea8 | ||
|
|
6b2841793c | ||
|
|
5dfa3d5715 | ||
|
|
c7f29e77b8 | ||
|
|
7af9263e4a | ||
|
|
9e906d39a3 | ||
|
|
61543ad19b | ||
|
|
ad3500eae6 | ||
|
|
025f8fc66d | ||
|
|
74994a36c4 | ||
|
|
2669b5c4ae | ||
|
|
d5d1ec8b1c | ||
|
|
1c1e63e322 | ||
|
|
5d785cb862 | ||
|
|
7c237ea444 | ||
|
|
99036a8aaf | ||
|
|
db0643676a | ||
|
|
319e985616 | ||
|
|
69ebee9bf2 | ||
|
|
139ad7c3ee | ||
|
|
8dc8ceca2d | ||
|
|
35c4cbd438 | ||
|
|
c6d58640b2 | ||
|
|
d56ec3c1da | ||
|
|
dcdbd13674 | ||
|
|
02bd284390 | ||
|
|
d3708e1e79 | ||
|
|
ea3804efb7 | ||
|
|
c843a9681f | ||
|
|
229f88dfcd | ||
|
|
3486a53132 | ||
|
|
9fb1e2a028 | ||
|
|
0704dd7b80 | ||
|
|
eef9e3e948 | ||
|
|
badd23b717 | ||
|
|
08e07e67a6 | ||
|
|
41093304ae | ||
|
|
6011453a14 | ||
|
|
a5f985c9cc | ||
|
|
0b75c5194f | ||
|
|
b8170a38ec | ||
|
|
3ff20fbeb4 | ||
|
|
786b0c7b6b | ||
|
|
aa022adecb | ||
|
|
ce9a3a3f27 | ||
|
|
373f514de6 | ||
|
|
073e9758f1 | ||
|
|
91859d8a99 | ||
|
|
2faacc1b43 | ||
|
|
d5d39da999 | ||
|
|
3f02247791 | ||
|
|
bcda03d948 | ||
|
|
a9e0028007 | ||
|
|
7bb2a6ccf0 | ||
|
|
efaec60e6b | ||
|
|
00aae545ac | ||
|
|
ec92f6d935 | ||
|
|
f59a581c35 | ||
|
|
abf865beca | ||
|
|
c18e0838d6 | ||
|
|
08cb7f9dff | ||
|
|
c9009b1e54 | ||
|
|
77e7ddda7e | ||
|
|
8d55ffdd87 | ||
|
|
6b69f6371c | ||
|
|
0e068b4670 | ||
|
|
3c89b6838c | ||
|
|
614cf89d55 | ||
|
|
396f6b0b50 | ||
|
|
1dca1441ee | ||
|
|
aa3d7986d8 | ||
|
|
bd19df886b | ||
|
|
3fb1ab94d7 | ||
|
|
9a298364f1 | ||
|
|
0bbec49880 | ||
|
|
16af948b36 | ||
|
|
ac28e9f880 | ||
|
|
b4170b539d | ||
|
|
59f60e1fe2 | ||
|
|
8f660978e4 | ||
|
|
6b69241e17 | ||
|
|
c1a482a8da | ||
|
|
69b8f58735 | ||
|
|
29b21ebb7a | ||
|
|
f72836ba14 | ||
|
|
4af311894b | ||
|
|
66c37a2d40 | ||
|
|
66b5b7a0c5 | ||
|
|
1712a024e5 | ||
|
|
cbec87e181 | ||
|
|
19bc2fa084 | ||
|
|
412b28b219 | ||
|
|
80799e28a0 |
396 changed files with 31017 additions and 29445 deletions
12
.dockerignore
Normal file
12
.dockerignore
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
# this file uses slightly different syntax than .gitignore,
|
||||
# e.g. ".tox/" will not ignore .tox directory
|
||||
|
||||
# well, official docker build should be done on clean git checkout
|
||||
# anyway, so .tox should be empty... But I'm sure people will try to
|
||||
# test docker on their git working directories.
|
||||
|
||||
.git
|
||||
.tox
|
||||
venv
|
||||
venv3
|
||||
docs
|
||||
25
.gitignore
vendored
25
.gitignore
vendored
|
|
@ -1,3 +1,24 @@
|
|||
*.pyc
|
||||
trustify/protocol/chocolate_pb2.py
|
||||
m3
|
||||
*.egg-info/
|
||||
.eggs/
|
||||
build/
|
||||
dist/
|
||||
/venv/
|
||||
/venv3/
|
||||
/.tox/
|
||||
letsencrypt.log
|
||||
|
||||
# coverage
|
||||
.coverage
|
||||
/htmlcov/
|
||||
|
||||
/.vagrant
|
||||
|
||||
# editor temporary files
|
||||
*~
|
||||
*.swp
|
||||
\#*#
|
||||
.idea
|
||||
|
||||
# auth --cert-path --chain-path
|
||||
/*.pem
|
||||
|
|
|
|||
6
.gitmodules
vendored
6
.gitmodules
vendored
|
|
@ -1,6 +0,0 @@
|
|||
[submodule "m3crypto"]
|
||||
path = m3crypto
|
||||
url = git@github.com:research/m3crypto.git
|
||||
[submodule "pygeoip"]
|
||||
path = pygeoip
|
||||
url = https://github.com/appliedsec/pygeoip.git
|
||||
333
.pylintrc
Normal file
333
.pylintrc
Normal file
|
|
@ -0,0 +1,333 @@
|
|||
[MASTER]
|
||||
|
||||
# Specify a configuration file.
|
||||
#rcfile=
|
||||
|
||||
# Python code to execute, usually for sys.path manipulation such as
|
||||
# pygtk.require().
|
||||
#init-hook=
|
||||
|
||||
# Profiled execution.
|
||||
profile=no
|
||||
|
||||
# Add files or directories to the blacklist. They should be base names, not
|
||||
# paths.
|
||||
ignore=CVS
|
||||
|
||||
# Pickle collected data for later comparisons.
|
||||
persistent=yes
|
||||
|
||||
# List of plugins (as comma separated values of python modules names) to load,
|
||||
# usually to register additional checkers.
|
||||
load-plugins=linter_plugin
|
||||
|
||||
|
||||
[MESSAGES CONTROL]
|
||||
|
||||
# Enable the message, report, category or checker with the given id(s). You can
|
||||
# either give multiple identifier separated by comma (,) or put this option
|
||||
# multiple time. See also the "--disable" option for examples.
|
||||
#enable=
|
||||
|
||||
# Disable the message, report, category or checker with the given id(s). You
|
||||
# can either give multiple identifiers separated by comma (,) or put this
|
||||
# option multiple times (only on the command line, not in the configuration
|
||||
# file where it should appear only once).You can also use "--disable=all" to
|
||||
# disable everything first and then reenable specific checks. For example, if
|
||||
# you want to run only the similarities checker, you can use "--disable=all
|
||||
# --enable=similarities". If you want to run only the classes checker, but have
|
||||
# no Warning level messages displayed, use"--disable=all --enable=classes
|
||||
# --disable=W"
|
||||
disable=fixme,locally-disabled,abstract-class-not-used
|
||||
# abstract-class-not-used cannot be disabled locally (at least in pylint 1.4.1)
|
||||
|
||||
|
||||
[REPORTS]
|
||||
|
||||
# Set the output format. Available formats are text, parseable, colorized, msvs
|
||||
# (visual studio) and html. You can also give a reporter class, eg
|
||||
# mypackage.mymodule.MyReporterClass.
|
||||
output-format=text
|
||||
|
||||
# Put messages in a separate file for each module / package specified on the
|
||||
# command line instead of printing them on stdout. Reports (if any) will be
|
||||
# written in a file name "pylint_global.[txt|html]".
|
||||
files-output=no
|
||||
|
||||
# Tells whether to display a full report or only the messages
|
||||
reports=yes
|
||||
|
||||
# Python expression which should return a note less than 10 (10 is the highest
|
||||
# note). You have access to the variables errors warning, statement which
|
||||
# respectively contain the number of errors / warnings messages and the total
|
||||
# number of statements analyzed. This is used by the global evaluation report
|
||||
# (RP0004).
|
||||
evaluation=10.0 - ((float(5 * error + warning + refactor + convention) / statement) * 10)
|
||||
|
||||
# Add a comment according to your evaluation note. This is used by the global
|
||||
# evaluation report (RP0004).
|
||||
comment=no
|
||||
|
||||
# Template used to display messages. This is a python new-style format string
|
||||
# used to format the message information. See doc for all details
|
||||
#msg-template=
|
||||
|
||||
|
||||
[BASIC]
|
||||
|
||||
# Required attributes for module, separated by a comma
|
||||
required-attributes=
|
||||
|
||||
# List of builtins function names that should not be used, separated by a comma
|
||||
bad-functions=map,filter,apply,input,file
|
||||
|
||||
# Good variable names which should always be accepted, separated by a comma
|
||||
good-names=f,i,j,k,ex,Run,_,fd,logger
|
||||
|
||||
# Bad variable names which should always be refused, separated by a comma
|
||||
bad-names=foo,bar,baz,toto,tutu,tata
|
||||
|
||||
# Colon-delimited sets of names that determine each other's naming style when
|
||||
# the name regexes allow several styles.
|
||||
name-group=
|
||||
|
||||
# Include a hint for the correct naming format with invalid-name
|
||||
include-naming-hint=no
|
||||
|
||||
# Regular expression matching correct function names
|
||||
function-rgx=[a-z_][a-z0-9_]{2,40}$
|
||||
|
||||
# Naming hint for function names
|
||||
function-name-hint=[a-z_][a-z0-9_]{2,40}$
|
||||
|
||||
# Regular expression matching correct variable names
|
||||
variable-rgx=[a-z_][a-z0-9_]{2,30}$
|
||||
|
||||
# Naming hint for variable names
|
||||
variable-name-hint=[a-z_][a-z0-9_]{2,30}$
|
||||
|
||||
# Regular expression matching correct constant names
|
||||
const-rgx=(([A-Z_][A-Z0-9_]*)|(__.*__))$
|
||||
|
||||
# Naming hint for constant names
|
||||
const-name-hint=(([A-Z_][A-Z0-9_]*)|(__.*__))$
|
||||
|
||||
# Regular expression matching correct attribute names
|
||||
attr-rgx=[a-z_][a-z0-9_]{2,30}$
|
||||
|
||||
# Naming hint for attribute names
|
||||
attr-name-hint=[a-z_][a-z0-9_]{2,30}$
|
||||
|
||||
# Regular expression matching correct argument names
|
||||
argument-rgx=[a-z_][a-z0-9_]{2,30}$
|
||||
|
||||
# Naming hint for argument names
|
||||
argument-name-hint=[a-z_][a-z0-9_]{2,30}$
|
||||
|
||||
# Regular expression matching correct class attribute names
|
||||
class-attribute-rgx=([A-Za-z_][A-Za-z0-9_]{2,30}|(__.*__))$
|
||||
|
||||
# Naming hint for class attribute names
|
||||
class-attribute-name-hint=([A-Za-z_][A-Za-z0-9_]{2,30}|(__.*__))$
|
||||
|
||||
# Regular expression matching correct inline iteration names
|
||||
inlinevar-rgx=[A-Za-z_][A-Za-z0-9_]*$
|
||||
|
||||
# Naming hint for inline iteration names
|
||||
inlinevar-name-hint=[A-Za-z_][A-Za-z0-9_]*$
|
||||
|
||||
# Regular expression matching correct class names
|
||||
class-rgx=[A-Z_][a-zA-Z0-9]+$
|
||||
|
||||
# Naming hint for class names
|
||||
class-name-hint=[A-Z_][a-zA-Z0-9]+$
|
||||
|
||||
# Regular expression matching correct module names
|
||||
module-rgx=(([a-z_][a-z0-9_]*)|([A-Z][a-zA-Z0-9]+))$
|
||||
|
||||
# Naming hint for module names
|
||||
module-name-hint=(([a-z_][a-z0-9_]*)|([A-Z][a-zA-Z0-9]+))$
|
||||
|
||||
# Regular expression matching correct method names
|
||||
method-rgx=[a-z_][a-z0-9_]{2,50}$
|
||||
|
||||
# Naming hint for method names
|
||||
method-name-hint=[a-z_][a-z0-9_]{2,50}$
|
||||
|
||||
# Regular expression which should only match function or class names that do
|
||||
# not require a docstring.
|
||||
no-docstring-rgx=(__.*__)|(test_[A-Za-z0-9_]*)|(_.*)|(.*Test$)
|
||||
|
||||
# Minimum line length for functions/classes that require docstrings, shorter
|
||||
# ones are exempt.
|
||||
docstring-min-length=-1
|
||||
|
||||
|
||||
[MISCELLANEOUS]
|
||||
|
||||
# List of note tags to take in consideration, separated by a comma.
|
||||
notes=FIXME,XXX,TODO
|
||||
|
||||
|
||||
[LOGGING]
|
||||
|
||||
# Logging modules to check that the string format arguments are in logging
|
||||
# function parameter format
|
||||
logging-modules=logging,logger
|
||||
|
||||
|
||||
[VARIABLES]
|
||||
|
||||
# Tells whether we should check for unused import in __init__ files.
|
||||
init-import=no
|
||||
|
||||
# A regular expression matching the name of dummy variables (i.e. expectedly
|
||||
# not used).
|
||||
dummy-variables-rgx=(unused)?_.*|dummy
|
||||
|
||||
# List of additional names supposed to be defined in builtins. Remember that
|
||||
# you should avoid to define new builtins when possible.
|
||||
additional-builtins=
|
||||
|
||||
|
||||
[SIMILARITIES]
|
||||
|
||||
# Minimum lines number of a similarity.
|
||||
min-similarity-lines=6
|
||||
|
||||
# Ignore comments when computing similarities.
|
||||
ignore-comments=yes
|
||||
|
||||
# Ignore docstrings when computing similarities.
|
||||
ignore-docstrings=yes
|
||||
|
||||
# Ignore imports when computing similarities.
|
||||
ignore-imports=yes
|
||||
|
||||
|
||||
[FORMAT]
|
||||
|
||||
# Maximum number of characters on a single line.
|
||||
max-line-length=100
|
||||
|
||||
# Regexp for a line that is allowed to be longer than the limit.
|
||||
ignore-long-lines=^\s*(# )?<?https?://\S+>?$
|
||||
|
||||
# Allow the body of an if to be on the same line as the test if there is no
|
||||
# else.
|
||||
single-line-if-stmt=no
|
||||
|
||||
# List of optional constructs for which whitespace checking is disabled
|
||||
no-space-check=trailing-comma,dict-separator
|
||||
|
||||
# Maximum number of lines in a module
|
||||
max-module-lines=1250
|
||||
|
||||
# String used as indentation unit. This is usually " " (4 spaces) or "\t" (1
|
||||
# tab).
|
||||
indent-string=' '
|
||||
|
||||
# Number of spaces of indent required inside a hanging or continued line.
|
||||
indent-after-paren=4
|
||||
|
||||
|
||||
[TYPECHECK]
|
||||
|
||||
# Tells whether missing members accessed in mixin class should be ignored. A
|
||||
# mixin class is detected if its name ends with "mixin" (case insensitive).
|
||||
ignore-mixin-members=yes
|
||||
|
||||
# List of module names for which member attributes should not be checked
|
||||
# (useful for modules/projects where namespaces are manipulated during runtime
|
||||
# and thus existing member attributes cannot be deduced by static analysis
|
||||
ignored-modules=pkg_resources,confargparse,argparse,six.moves,six.moves.urllib
|
||||
# import errors ignored only in 1.4.4
|
||||
# https://bitbucket.org/logilab/pylint/commits/cd000904c9e2
|
||||
|
||||
# List of classes names for which member attributes should not be checked
|
||||
# (useful for classes with attributes dynamically set).
|
||||
ignored-classes=SQLObject
|
||||
|
||||
# When zope mode is activated, add a predefined set of Zope acquired attributes
|
||||
# to generated-members.
|
||||
zope=yes
|
||||
|
||||
# List of members which are set dynamically and missed by pylint inference
|
||||
# system, and so shouldn't trigger E0201 when accessed. Python regular
|
||||
# expressions are accepted.
|
||||
generated-members=REQUEST,acl_users,aq_parent
|
||||
|
||||
|
||||
[IMPORTS]
|
||||
|
||||
# Deprecated modules which should not be used, separated by a comma
|
||||
deprecated-modules=regsub,TERMIOS,Bastion,rexec
|
||||
|
||||
# Create a graph of every (i.e. internal and external) dependencies in the
|
||||
# given file (report RP0402 must not be disabled)
|
||||
import-graph=
|
||||
|
||||
# Create a graph of external dependencies in the given file (report RP0402 must
|
||||
# not be disabled)
|
||||
ext-import-graph=
|
||||
|
||||
# Create a graph of internal dependencies in the given file (report RP0402 must
|
||||
# not be disabled)
|
||||
int-import-graph=
|
||||
|
||||
|
||||
[CLASSES]
|
||||
|
||||
# List of interface methods to ignore, separated by a comma. This is used for
|
||||
# instance to not check methods defined in Zope's Interface base class.
|
||||
ignore-iface-methods=isImplementedBy,deferred,extends,names,namesAndDescriptions,queryDescriptionFor,getBases,getDescriptionFor,getDoc,getName,getTaggedValue,getTaggedValueTags,isEqualOrExtendedBy,setTaggedValue,isImplementedByInstancesOf,adaptWith,is_implemented_by,implementedBy,providedBy
|
||||
|
||||
# List of method names used to declare (i.e. assign) instance attributes.
|
||||
defining-attr-methods=__init__,__new__,setUp
|
||||
|
||||
# List of valid names for the first argument in a class method.
|
||||
valid-classmethod-first-arg=cls
|
||||
|
||||
# List of valid names for the first argument in a metaclass class method.
|
||||
valid-metaclass-classmethod-first-arg=mcs
|
||||
|
||||
|
||||
[DESIGN]
|
||||
|
||||
# Maximum number of arguments for function / method
|
||||
max-args=6
|
||||
|
||||
# Argument names that match this expression will be ignored. Default to name
|
||||
# with leading underscore
|
||||
ignored-argument-names=_.*
|
||||
|
||||
# Maximum number of locals for function / method body
|
||||
max-locals=15
|
||||
|
||||
# Maximum number of return / yield for function / method body
|
||||
max-returns=6
|
||||
|
||||
# Maximum number of branch for function / method body
|
||||
max-branches=12
|
||||
|
||||
# Maximum number of statements in function / method body
|
||||
max-statements=50
|
||||
|
||||
# Maximum number of parents for a class (see R0901).
|
||||
max-parents=12
|
||||
|
||||
# Maximum number of attributes for a class (see R0902).
|
||||
max-attributes=7
|
||||
|
||||
# Minimum number of public methods for a class (see R0903).
|
||||
min-public-methods=2
|
||||
|
||||
# Maximum number of public methods for a class (see R0904).
|
||||
max-public-methods=20
|
||||
|
||||
|
||||
[EXCEPTIONS]
|
||||
|
||||
# Exceptions that will emit a warning when being caught. Defaults to
|
||||
# "Exception"
|
||||
overgeneral-exceptions=Exception
|
||||
39
.travis.yml
Normal file
39
.travis.yml
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
language: python
|
||||
|
||||
services:
|
||||
- rabbitmq
|
||||
|
||||
# http://docs.travis-ci.com/user/ci-environment/#CI-environment-OS
|
||||
before_install:
|
||||
- travis_retry sudo ./bootstrap/ubuntu.sh
|
||||
- travis_retry sudo apt-get install --no-install-recommends nginx-light openssl
|
||||
|
||||
# using separate envs with different TOXENVs creates 4x1 Travis build
|
||||
# matrix, which allows us to clearly distinguish which component under
|
||||
# test has failed
|
||||
env:
|
||||
global:
|
||||
- GOPATH=/tmp/go
|
||||
matrix:
|
||||
- TOXENV=py26 BOULDER_INTEGRATION=1
|
||||
- TOXENV=py27 BOULDER_INTEGRATION=1
|
||||
- TOXENV=py33
|
||||
- TOXENV=py34
|
||||
- TOXENV=lint
|
||||
- TOXENV=cover
|
||||
|
||||
install: "travis_retry pip install tox coveralls"
|
||||
before_script: '[ "xxx$BOULDER_INTEGRATION" = "xxx" ] || ./tests/boulder-start.sh amqp'
|
||||
script: 'travis_retry tox && ([ "xxx$BOULDER_INTEGRATION" = "xxx" ] || (source .tox/$TOXENV/bin/activate && ./tests/boulder-integration.sh))'
|
||||
|
||||
after_success: '[ "$TOXENV" == "cover" ] && coveralls'
|
||||
|
||||
notifications:
|
||||
email: false
|
||||
irc:
|
||||
channels:
|
||||
- "chat.freenode.net#letsencrypt"
|
||||
on_success: never
|
||||
on_failure: always
|
||||
use_notice: true
|
||||
skip_join: true
|
||||
27
CHANGES.rst
Normal file
27
CHANGES.rst
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
ChangeLog
|
||||
=========
|
||||
|
||||
Please note:
|
||||
the change log will only get updated after first release - for now please use the
|
||||
`commit log <https://github.com/letsencrypt/letsencrypt/commits/master>`_.
|
||||
|
||||
|
||||
Release 0.1.0 (not released yet)
|
||||
--------------------------------
|
||||
|
||||
New Features:
|
||||
|
||||
* ...
|
||||
|
||||
Fixes:
|
||||
|
||||
* ...
|
||||
|
||||
Other changes:
|
||||
|
||||
* ...
|
||||
|
||||
Release 0.0.0 (not released yet)
|
||||
--------------------------------
|
||||
|
||||
Initial release.
|
||||
18
CONTRIBUTING.md
Normal file
18
CONTRIBUTING.md
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
<!---
|
||||
|
||||
This file serves as an entry point for GitHub's Contributing
|
||||
Guidelines [1] only.
|
||||
|
||||
GitHub doesn't render rST very well, especially in respect to internal
|
||||
hyperlink targets and cross-references [2]. People also tend to
|
||||
confuse rST and Markdown syntax. Therefore, instead of keeping the
|
||||
contents here (and including from rST documentation under doc/), link
|
||||
to the Sphinx generated docs is provided below.
|
||||
|
||||
|
||||
[1] https://github.com/blog/1184-contributing-guidelines
|
||||
[2] http://docutils.sourceforge.net/docs/user/rst/quickref.html#hyperlink-targets
|
||||
|
||||
-->
|
||||
|
||||
https://letsencrypt.readthedocs.org/en/latest/contributing.html
|
||||
66
Dockerfile
Normal file
66
Dockerfile
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
# https://github.com/letsencrypt/letsencrypt/pull/431#issuecomment-103659297
|
||||
# it is more likely developers will already have ubuntu:trusty rather
|
||||
# than e.g. debian:jessie and image size differences are negligible
|
||||
FROM ubuntu:trusty
|
||||
MAINTAINER Jakub Warmuz <jakub@warmuz.org>
|
||||
MAINTAINER William Budington <bill@eff.org>
|
||||
|
||||
# Note: this only exposes the port to other docker containers. You
|
||||
# still have to bind to 443@host at runtime, as per the ACME spec.
|
||||
EXPOSE 443
|
||||
|
||||
# TODO: make sure --config-dir and --work-dir cannot be changed
|
||||
# through the CLI (letsencrypt-docker wrapper that uses standalone
|
||||
# authenticator and text mode only?)
|
||||
VOLUME /etc/letsencrypt /var/lib/letsencrypt
|
||||
|
||||
WORKDIR /opt/letsencrypt
|
||||
|
||||
# no need to mkdir anything:
|
||||
# https://docs.docker.com/reference/builder/#copy
|
||||
# If <dest> doesn't exist, it is created along with all missing
|
||||
# directories in its path.
|
||||
|
||||
COPY bootstrap/ubuntu.sh /opt/letsencrypt/src/
|
||||
RUN /opt/letsencrypt/src/ubuntu.sh && \
|
||||
apt-get clean && \
|
||||
rm -rf /var/lib/apt/lists/* \
|
||||
/tmp/* \
|
||||
/var/tmp/*
|
||||
|
||||
# the above is not likely to change, so by putting it further up the
|
||||
# Dockerfile we make sure we cache as much as possible
|
||||
|
||||
|
||||
COPY setup.py README.rst CHANGES.rst MANIFEST.in /opt/letsencrypt/src/
|
||||
|
||||
# all above files are necessary for setup.py, however, package source
|
||||
# code directory has to be copied separately to a subdirectory...
|
||||
# https://docs.docker.com/reference/builder/#copy: "If <src> is a
|
||||
# directory, the entire contents of the directory are copied,
|
||||
# including filesystem metadata. Note: The directory itself is not
|
||||
# copied, just its contents." Order again matters, three files are far
|
||||
# more likely to be cached than the whole project directory
|
||||
|
||||
COPY letsencrypt /opt/letsencrypt/src/letsencrypt/
|
||||
COPY acme /opt/letsencrypt/src/acme/
|
||||
COPY letsencrypt-apache /opt/letsencrypt/src/letsencrypt-apache/
|
||||
COPY letsencrypt-nginx /opt/letsencrypt/src/letsencrypt-nginx/
|
||||
|
||||
|
||||
# requirements.txt not installed!
|
||||
RUN virtualenv --no-site-packages -p python2 /opt/letsencrypt/venv && \
|
||||
/opt/letsencrypt/venv/bin/pip install \
|
||||
-e /opt/letsencrypt/src/acme \
|
||||
-e /opt/letsencrypt/src \
|
||||
-e /opt/letsencrypt/src/letsencrypt-apache \
|
||||
-e /opt/letsencrypt/src/letsencrypt-nginx
|
||||
|
||||
# install in editable mode (-e) to save space: it's not possible to
|
||||
# "rm -rf /opt/letsencrypt/src" (it's stays in the underlaying image);
|
||||
# this might also help in debugging: you can "docker run --entrypoint
|
||||
# bash" and investigate, apply patches, etc.
|
||||
|
||||
ENV PATH /opt/letsencrypt/venv/bin:$PATH
|
||||
# TODO: is --text really necessary?
|
||||
ENTRYPOINT [ "letsencrypt", "--text" ]
|
||||
1
EULA
Symbolic link
1
EULA
Symbolic link
|
|
@ -0,0 +1 @@
|
|||
letsencrypt/EULA
|
||||
205
LICENSE.txt
Normal file
205
LICENSE.txt
Normal file
|
|
@ -0,0 +1,205 @@
|
|||
Let's Encrypt:
|
||||
Copyright (c) Internet Security Research Group
|
||||
Licensed Apache Version 2.0
|
||||
|
||||
Incorporating code from nginxparser
|
||||
Copyright (c) 2014 Fatih Erikli
|
||||
Licensed MIT
|
||||
|
||||
|
||||
Text of Apache License
|
||||
======================
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
|
||||
Text of MIT License
|
||||
===================
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
this software and associated documentation files (the "Software"), to deal in
|
||||
the Software without restriction, including without limitation the rights to
|
||||
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
|
||||
the Software, and to permit persons to whom the Software is furnished to do so,
|
||||
subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
|
||||
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
|
||||
CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
|
|
@ -1 +1,7 @@
|
|||
recursive-include trustify *
|
||||
include requirements.txt
|
||||
include README.rst
|
||||
include CHANGES.rst
|
||||
include CONTRIBUTING.md
|
||||
include linter_plugin.py
|
||||
include letsencrypt/EULA
|
||||
recursive-include letsencrypt/tests/testdata *
|
||||
|
|
|
|||
24
README
24
README
|
|
@ -1,24 +0,0 @@
|
|||
The Chocolate project to implement sweet automatic encryption for webservers.
|
||||
|
||||
There are two portions to the Chocolate protocol.
|
||||
|
||||
trustify/ contains code that can be run on any webserver (eventually,
|
||||
email, XMPP and other SSL-securable servers too); it is used to automatically
|
||||
request and install a CA-signed certificate for that server's public names.
|
||||
|
||||
server-ca/ contains a reference implementation for CAs to receive requests for
|
||||
certs, set challenges for the requesting servers to prove that they really
|
||||
control the names, and issue certificates.
|
||||
|
||||
Debian dependencies:
|
||||
|
||||
build deps:
|
||||
swig
|
||||
protobuf-compiler
|
||||
python-dev
|
||||
|
||||
others:
|
||||
gnutls-bin # for make cert requests
|
||||
python-protobuf
|
||||
python-dialog
|
||||
hashcash
|
||||
120
README.rst
Normal file
120
README.rst
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
.. notice for github users
|
||||
|
||||
Official **documentation**, including `installation instructions`_, is
|
||||
available at https://letsencrypt.readthedocs.org.
|
||||
|
||||
Generic information about Let's Encrypt project can be found at
|
||||
https://letsencrypt.org. Please read `Frequently Asked Questions (FAQ)
|
||||
<https://letsencrypt.org/faq/>`_.
|
||||
|
||||
|
||||
About the Let's Encrypt Client
|
||||
==============================
|
||||
|
||||
|build-status| |coverage| |docs| |container|
|
||||
|
||||
In short: getting and installing SSL/TLS certificates made easy (`watch demo video`_).
|
||||
|
||||
The Let's Encrypt Client is a tool to automatically receive and install
|
||||
X.509 certificates to enable TLS on servers. The client will
|
||||
interoperate with the Let's Encrypt CA which will be issuing browser-trusted
|
||||
certificates for free beginning the summer of 2015.
|
||||
|
||||
It's all automated:
|
||||
|
||||
* The tool will prove domain control to the CA and submit a CSR (Certificate
|
||||
Signing Request).
|
||||
* If domain control has been proven, a certificate will get issued and the tool
|
||||
will automatically install it.
|
||||
|
||||
All you need to do to sign a single domain is::
|
||||
|
||||
user@www:~$ sudo letsencrypt -d www.example.org auth
|
||||
|
||||
For multiple domains (SAN) use::
|
||||
|
||||
user@www:~$ sudo letsencrypt -d www.example.org -d example.org auth
|
||||
|
||||
and if you have a compatible web server (Apache or Nginx), Let's Encrypt can
|
||||
not only get a new certificate, but also deploy it and configure your
|
||||
server automatically!::
|
||||
|
||||
user@www:~$ sudo letsencrypt -d www.example.org run
|
||||
|
||||
|
||||
**Encrypt ALL the things!**
|
||||
|
||||
|
||||
.. |build-status| image:: https://travis-ci.org/letsencrypt/letsencrypt.svg?branch=master
|
||||
:target: https://travis-ci.org/letsencrypt/letsencrypt
|
||||
:alt: Travis CI status
|
||||
|
||||
.. |coverage| image:: https://coveralls.io/repos/letsencrypt/letsencrypt/badge.svg?branch=master
|
||||
:target: https://coveralls.io/r/letsencrypt/letsencrypt
|
||||
:alt: Coverage status
|
||||
|
||||
.. |docs| image:: https://readthedocs.org/projects/letsencrypt/badge/
|
||||
:target: https://readthedocs.org/projects/letsencrypt/
|
||||
:alt: Documentation status
|
||||
|
||||
.. |container| image:: https://quay.io/repository/letsencrypt/letsencrypt/status
|
||||
:target: https://quay.io/repository/letsencrypt/letsencrypt
|
||||
:alt: Docker Repository on Quay.io
|
||||
|
||||
.. _`installation instructions`:
|
||||
https://letsencrypt.readthedocs.org/en/latest/using.html
|
||||
|
||||
.. _watch demo video: https://www.youtube.com/watch?v=Gas_sSB-5SU
|
||||
|
||||
|
||||
Disclaimer
|
||||
----------
|
||||
|
||||
This is a **DEVELOPER PREVIEW** intended for developers and testers only.
|
||||
|
||||
**DO NOT RUN THIS CODE ON A PRODUCTION SERVER. IT WILL INSTALL CERTIFICATES
|
||||
SIGNED BY A TEST CA, AND WILL CAUSE CERT WARNINGS FOR USERS.**
|
||||
|
||||
|
||||
Current Features
|
||||
----------------
|
||||
|
||||
* web servers supported:
|
||||
|
||||
- apache/2.x (tested and working on Ubuntu Linux)
|
||||
- nginx/0.8.48+ (tested and mostly working on Ubuntu Linux)
|
||||
- standalone (runs its own webserver to prove you control the domain)
|
||||
|
||||
* the private key is generated locally on your system
|
||||
* can talk to the Let's Encrypt (demo) CA or optionally to other ACME
|
||||
compliant services
|
||||
* can get domain-validated (DV) certificates
|
||||
* can revoke certificates
|
||||
* adjustable RSA key bitlength (2048 (default), 4096, ...)
|
||||
* optionally can install a http->https redirect, so your site effectively
|
||||
runs https only (Apache only)
|
||||
* fully automated
|
||||
* configuration changes are logged and can be reverted using the CLI
|
||||
* text and ncurses UI
|
||||
* Free and Open Source Software, made with Python.
|
||||
|
||||
|
||||
Links
|
||||
-----
|
||||
|
||||
Documentation: https://letsencrypt.readthedocs.org
|
||||
|
||||
Software project: https://github.com/letsencrypt/letsencrypt
|
||||
|
||||
Notes for developers: CONTRIBUTING.md_
|
||||
|
||||
Main Website: https://letsencrypt.org/
|
||||
|
||||
IRC Channel: #letsencrypt on `Freenode`_
|
||||
|
||||
Mailing list: `client-dev`_ (to subscribe without a Google account, send an
|
||||
email to client-dev+subscribe@letsencrypt.org)
|
||||
|
||||
.. _Freenode: https://freenode.net
|
||||
.. _client-dev: https://groups.google.com/a/letsencrypt.org/forum/#!forum/client-dev
|
||||
.. _CONTRIBUTING.md: https://github.com/letsencrypt/letsencrypt/blob/master/CONTRIBUTING.md
|
||||
30
Vagrantfile
vendored
Normal file
30
Vagrantfile
vendored
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
# -*- mode: ruby -*-
|
||||
# vi: set ft=ruby :
|
||||
|
||||
# Vagrantfile API/syntax version. Don't touch unless you know what you're doing!
|
||||
VAGRANTFILE_API_VERSION = "2"
|
||||
|
||||
# Setup instructions from docs/using.rst
|
||||
$ubuntu_setup_script = <<SETUP_SCRIPT
|
||||
cd /vagrant
|
||||
sudo ./bootstrap/ubuntu.sh
|
||||
if [ ! -d "venv" ]; then
|
||||
virtualenv --no-site-packages -p python2 venv
|
||||
./venv/bin/pip install -r requirements.txt -e acme -e .[dev,docs,testing] -e letsencrypt-apache -e letsencrypt-nginx
|
||||
fi
|
||||
SETUP_SCRIPT
|
||||
|
||||
Vagrant.configure(VAGRANTFILE_API_VERSION) do |config|
|
||||
|
||||
config.vm.define "ubuntu-trusty", primary: true do |ubuntu_trusty|
|
||||
ubuntu_trusty.vm.box = "ubuntu/trusty64"
|
||||
ubuntu_trusty.vm.provision "shell", inline: $ubuntu_setup_script
|
||||
ubuntu_trusty.vm.provider "virtualbox" do |v|
|
||||
# VM needs more memory to run test suite, got "OSError: [Errno 12]
|
||||
# Cannot allocate memory" when running
|
||||
# letsencrypt.client.tests.display.util_test.NcursesDisplayTest
|
||||
v.memory = 1024
|
||||
end
|
||||
end
|
||||
|
||||
end
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,869 +0,0 @@
|
|||
<?xml version="1.0" encoding="US-ASCII"?>
|
||||
<!DOCTYPE rfc SYSTEM "rfc2629.dtd" [
|
||||
<!ENTITY RFC2119 SYSTEM "http://xml.resource.org/public/rfc/bibxml/reference.RFC.2119.xml">
|
||||
<!ENTITY RFC2314 SYSTEM "http://xml.resource.org/public/rfc/bibxml/reference.RFC.2314.xml">
|
||||
<!ENTITY RFC2818 SYSTEM "http://xml.resource.org/public/rfc/bibxml/reference.RFC.2818.xml">
|
||||
<!ENTITY RFC5226 SYSTEM "http://xml.resource.org/public/rfc/bibxml/reference.RFC.5226.xml">
|
||||
<!ENTITY RFC5246 SYSTEM "http://xml.resource.org/public/rfc/bibxml/reference.RFC.5246.xml">
|
||||
]>
|
||||
<?xml-stylesheet type="text/xsl" href="rfc2629.xslt" ?>
|
||||
<?rfc toc="yes" ?>
|
||||
<?rfc symrefs="yes" ?>
|
||||
<?rfc strict="yes" ?>
|
||||
<?rfc compact="yes" ?>
|
||||
<?rfc sortrefs="yes" ?>
|
||||
<?rfc colonspace="yes" ?>
|
||||
<?rfc rfcedstyle="no" ?>
|
||||
<!-- Don't change this. It breaks stuff -->
|
||||
<?rfc tocdepth="4"?>
|
||||
<rfc category="std" docName="draft-rescorla-stir-fallback-00"
|
||||
ipr="pre5378Trust200902">
|
||||
<front>
|
||||
<title abbrev="ACIP">Automatic Certificate Issuance Protocol (ACIP)</title>
|
||||
|
||||
<author fullname="Eric Rescorla" initials="E.K." surname="Rescorla">
|
||||
<organization>Mozilla</organization>
|
||||
|
||||
<address>
|
||||
<postal>
|
||||
<street>2064 Edgewood Drive</street>
|
||||
|
||||
<city>Palo Alto</city>
|
||||
|
||||
<region>CA</region>
|
||||
|
||||
<code>94303</code>
|
||||
|
||||
<country>USA</country>
|
||||
</postal>
|
||||
|
||||
<phone>+1 650 678 2350</phone>
|
||||
|
||||
<email>ekr@rtfm.com</email>
|
||||
|
||||
<!-- Insert names of other people here -->
|
||||
</address>
|
||||
</author>
|
||||
|
||||
<date day="11" month="December" year="2013" />
|
||||
|
||||
<area>SEC</area>
|
||||
|
||||
<abstract>
|
||||
<t>
|
||||
[TODO]
|
||||
</t>
|
||||
</abstract>
|
||||
</front>
|
||||
|
||||
|
||||
<middle>
|
||||
<section anchor="sec-term" title="Terminology">
|
||||
<t>The key words "MUST", "MUST NOT", "REQUIRED", "SHALL", "SHALL NOT",
|
||||
"SHOULD", "SHOULD NOT", "RECOMMENDED", "MAY", and "OPTIONAL" in this
|
||||
document are to be interpreted as described in <xref
|
||||
target="RFC2119">RFC 2119</xref>.</t>
|
||||
</section>
|
||||
|
||||
<section title="Introduction" anchor="sec.intro">
|
||||
<t>
|
||||
Existing Web PKI certificate authorities tend to run on
|
||||
a set of ad hoc protocols for certificate issuance and
|
||||
identity verification. A typical user experience is something
|
||||
like:
|
||||
</t>
|
||||
<t>
|
||||
<list style="symbols">
|
||||
<t>Generate a PKCS#10 <xref target="RFC2314"/> Certificate Signing Request (CSR).</t>
|
||||
<t>Cut-and-paste the CSR into a CA web page.</t>
|
||||
<t>Prove ownership of the domain by one of the following methods:
|
||||
<list style="symbols">
|
||||
<t>Put a CA-provided challenge at a specific place on the web server</t>
|
||||
|
||||
<t>Put a CA-challenge at a DNS location corresponding to the target domain.</t>
|
||||
|
||||
<t>Receive CA challenge at an (allegedly) administrator controlled e-mail address corresponding to the domain and then respond to it on the CA's web page.</t>
|
||||
</list>
|
||||
</t>
|
||||
<t>Download the issued certificate and install it on their Web Server.</t>
|
||||
</list>
|
||||
</t>
|
||||
<t>
|
||||
With the exception of the CSR itself and the certificates that are
|
||||
issued, these are all completely ad hoc procedures and are accomplished
|
||||
by getting the user to follow instructions from the CA rather than by
|
||||
published protocols. In many cases, the instructions are difficult to
|
||||
follow and cause significant confusion. Even in the best case, the
|
||||
lack of published, standardized mechanisms presents an obstacle to
|
||||
the wide deployment of certificate-using systems.
|
||||
</t>
|
||||
<t>
|
||||
This document describes a framework for automating the issuance
|
||||
and domain validation procedure, thus enabling the construction of
|
||||
certificate-holding entities which can obtain certificates without
|
||||
user interaction. It is hoped that this will radically increase
|
||||
the level of deployment of certificate-using systems and specifically
|
||||
of HTTPS.
|
||||
</t>
|
||||
</section>
|
||||
<section title="Deployment Model and Operator Experience">
|
||||
<t>
|
||||
It's easiest to understand ACIP in the context of certificates
|
||||
for Web sites (HTTPS <xref target="RFC2818"/>). In that case, the
|
||||
server is intended to speak for one or more domains and the process of
|
||||
certificate issuance is intended to verify that the server actually
|
||||
speaks for the domain. In the case of "Domain Validation" (DV) certificates,
|
||||
the server validation process merely verifies that the requester
|
||||
has effective control of the domain but does not really attempt to
|
||||
verify their real-world identity. (This is as opposed to "Extended
|
||||
Validation" (EV) certificates where the process is intended to also verify
|
||||
the real-world identity of the requester.)
|
||||
</t>
|
||||
<t>
|
||||
When an operator deploys a current HTTPS server, it generally prompts
|
||||
him to generate a self-signed certificate.
|
||||
When an operator deploys an ACIP-compatible server, the experience would
|
||||
be something like this:
|
||||
</t>
|
||||
<t>
|
||||
<list style="symbols">
|
||||
<t>The server prompts the operator for the intended domain name(s) that the
|
||||
server is to stand for.</t>
|
||||
<t>The server presents the operator with a list of CAs which it
|
||||
could get a certificate from. The server might prompt the operator
|
||||
for payment information at this point.</t>
|
||||
<t>Once the operator has selected a CA, tells the operator that he will have a certificate shortly.</t>
|
||||
<t>In the background, the server contacts the CA, sends a CSR, and
|
||||
engages in the validation procedure.</t>
|
||||
<t>Once the CA is satisfied, the certificate is issued and the
|
||||
server automatically downloads and installs it, potentially notifying
|
||||
the operator via e-mail, SMS, etc.</t>
|
||||
<t>The server periodically contacts the CA to get updated certificates,
|
||||
stapled OCSP responses, or whatever else would be required to keep
|
||||
the server functional and its credentials up-to-date.</t>
|
||||
</list>
|
||||
</t>
|
||||
<t>
|
||||
The overall idea is that it's nearly as easy to deploy with a valid
|
||||
certificate as a self-signed certificate and that once the operator
|
||||
has done so, the process is self-sustaining with minimal manual
|
||||
intervention.
|
||||
</t>
|
||||
<section title="Other Use Cases">
|
||||
<t>
|
||||
While ACIP is explicitly designed for HTTPS server certificates,
|
||||
it is in general usable in any situation where the certificate
|
||||
subject is automatically verifiable. In principle, then, it
|
||||
could be used with personal addresses (XMPP or e-mail) if there
|
||||
is some straightforward way of responding to the validation
|
||||
procedure, e.g., by having the client automatically
|
||||
detect and respond to validation answerback queries.
|
||||
</t>
|
||||
</section>
|
||||
</section>
|
||||
<section title="System Architecture Overview" anchor="sec.overview">
|
||||
<t>
|
||||
We assume that that an ACIP-compatible agent which wishes
|
||||
to get a certificate (currently, the "Certificate Holder" (CH))
|
||||
starts with a list of ACIP-compliant CAs represented as URLs.
|
||||
From there, the protocol proceeds as shown below. Note
|
||||
that we have taken some liberties with messages from the
|
||||
CA to the CH: everything is done over HTTPS, so messages
|
||||
from the CA likely require either polling by the CH or
|
||||
that the CH have some accessible endpoint for the CA to
|
||||
talk to.
|
||||
</t>
|
||||
<section title="Initial Issuance" anchor="sec.initial-issuance">
|
||||
<figure>
|
||||
<artwork align="left" alt="" height="" name="Initial Issuance" type="" width=""
|
||||
xml:space="preserve"><![CDATA[
|
||||
CH CA
|
||||
|
||||
Policy
|
||||
<-------- Validation Methods
|
||||
|
||||
Desired identity
|
||||
CSR
|
||||
Validation Method -------->
|
||||
|
||||
<-------- Challenge
|
||||
|
||||
Response -------->
|
||||
|
||||
<-------- Certificate
|
||||
]]></artwork>
|
||||
</figure>
|
||||
<t>
|
||||
Initially, the CH contacts the CA and retrieves the CA's
|
||||
capabilities. This likely includes information like
|
||||
the validation types (e-mail, DNS, Web, etc.) that the
|
||||
CA supports, whether it charges, the types of keys it will
|
||||
issue for, how often the certificates
|
||||
need to be reissued, etc. The CH might contact multiple
|
||||
CAs to find one with the best policies before it actually
|
||||
requests a certificate.
|
||||
</t>
|
||||
<t>
|
||||
Once the CH (or more likely the operator) has selected a CA,
|
||||
the CH generates a key pair and contacts the CA with
|
||||
signing request. He will also indicate (probably separately),
|
||||
the identity he wants the certificate issued for and which
|
||||
validation method he wants the CA to use. He might
|
||||
also provide a URL which the CA can use to indicate that
|
||||
the certificate is ready.
|
||||
</t>
|
||||
<t>
|
||||
Depending on the validation method, the CA will then supply
|
||||
a challenge to the CH. This might happen immediately, as
|
||||
in the case of HTTPS validation (section XXX) or might
|
||||
happen later, as in the case of e-mail validation (section YYY).
|
||||
The CH responds to the challenge, e.g., by putting it somewhere
|
||||
in /.well-known on their server.
|
||||
</t>
|
||||
<t>
|
||||
Once the CA has validated the CH's identity, it issues the
|
||||
certificate. The CH might either poll the CA for readiness
|
||||
or the CH can provide a URL for the CA to contact when
|
||||
the certificate is ready. In either case, once the CH
|
||||
has the certificate, it installs it.
|
||||
</t>
|
||||
</section>
|
||||
<section title="Certificate Refresh" anchor="sec.cert-refresh">
|
||||
<figure>
|
||||
<artwork align="left" alt="" height="" name="Re-Issuance" type="" width=""
|
||||
xml:space="preserve"><![CDATA[
|
||||
CH CA
|
||||
|
||||
Retrieve Certificate -------->
|
||||
|
||||
<-------- Certificate
|
||||
]]></artwork>
|
||||
</figure>
|
||||
<t>
|
||||
Because certificates eventually expire, there must be some
|
||||
for the CH to get a new certificate. This need not involve
|
||||
a new authentication transaction--and if short-lived
|
||||
certificates are used, then it generally will not--but
|
||||
does require getting new bits to the CH.
|
||||
In general, the CA will just generate new certificates
|
||||
periodically and publish them at some deterministic
|
||||
URL that the CH can retrieve them at. Where a new
|
||||
authentication transaction is required, we just repeat
|
||||
the flow of <xref target="sec.initial-issuance"/>
|
||||
</t>
|
||||
</section>
|
||||
</section>
|
||||
|
||||
<section title="Protocol Description">
|
||||
<t>
|
||||
This section describes the relevant protocol elements.
|
||||
For explanatory reasons, we have chosen to describe
|
||||
these as if they were JSON structures. Eventually
|
||||
we may decide on some more fixed binary encoding,
|
||||
but it's easier to explain with JSON and we may
|
||||
eventually just use JSON in any cse.
|
||||
</t>
|
||||
|
||||
<section title="CA Policies" anchor="sec.ca-policies">
|
||||
<t>
|
||||
Every ACIP-compliant CA will post a policy document in
|
||||
some public location. This document MUST be hosted over
|
||||
HTTPS <xref target="RFC2818"/> so that it can be validated.
|
||||
The URL is assumed to be known to the CH.
|
||||
</t>
|
||||
<t>
|
||||
The policy document contains the following values:
|
||||
</t>
|
||||
<t>
|
||||
<list style="hanging">
|
||||
<t></t><t hangText="issuance_validation_methods (mandatory):"></t>
|
||||
<t>A list of validation methods for issuance that the CA supports.</t>
|
||||
|
||||
<t></t><t hangText="revocation_validation_methods (mandatory):"></t>
|
||||
<t>A list of validation methods for revocation that the CA supports.</t>
|
||||
|
||||
<t></t><t hangText="signature_algorithms (mandatory):"></t>
|
||||
<t>A list of signature algorithms that the CA supports. [TODO: which registry]</t>
|
||||
<t></t><t hangText="key_types (mandatory):"></t>
|
||||
<t>A list of the key types that the CA allows the CH to have, structured
|
||||
as an algorithm and an (optional min/max} [TODO: which registry.</t>
|
||||
|
||||
<t></t><t hangText="status_mechanisms (mandatory)"></t>
|
||||
<t>A list of the status mechanisms that the CA supports (OCSP, etc.)</t>
|
||||
|
||||
<t></t><t hangText="signing_certificate (optional):"></t>
|
||||
<t>The base64 [REF] encoding of the certificate which will
|
||||
be used to authorize the CH's key. If the CA has multiple
|
||||
certificates signed by some intermediate, it will include
|
||||
the intermediate. The idea here is to provide some idea
|
||||
of what the final certificate will look like.</t>
|
||||
|
||||
<t></t><t hangText="min_lifetime (optional):"></t>
|
||||
<t>The minimum lifetime of certificates issued by the CA in seconds
|
||||
so that the CH knows how often it is likely to have to refresh.</t>
|
||||
|
||||
</list>
|
||||
</t>
|
||||
<t>
|
||||
An example policy document might look like the one below.
|
||||
</t>
|
||||
<figure>
|
||||
<artwork><![CDATA[
|
||||
{
|
||||
"issuance_issuance_validation_methods" :
|
||||
["https", "https-sni"],
|
||||
"revocation_validation_methods" :
|
||||
["https", "shared-secret"],
|
||||
"signature_algorithms" : [ "RSA", "ECDSA"],
|
||||
"key_types" : [
|
||||
{
|
||||
"algorithm" : "RSA",
|
||||
"min" : 2048,
|
||||
"max" : 4096
|
||||
},
|
||||
{
|
||||
"algorithm" : "ECDSA",
|
||||
"min" : 192,
|
||||
"max" : 512
|
||||
},
|
||||
"min_lifetime" : 604800,
|
||||
"status_mechanisms" : ["OCSP",
|
||||
"short-lived",
|
||||
"OCSP-stapled"],
|
||||
]
|
||||
}
|
||||
]]></artwork>
|
||||
</figure>
|
||||
<t>
|
||||
Note that one important consideration is that it be possible for
|
||||
add new policy values. Since these are just information to the
|
||||
CH, if they are unknown they can be ignored, but may of course
|
||||
cause issuance failure. [TODO: Add some support for payment.]
|
||||
[TODO: Add other types.]
|
||||
</t>
|
||||
</section>
|
||||
|
||||
<section title="Protocol Messages" anchor="sec.messages">
|
||||
<t>
|
||||
All ACIP protocol messages other than the policy document are
|
||||
carried in a generic wrapper.
|
||||
</t>
|
||||
<t>
|
||||
<list style="hanging">
|
||||
<t></t><t hangText="type (mandatory):"></t>
|
||||
<t>The message type, either "error" or one of the messages
|
||||
described below.</t>
|
||||
|
||||
<t></t><t hangText="message (mandatory):"></t>
|
||||
<t>The message itself. In the JSON encoding here, just the
|
||||
JSON structure shown below.</t>
|
||||
</list>
|
||||
</t>
|
||||
<t>
|
||||
An example message is shown below.
|
||||
</t>
|
||||
<figure>
|
||||
<artwork><![CDATA[
|
||||
{
|
||||
"type": "issuance_request",
|
||||
"message":
|
||||
{
|
||||
"validation_method" : "https",
|
||||
"identities" : [
|
||||
{
|
||||
"type" : "dnsNAme",
|
||||
"www1.example.com"
|
||||
},
|
||||
{
|
||||
"type" : "dnsNAme",
|
||||
"www2.example.com"
|
||||
}
|
||||
],
|
||||
"csr" : "<base-64-encoded CSR>",
|
||||
"notification_endpoint" :
|
||||
"http://status.example.com/acip-notification"
|
||||
}
|
||||
}
|
||||
]]></artwork>
|
||||
</figure>
|
||||
<section title="Issuance Requests" anchor="sec.issuance-requests">
|
||||
<t>
|
||||
The issuance request (message type "issuance_request") is relatively simple:
|
||||
</t>
|
||||
<t>
|
||||
<list style="hanging">
|
||||
<t></t><t hangText="validation_method (mandatory):"></t>
|
||||
<t>The validation method the CH has selected.</t>
|
||||
|
||||
<t></t><t hangText="identities (mandatory):"></t>
|
||||
<t>A list of the identities that the CH wants issuance for.
|
||||
Each identity is a pair of PKIX type and a string indicating
|
||||
the identity and may also contain a "validation_info"
|
||||
value for the selected validation method.</t>
|
||||
|
||||
<t></t><t hangText="csr (mandatory):"></t>
|
||||
<t>The PKCS#10 CSR.</t>
|
||||
|
||||
<t></t><t hangText="notification_endpoint (optional):"></t>
|
||||
<t>A URL that the CA can contact with status notifications"</t>
|
||||
</list>
|
||||
</t>
|
||||
<t>
|
||||
An example issuance request is shown below:
|
||||
</t>
|
||||
<figure>
|
||||
<artwork><![CDATA[
|
||||
{
|
||||
"validation_method" : "https",
|
||||
"identities" : [
|
||||
{
|
||||
"type" : "dnsNAme",
|
||||
"www1.example.com"
|
||||
},
|
||||
{
|
||||
"type" : "dnsNAme",
|
||||
"www2.example.com"
|
||||
}
|
||||
],
|
||||
"csr" : "<base-64-encoded CSR>",
|
||||
"notification_endpoint" :
|
||||
"http://status.example.com/acip-notification"
|
||||
}
|
||||
]]></artwork>
|
||||
</figure>
|
||||
|
||||
<t>
|
||||
The CA responds to a successful issuance request with a message
|
||||
indicating acknowledgement (type "issuance_response"),
|
||||
and containing the following contents.
|
||||
</t>
|
||||
<t>
|
||||
<list style="hanging">
|
||||
<t></t><t hangText="challenges (optional)"></t>
|
||||
<t>If the selected validation method requires a challenge, then
|
||||
the CA shall include the challenge data in the challenges
|
||||
value. The data is a list of values containing an identity
|
||||
field from the request. More than one challenge may be
|
||||
provided for a given identity, which means that the CH
|
||||
must comply with all of them. [TODO: Do we want to allow
|
||||
multiple simultaneous types of challenge? If so, we may
|
||||
need to tweak the negotiation to make the client offer
|
||||
and then add a type in this field.</t>
|
||||
|
||||
<t></t><t hangText="status_url (mandatory)"></t>
|
||||
<t>A URL which the CH can contact to find the status of the
|
||||
request. This MUST contain a large enough random component
|
||||
to avoid guessing (minumum 80 bits of entropy).</t>
|
||||
|
||||
<t></t><t hangText="certificate_url (mandatory)"></t>
|
||||
<t>A URL where the eventual certificate will live. At any
|
||||
point this MUST contain the most recent certificate, thus
|
||||
enabling short-lived certificates. This need not be secret.</t>
|
||||
</list>
|
||||
</t>
|
||||
<t>
|
||||
An example issuance response is shown below:
|
||||
</t>
|
||||
<figure>
|
||||
<artwork><![CDATA[
|
||||
{
|
||||
"challenges" : [
|
||||
{
|
||||
"identity" : {
|
||||
"type" : "dnsNAme",
|
||||
"www1.example.com"
|
||||
},
|
||||
"value" : {
|
||||
"path" : "<random1>",
|
||||
"value" : "<random2>",
|
||||
},
|
||||
},
|
||||
{
|
||||
"identity" : {
|
||||
"type" : "dnsNAme",
|
||||
"www2.example.com"
|
||||
},
|
||||
"value" : {
|
||||
"path" : "<random3>",
|
||||
"value" : "<random4>",
|
||||
}
|
||||
}
|
||||
],
|
||||
"status_url":"https://ca.example.com/status/<random>",
|
||||
"certificate_url:"https://ca.example.com/cert/1234"
|
||||
]]></artwork>
|
||||
</figure>
|
||||
|
||||
</section>
|
||||
<section title="HTTPS with SNI" anchor="sec.https-with-sni">
|
||||
<t>[TODO]</t>
|
||||
</section>
|
||||
|
||||
<section title="DNS" anchor="sec.dns"/>
|
||||
<section title="DANE" anchor="sec.dane"/>
|
||||
</section>
|
||||
|
||||
<section title="Certificate Issuance" anchor="sec.issuance">
|
||||
<t>
|
||||
Once the certificate is issued, the CA places it at the
|
||||
location indicated by the "certificate_url" it previously
|
||||
provided in its request to the issuance response. A
|
||||
HTTP GET to that location produces a JSON structure
|
||||
of the following form.
|
||||
</t>
|
||||
<t>
|
||||
<list style="hanging">
|
||||
<t></t><t hangText="certificate chain (mandatory):"></t>
|
||||
<t>A list of certificates in the order specified by
|
||||
TLS <xref target="RFC5246"/>
|
||||
(i.e., with each certificate certifying the next one and the
|
||||
end-entity certificate as the last one.). The
|
||||
CA SHOULD include the intermediate certificates
|
||||
that it believes a relying party would use,but
|
||||
ultimately it is the CH's job to sort this out.</t>
|
||||
|
||||
<t></t><t hangText="next_issuance (mandatory):"></t>
|
||||
<t>The time that the CA expects to automatically
|
||||
issue a replacement for the certificate, to be used
|
||||
for the CH to determine when to refresh.</t>
|
||||
</list>
|
||||
</t>
|
||||
</section>
|
||||
|
||||
<section title="Requesting Revocation" anchor="sec.revocation-request">
|
||||
<t>
|
||||
[TODO(ekr@rtfm.com): Should we refactor this and issuance to
|
||||
have the common elements in one place.]
|
||||
Under some circumstances, ACIP-issued certificates may need to
|
||||
be revoked. While conventional mechanisms (OCSP, CRLs, etc.)
|
||||
can be used for disseminating status information, an automatic
|
||||
mechanism is needed for informing the CA that the certificate
|
||||
should be revoked. As with certificate issuance, ACIP provides
|
||||
a generic framework for this request, which then must be validated
|
||||
by one or more of several validation mechanisms.
|
||||
</t>
|
||||
<t>
|
||||
The general form of the revocation request is:
|
||||
</t>
|
||||
<t>
|
||||
<list style="hanging">
|
||||
<t></t><t hangText="validation_method (mandatory):"></t>
|
||||
<t>The revocation validation method the CH has selected.</t>
|
||||
|
||||
<t></t><t hangText="certificates (mandatory):"></t>
|
||||
<t>A list of the certificates to revoke.
|
||||
Each entry in the list has a "certificate" field and
|
||||
may also have a "validation_info" field if required
|
||||
by the validation method.
|
||||
If multiple certificates
|
||||
have been provided, they MUST all have the same identities.
|
||||
</t>
|
||||
</list>
|
||||
</t>
|
||||
<t>
|
||||
For example:
|
||||
</t>
|
||||
<figure>
|
||||
<artwork><![CDATA[
|
||||
{
|
||||
"validation_method" : "shared_secret",
|
||||
"certificates" : [
|
||||
"certificate" : "<base-64-encoded cert>",
|
||||
"validation_info" : "<revocation-secret>"
|
||||
]
|
||||
}
|
||||
]]></artwork>
|
||||
</figure>
|
||||
<t>
|
||||
The CA responds to a revocation request either with an error
|
||||
or with an indication of what the CH needs to do in order to
|
||||
have the certificate revoked (type "revocation_response"),
|
||||
containing the following items:
|
||||
</t>
|
||||
<t>
|
||||
<list style="hanging">
|
||||
<t></t><t hangText="challenges (optional)"></t>
|
||||
<t>If the selected validation method requires a challenge, then
|
||||
the CA shall include the challenge data in the challenges
|
||||
value. The data is a list of values containing an identity
|
||||
field from the request. More than one challenge may be
|
||||
provided for a given identity, which means that the CH
|
||||
must comply with all of them.</t>
|
||||
|
||||
<t></t><t hangText="status_url (mandatory)"></t>
|
||||
<t>A URL which the CH can contact to find the status of the
|
||||
request. This MUST contain a large enough random component
|
||||
to avoid guessing (minumum 80 bits of entropy).</t>
|
||||
</list>
|
||||
</t>
|
||||
</section>
|
||||
|
||||
<section title="Validation Methods" anchor="sec.validation-types">
|
||||
<t>
|
||||
This section describes mechanisms for validating requests (both
|
||||
issuance and revocation). In practice, some of these may only
|
||||
be useful for validating issuance or revocation (noted below
|
||||
where known) but because there is significant overlap,
|
||||
and this is somewhat subject to CA policy, we simply describe
|
||||
validation requests.
|
||||
</t>
|
||||
<section title="Simple HTTPS" anchor="sec.simple-https">
|
||||
<t>
|
||||
The "Simple HTTPS" validation method consists simply of verifying
|
||||
that the operator of the server has authority to insert a given
|
||||
data value at a specific location in the /.well-known directory
|
||||
of the target Web server, specifically under "/.well-known/acip-verify".
|
||||
[TODO: Registration needed for this?]. The challenge information is a JSON
|
||||
[TODO: What if the server for some reason has an existing cert?
|
||||
SNI is a clumsy discrimination mechanism here...]
|
||||
dictionary with two values:
|
||||
</t>
|
||||
<t>
|
||||
<list style="hanging">
|
||||
<t></t><t hangText="path (mandatory):"></t>
|
||||
<t>The location in /.well-known/acip-verify to write the challenge value.</t>
|
||||
|
||||
<t></t><t hangText="value (mandatory):"></t>
|
||||
<t>The value to write.</t>
|
||||
|
||||
<t></t><t hangText="expected_certificate (mandatory):"></t>
|
||||
<t>The certificate which MUST be used by the CH server to authenticate the connection.</t>
|
||||
</list>
|
||||
</t>
|
||||
<t>
|
||||
For instance:
|
||||
</t>
|
||||
<figure>
|
||||
<artwork><![CDATA[
|
||||
{
|
||||
"path" : "abcdef",
|
||||
"value" : "12345",
|
||||
"expected_key" : "<base64-encoded key>"
|
||||
}
|
||||
]]></artwork>
|
||||
</figure>
|
||||
<t>
|
||||
In this example, the CH MUST write the string "12345" to the file
|
||||
/well-known/acip-verify/12345.
|
||||
</t>
|
||||
<t>
|
||||
The CH server MUST use the a certificate which matches the
|
||||
"expected_key" provided by the client. If the server has
|
||||
no such certificate, it MUST signal an error to the operator.
|
||||
In general, the expected_key will be chosen to match one of two
|
||||
values:
|
||||
</t>
|
||||
<t>
|
||||
<list style="symbols">
|
||||
<t>The key in the CSR.</t>
|
||||
<t>Some previous key that the operator knows the CH previously used.</t>
|
||||
</list>
|
||||
</t>
|
||||
<t>
|
||||
[TODO: Do we want to force both of these? if so we will need to discriminate
|
||||
via SNI. We could also require that the challenge be signed for POP...]
|
||||
</t>
|
||||
</section>
|
||||
<section title="Digital Signature" anchor="sec.digital-signature">
|
||||
<t>
|
||||
The digital signature validation request involves the CH
|
||||
signing a message requesting a service from a CA. This
|
||||
message MAY involve a challenge from the CA, but need not
|
||||
(for instance, a signed request for revocation need not
|
||||
involve a challenge, as the requester clearly had
|
||||
control of the key at some point and therefore a request
|
||||
for revocation seems in order.
|
||||
</t>
|
||||
<t>
|
||||
[TODO: RLB, please provide the JWT text here.]
|
||||
</t>
|
||||
</section>
|
||||
<section title="Secret Token" anchor="sec.secret-token">
|
||||
<t>
|
||||
In some cases, the CA and the CH may wish to establish a
|
||||
shared secret which can be used non-cryptographically to
|
||||
authenticate the CH to the CA. The most obvious example is
|
||||
that the CA may want to issue the CH a "revocation token"
|
||||
which it can use to authenticate revocation requests.
|
||||
This value could be comparatively low entropy if it is
|
||||
used solely to revoke the certificate.
|
||||
</t>
|
||||
</section>
|
||||
</section>
|
||||
<section title="Errors" anchor="sec.errors">
|
||||
<t>
|
||||
Any request from the CH to the CA can potentially generate an error
|
||||
(message type = "error"). The error message contents are:
|
||||
</t>
|
||||
<t>
|
||||
<list style="hanging">
|
||||
<t></t><t hangText="error_code (mandatory):"></t>
|
||||
<t>An IANA-defined error code.</t>
|
||||
|
||||
<t></t><t hangText="error_reason (optional):"></t>
|
||||
<t>A free-form text string providing further information. The
|
||||
error_reason field SHOULD be populated.</t>
|
||||
</list>
|
||||
</t>
|
||||
<t>
|
||||
For example:
|
||||
</t>
|
||||
|
||||
<figure>
|
||||
<artwork><![CDATA[
|
||||
{
|
||||
"error_code" : "invalid_key",
|
||||
"error_reason" : "RSA key less than 2048 bits provided."
|
||||
}
|
||||
]]>
|
||||
</artwork>
|
||||
</figure>
|
||||
|
||||
<t>
|
||||
The following error codes are defined.
|
||||
</t>
|
||||
<t>
|
||||
<list style="hanging">
|
||||
<t></t><t hangText="invalid_key:"></t>
|
||||
<t>The provided key was invalid for some reason.</t>
|
||||
|
||||
<t></t><t hangText="invalid_csr:"></t>
|
||||
<t>The provided CSR was invalid for some reason other than the key,
|
||||
e.g., because the signature is broken.</t>
|
||||
|
||||
<t></t><t hangText="invalid_validation_method:"></t>
|
||||
<t>The authentication method specified is unknown or unacceptable.</t>
|
||||
|
||||
|
||||
<t></t><t hangText="invalid_identity:"></t>
|
||||
<t>The requested identity is invalid for some reason, e.g., because
|
||||
it is improperly formatted.</t>
|
||||
|
||||
<t></t><t hangText="other_error:"></t>
|
||||
<t>There is some other error not covered by the above. The
|
||||
"error_reason" field MUST be populated for this error, as
|
||||
it is the only way for the CH to get information about what
|
||||
happened.</t>
|
||||
</list>
|
||||
</t>
|
||||
</section>
|
||||
<section title="Status Publication" anchor="sec.status">
|
||||
<t>
|
||||
CAs are encouraged to provide a status URL so that CHs can determine
|
||||
the status of a given request. A GET to the status URL returns a
|
||||
status document consisting of:
|
||||
</t>
|
||||
<t>
|
||||
<list style="hanging">
|
||||
<t></t><t hangText="request_status (mandatory):"></t>
|
||||
<t>One of the following values:
|
||||
|
||||
<list style="symbols">
|
||||
<t>"pending": the request is pending.</t>
|
||||
<t>"complete": the request has successfully completed.</t>
|
||||
<t>"failed": the request has failed</t>
|
||||
</list>
|
||||
</t>
|
||||
|
||||
<t></t><t hangText="error (optional):"></t>
|
||||
<t>If the request failed, the CA SHOULD supply the error information
|
||||
that would have been published in an error.</t>
|
||||
|
||||
<t></t><t hangText="log (optional):"></t>
|
||||
<t>A free-text field consisting of logging information that might be
|
||||
helpful to the CH.</t>
|
||||
</list>
|
||||
</t>
|
||||
</section>
|
||||
<section title="HTTPS Binding" anchor="sec.http-binding">
|
||||
<t>
|
||||
While the protocol defined in this document could in principle be
|
||||
carried over any transport, this document defines a single transport
|
||||
over HTTPS <xref target="RFC2818"/>.
|
||||
</t>
|
||||
<t>
|
||||
An CA has a single URL which it uses as its main
|
||||
ACIP endpoint. As discussed in <xref target="sec.overview"/>,
|
||||
the CH knows this URL via some out-of-band mechanism. The
|
||||
CA responds to both GET and POST requests at this URL
|
||||
as described below. All messages have the type "application/json" [REF].
|
||||
</t>
|
||||
<t>
|
||||
The response to any GET request is the policy document <xref target="sec.ca-policies"/>. The GET request SHOULD not contain any HTTP parameters or body and
|
||||
in any case these MUST be ignored.
|
||||
</t>
|
||||
<t>
|
||||
The CH issues instructions to the CA using HTTP POST. The
|
||||
body of the POST message contains an ACIP message as
|
||||
specified in <xref target="sec.messages"/>. Again, there
|
||||
SHOULD be no query arguments. If the message is not well-formed,
|
||||
than an HTTP 400 "bad request" error SHOULD be generated. If the
|
||||
message is well-formed, then the CA SHOULD attempt to process
|
||||
it and return any errors in the response body with an HTTP 200
|
||||
success code, rather than as an HTTP error.
|
||||
</t>
|
||||
<t>
|
||||
In addition to the above, the CA is expected to maintain a number
|
||||
of informational URLs for a given certificate or certificate request
|
||||
(e.g., "status_url" and "certificate_url") however these are
|
||||
orthogonal to the protocol used for interacting with the CA
|
||||
(since the client just does GETs to them) and therefore could
|
||||
in principle be used even if some other protocol were used
|
||||
to interact with the CA.
|
||||
</t>
|
||||
</section>
|
||||
</section>
|
||||
<section title="Security Considerations" anchor="sec.sec-cons">
|
||||
<t>
|
||||
<list style="symbols">
|
||||
<t>CAs should bind the challenge to the key to prevent referrals...</t>
|
||||
</list>
|
||||
</t>
|
||||
</section>
|
||||
|
||||
<section title="IANA Considerations" anchor="sec.iana-cons">
|
||||
<t>
|
||||
IANA [SHALL create/has created] an ACIP Validation Protocol specification
|
||||
registry. The values are ASCII strings [REF: 8859-1?] and the registry
|
||||
is initially populated with values as below. The registration policy
|
||||
is Specification Required <xref target="RFC5226"/>.
|
||||
</t>
|
||||
<texttable>
|
||||
<ttcol>Code Point</ttcol>
|
||||
<ttcol>Description</ttcol>
|
||||
<c>simple-https</c>
|
||||
<c><xref target="sec.simple-https"/></c>
|
||||
<c>https-sni</c>
|
||||
<c><xref target="sec.https-with-sni"/></c>
|
||||
<c>digital-signature</c>
|
||||
<c><xref target="sec.digital-signature"/></c>
|
||||
<c>secret-token</c>
|
||||
<c><xref target="sec.secret-token"/></c>
|
||||
</texttable>
|
||||
|
||||
<t>
|
||||
IANA [SHALL create/has created] an ACIP Error registry.
|
||||
The values are ASCII strings [REF: 8859-1?] and the registry
|
||||
is initially populated with values from <xref target="sec.errors"/>.
|
||||
The registration policy
|
||||
is Standards Action <xref target="RFC5226"/>. The
|
||||
</t>
|
||||
</section>
|
||||
|
||||
<section title="Acknowledgements" anchor="sec.acknowledgments">
|
||||
</section>
|
||||
</middle>
|
||||
<back>
|
||||
<references title="Normative References">
|
||||
&RFC2119;
|
||||
&RFC2314;
|
||||
&RFC2818;
|
||||
&RFC5226;
|
||||
&RFC5246;
|
||||
</references>
|
||||
<!--
|
||||
<references title="Informative References">
|
||||
</references>-->
|
||||
</back>
|
||||
</rfc>
|
||||
|
||||
1
acme/MANIFEST.in
Normal file
1
acme/MANIFEST.in
Normal file
|
|
@ -0,0 +1 @@
|
|||
recursive-include acme/testdata *
|
||||
12
acme/acme/__init__.py
Normal file
12
acme/acme/__init__.py
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
"""ACME protocol implementation.
|
||||
|
||||
This module is an implementation of the `ACME protocol`_. Latest
|
||||
supported version: `v02`_.
|
||||
|
||||
.. _`ACME protocol`: https://github.com/letsencrypt/acme-spec
|
||||
|
||||
.. _`v02`:
|
||||
https://github.com/letsencrypt/acme-spec/commit/d328fea2d507deb9822793c512830d827a4150c4
|
||||
|
||||
|
||||
"""
|
||||
454
acme/acme/challenges.py
Normal file
454
acme/acme/challenges.py
Normal file
|
|
@ -0,0 +1,454 @@
|
|||
"""ACME Identifier Validation Challenges."""
|
||||
import binascii
|
||||
import functools
|
||||
import hashlib
|
||||
import logging
|
||||
import os
|
||||
import socket
|
||||
|
||||
from cryptography.hazmat.backends import default_backend
|
||||
from cryptography import x509
|
||||
import OpenSSL
|
||||
import requests
|
||||
|
||||
from acme import errors
|
||||
from acme import crypto_util
|
||||
from acme import fields
|
||||
from acme import jose
|
||||
from acme import other
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# pylint: disable=too-few-public-methods
|
||||
|
||||
|
||||
class Challenge(jose.TypedJSONObjectWithFields):
|
||||
# _fields_to_partial_json | pylint: disable=abstract-method
|
||||
"""ACME challenge."""
|
||||
TYPES = {}
|
||||
|
||||
|
||||
class ContinuityChallenge(Challenge): # pylint: disable=abstract-method
|
||||
"""Client validation challenges."""
|
||||
|
||||
|
||||
class DVChallenge(Challenge): # pylint: disable=abstract-method
|
||||
"""Domain validation challenges."""
|
||||
|
||||
|
||||
class ChallengeResponse(jose.TypedJSONObjectWithFields):
|
||||
# _fields_to_partial_json | pylint: disable=abstract-method
|
||||
"""ACME challenge response."""
|
||||
TYPES = {}
|
||||
resource_type = 'challenge'
|
||||
resource = fields.Resource(resource_type)
|
||||
|
||||
|
||||
@Challenge.register
|
||||
class SimpleHTTP(DVChallenge):
|
||||
"""ACME "simpleHttp" challenge.
|
||||
|
||||
:ivar unicode token:
|
||||
|
||||
"""
|
||||
typ = "simpleHttp"
|
||||
token = jose.Field("token")
|
||||
|
||||
|
||||
@ChallengeResponse.register
|
||||
class SimpleHTTPResponse(ChallengeResponse):
|
||||
"""ACME "simpleHttp" challenge response.
|
||||
|
||||
:ivar unicode path:
|
||||
:ivar unicode tls:
|
||||
|
||||
"""
|
||||
typ = "simpleHttp"
|
||||
path = jose.Field("path")
|
||||
tls = jose.Field("tls", default=True, omitempty=True)
|
||||
|
||||
URI_ROOT_PATH = ".well-known/acme-challenge"
|
||||
"""URI root path for the server provisioned resource."""
|
||||
|
||||
_URI_TEMPLATE = "{scheme}://{domain}/" + URI_ROOT_PATH + "/{path}"
|
||||
|
||||
MAX_PATH_LEN = 25
|
||||
"""Maximum allowed `path` length."""
|
||||
|
||||
CONTENT_TYPE = "text/plain"
|
||||
|
||||
@property
|
||||
def good_path(self):
|
||||
"""Is `path` good?
|
||||
|
||||
.. todo:: acme-spec: "The value MUST be comprised entirely of
|
||||
characters from the URL-safe alphabet for Base64 encoding
|
||||
[RFC4648]", base64.b64decode ignores those characters
|
||||
|
||||
"""
|
||||
# TODO: check that path combined with uri does not go above
|
||||
# URI_ROOT_PATH!
|
||||
return len(self.path) <= 25
|
||||
|
||||
@property
|
||||
def scheme(self):
|
||||
"""URL scheme for the provisioned resource."""
|
||||
return "https" if self.tls else "http"
|
||||
|
||||
@property
|
||||
def port(self):
|
||||
"""Port that the ACME client should be listening for validation."""
|
||||
return 443 if self.tls else 80
|
||||
|
||||
def uri(self, domain):
|
||||
"""Create an URI to the provisioned resource.
|
||||
|
||||
Forms an URI to the HTTPS server provisioned resource
|
||||
(containing :attr:`~SimpleHTTP.token`).
|
||||
|
||||
:param unicode domain: Domain name being verified.
|
||||
|
||||
"""
|
||||
return self._URI_TEMPLATE.format(
|
||||
scheme=self.scheme, domain=domain, path=self.path)
|
||||
|
||||
def simple_verify(self, chall, domain, port=None):
|
||||
"""Simple verify.
|
||||
|
||||
According to the ACME specification, "the ACME server MUST
|
||||
ignore the certificate provided by the HTTPS server", so
|
||||
``requests.get`` is called with ``verify=False``.
|
||||
|
||||
:param .SimpleHTTP chall: Corresponding challenge.
|
||||
:param unicode domain: Domain name being verified.
|
||||
:param int port: Port used in the validation.
|
||||
|
||||
:returns: ``True`` iff validation is successful, ``False``
|
||||
otherwise.
|
||||
:rtype: bool
|
||||
|
||||
"""
|
||||
# TODO: ACME specification defines URI template that doesn't
|
||||
# allow to use a custom port... Make sure port is not in the
|
||||
# request URI, if it's standard.
|
||||
if port is not None and port != self.port:
|
||||
logger.warn(
|
||||
"Using non-standard port for SimpleHTTP verification: %s", port)
|
||||
domain += ":{0}".format(port)
|
||||
|
||||
uri = self.uri(domain)
|
||||
logger.debug("Verifying %s at %s...", chall.typ, uri)
|
||||
try:
|
||||
http_response = requests.get(uri, verify=False)
|
||||
except requests.exceptions.RequestException as error:
|
||||
logger.error("Unable to reach %s: %s", uri, error)
|
||||
return False
|
||||
logger.debug(
|
||||
"Received %s. Headers: %s", http_response, http_response.headers)
|
||||
|
||||
good_token = http_response.text == chall.token
|
||||
if not good_token:
|
||||
logger.error(
|
||||
"Unable to verify %s! Expected: %r, returned: %r.",
|
||||
uri, chall.token, http_response.text)
|
||||
# TODO: spec contradicts itself, c.f.
|
||||
# https://github.com/letsencrypt/acme-spec/pull/156/files#r33136438
|
||||
good_ct = self.CONTENT_TYPE == http_response.headers.get(
|
||||
"Content-Type", self.CONTENT_TYPE)
|
||||
return self.good_path and good_ct and good_token
|
||||
|
||||
|
||||
@Challenge.register
|
||||
class DVSNI(DVChallenge):
|
||||
"""ACME "dvsni" challenge.
|
||||
|
||||
:ivar bytes r: Random data, **not** base64-encoded.
|
||||
:ivar bytes nonce: Random data, **not** hex-encoded.
|
||||
|
||||
"""
|
||||
typ = "dvsni"
|
||||
|
||||
DOMAIN_SUFFIX = b".acme.invalid"
|
||||
"""Domain name suffix."""
|
||||
|
||||
R_SIZE = 32
|
||||
"""Required size of the :attr:`r` in bytes."""
|
||||
|
||||
NONCE_SIZE = 16
|
||||
"""Required size of the :attr:`nonce` in bytes."""
|
||||
|
||||
PORT = 443
|
||||
"""Port to perform DVSNI challenge."""
|
||||
|
||||
r = jose.Field("r", encoder=jose.encode_b64jose, # pylint: disable=invalid-name
|
||||
decoder=functools.partial(jose.decode_b64jose, size=R_SIZE))
|
||||
nonce = jose.Field("nonce", encoder=jose.encode_hex16,
|
||||
decoder=functools.partial(functools.partial(
|
||||
jose.decode_hex16, size=NONCE_SIZE)))
|
||||
|
||||
@property
|
||||
def nonce_domain(self):
|
||||
"""Domain name used in SNI.
|
||||
|
||||
:rtype: bytes
|
||||
|
||||
"""
|
||||
return binascii.hexlify(self.nonce) + self.DOMAIN_SUFFIX
|
||||
|
||||
def probe_cert(self, domain, **kwargs):
|
||||
"""Probe DVSNI challenge certificate."""
|
||||
host = socket.gethostbyname(domain)
|
||||
logging.debug('%s resolved to %s', domain, host)
|
||||
|
||||
kwargs.setdefault("host", host)
|
||||
kwargs.setdefault("port", self.PORT)
|
||||
kwargs["name"] = self.nonce_domain
|
||||
# TODO: try different methods?
|
||||
# pylint: disable=protected-access
|
||||
return crypto_util._probe_sni(**kwargs)
|
||||
|
||||
|
||||
@ChallengeResponse.register
|
||||
class DVSNIResponse(ChallengeResponse):
|
||||
"""ACME "dvsni" challenge response.
|
||||
|
||||
:param bytes s: Random data, **not** base64-encoded.
|
||||
|
||||
"""
|
||||
typ = "dvsni"
|
||||
|
||||
DOMAIN_SUFFIX = DVSNI.DOMAIN_SUFFIX
|
||||
"""Domain name suffix."""
|
||||
|
||||
S_SIZE = 32
|
||||
"""Required size of the :attr:`s` in bytes."""
|
||||
|
||||
s = jose.Field("s", encoder=jose.encode_b64jose, # pylint: disable=invalid-name
|
||||
decoder=functools.partial(jose.decode_b64jose, size=S_SIZE))
|
||||
|
||||
def __init__(self, s=None, *args, **kwargs):
|
||||
s = os.urandom(self.S_SIZE) if s is None else s
|
||||
super(DVSNIResponse, self).__init__(s=s, *args, **kwargs)
|
||||
|
||||
def z(self, chall): # pylint: disable=invalid-name
|
||||
"""Compute the parameter ``z``.
|
||||
|
||||
:param challenge: Corresponding challenge.
|
||||
:type challenge: :class:`DVSNI`
|
||||
|
||||
:rtype: bytes
|
||||
|
||||
"""
|
||||
z = hashlib.new("sha256") # pylint: disable=invalid-name
|
||||
z.update(chall.r)
|
||||
z.update(self.s)
|
||||
return z.hexdigest().encode()
|
||||
|
||||
def z_domain(self, chall):
|
||||
"""Domain name for certificate subjectAltName.
|
||||
|
||||
:rtype bytes:
|
||||
|
||||
"""
|
||||
return self.z(chall) + self.DOMAIN_SUFFIX
|
||||
|
||||
def gen_cert(self, chall, domain, key):
|
||||
"""Generate DVSNI certificate.
|
||||
|
||||
:param .DVSNI chall: Corresponding challenge.
|
||||
:param unicode domain:
|
||||
:param OpenSSL.crypto.PKey
|
||||
|
||||
"""
|
||||
return crypto_util.gen_ss_cert(key, [
|
||||
domain, chall.nonce_domain.decode(), self.z_domain(chall).decode()])
|
||||
|
||||
def simple_verify(self, chall, domain, public_key, **kwargs):
|
||||
"""Simple verify.
|
||||
|
||||
Probes DVSNI certificate and checks it using `verify_cert`;
|
||||
hence all arguments documented in `verify_cert`.
|
||||
|
||||
"""
|
||||
try:
|
||||
cert = chall.probe_cert(domain=domain, **kwargs)
|
||||
except errors.Error as error:
|
||||
logger.debug(error, exc_info=True)
|
||||
return False
|
||||
return self.verify_cert(chall, domain, public_key, cert)
|
||||
|
||||
def verify_cert(self, chall, domain, public_key, cert):
|
||||
"""Verify DVSNI certificate.
|
||||
|
||||
:param .challenges.DVSNI chall: Corresponding challenge.
|
||||
:param str domain: Domain name being validated.
|
||||
:param public_key: Public key for the key pair
|
||||
being authorized. If ``None`` key verification is not
|
||||
performed!
|
||||
:type public_key:
|
||||
`~cryptography.hazmat.primitives.asymmetric.rsa.RSAPublicKey`
|
||||
or
|
||||
`~cryptography.hazmat.primitives.asymmetric.dsa.DSAPublicKey`
|
||||
or
|
||||
`~cryptography.hazmat.primitives.asymmetric.ec.EllipticCurvePublicKey`
|
||||
wrapped in `.ComparableKey
|
||||
:param OpenSSL.crypto.X509 cert:
|
||||
|
||||
:returns: ``True`` iff client's control of the domain has been
|
||||
verified, ``False`` otherwise.
|
||||
:rtype: bool
|
||||
|
||||
"""
|
||||
# TODO: check "It is a valid self-signed certificate" and
|
||||
# return False if not
|
||||
|
||||
# pylint: disable=protected-access
|
||||
sans = crypto_util._pyopenssl_cert_or_req_san(cert)
|
||||
logging.debug('Certificate %s. SANs: %s', cert.digest('sha1'), sans)
|
||||
|
||||
cert = x509.load_der_x509_certificate(
|
||||
OpenSSL.crypto.dump_certificate(OpenSSL.crypto.FILETYPE_ASN1, cert),
|
||||
default_backend())
|
||||
|
||||
if public_key is None:
|
||||
logging.warn('No key verification is performed')
|
||||
elif public_key != jose.ComparableKey(cert.public_key()):
|
||||
return False
|
||||
|
||||
return domain in sans and self.z_domain(chall).decode() in sans
|
||||
|
||||
|
||||
@Challenge.register
|
||||
class RecoveryContact(ContinuityChallenge):
|
||||
"""ACME "recoveryContact" challenge.
|
||||
|
||||
:ivar unicode activation_url:
|
||||
:ivar unicode success_url:
|
||||
:ivar unicode contact:
|
||||
|
||||
"""
|
||||
typ = "recoveryContact"
|
||||
|
||||
activation_url = jose.Field("activationURL", omitempty=True)
|
||||
success_url = jose.Field("successURL", omitempty=True)
|
||||
contact = jose.Field("contact", omitempty=True)
|
||||
|
||||
|
||||
@ChallengeResponse.register
|
||||
class RecoveryContactResponse(ChallengeResponse):
|
||||
"""ACME "recoveryContact" challenge response.
|
||||
|
||||
:ivar unicode token:
|
||||
|
||||
"""
|
||||
typ = "recoveryContact"
|
||||
token = jose.Field("token", omitempty=True)
|
||||
|
||||
|
||||
@Challenge.register
|
||||
class RecoveryToken(ContinuityChallenge):
|
||||
"""ACME "recoveryToken" challenge."""
|
||||
typ = "recoveryToken"
|
||||
|
||||
|
||||
@ChallengeResponse.register
|
||||
class RecoveryTokenResponse(ChallengeResponse):
|
||||
"""ACME "recoveryToken" challenge response.
|
||||
|
||||
:ivar unicode token:
|
||||
|
||||
"""
|
||||
typ = "recoveryToken"
|
||||
token = jose.Field("token", omitempty=True)
|
||||
|
||||
|
||||
@Challenge.register
|
||||
class ProofOfPossession(ContinuityChallenge):
|
||||
"""ACME "proofOfPossession" challenge.
|
||||
|
||||
:ivar .JWAAlgorithm alg:
|
||||
:ivar bytes nonce: Random data, **not** base64-encoded.
|
||||
:ivar hints: Various clues for the client (:class:`Hints`).
|
||||
|
||||
"""
|
||||
typ = "proofOfPossession"
|
||||
|
||||
NONCE_SIZE = 16
|
||||
|
||||
class Hints(jose.JSONObjectWithFields):
|
||||
"""Hints for "proofOfPossession" challenge.
|
||||
|
||||
:ivar jwk: JSON Web Key (:class:`acme.jose.JWK`)
|
||||
:ivar tuple cert_fingerprints: `tuple` of `unicode`
|
||||
:ivar tuple certs: Sequence of :class:`acme.jose.ComparableX509`
|
||||
certificates.
|
||||
:ivar tuple subject_key_identifiers: `tuple` of `unicode`
|
||||
:ivar tuple issuers: `tuple` of `unicode`
|
||||
:ivar tuple authorized_for: `tuple` of `unicode`
|
||||
|
||||
"""
|
||||
jwk = jose.Field("jwk", decoder=jose.JWK.from_json)
|
||||
cert_fingerprints = jose.Field(
|
||||
"certFingerprints", omitempty=True, default=())
|
||||
certs = jose.Field("certs", omitempty=True, default=())
|
||||
subject_key_identifiers = jose.Field(
|
||||
"subjectKeyIdentifiers", omitempty=True, default=())
|
||||
serial_numbers = jose.Field("serialNumbers", omitempty=True, default=())
|
||||
issuers = jose.Field("issuers", omitempty=True, default=())
|
||||
authorized_for = jose.Field("authorizedFor", omitempty=True, default=())
|
||||
|
||||
@certs.encoder
|
||||
def certs(value): # pylint: disable=missing-docstring,no-self-argument
|
||||
return tuple(jose.encode_cert(cert) for cert in value)
|
||||
|
||||
@certs.decoder
|
||||
def certs(value): # pylint: disable=missing-docstring,no-self-argument
|
||||
return tuple(jose.decode_cert(cert) for cert in value)
|
||||
|
||||
alg = jose.Field("alg", decoder=jose.JWASignature.from_json)
|
||||
nonce = jose.Field(
|
||||
"nonce", encoder=jose.encode_b64jose, decoder=functools.partial(
|
||||
jose.decode_b64jose, size=NONCE_SIZE))
|
||||
hints = jose.Field("hints", decoder=Hints.from_json)
|
||||
|
||||
|
||||
@ChallengeResponse.register
|
||||
class ProofOfPossessionResponse(ChallengeResponse):
|
||||
"""ACME "proofOfPossession" challenge response.
|
||||
|
||||
:ivar bytes nonce: Random data, **not** base64-encoded.
|
||||
:ivar acme.other.Signature signature: Sugnature of this message.
|
||||
|
||||
"""
|
||||
typ = "proofOfPossession"
|
||||
|
||||
NONCE_SIZE = ProofOfPossession.NONCE_SIZE
|
||||
|
||||
nonce = jose.Field(
|
||||
"nonce", encoder=jose.encode_b64jose, decoder=functools.partial(
|
||||
jose.decode_b64jose, size=NONCE_SIZE))
|
||||
signature = jose.Field("signature", decoder=other.Signature.from_json)
|
||||
|
||||
def verify(self):
|
||||
"""Verify the challenge."""
|
||||
# self.signature is not Field | pylint: disable=no-member
|
||||
return self.signature.verify(self.nonce)
|
||||
|
||||
|
||||
@Challenge.register
|
||||
class DNS(DVChallenge):
|
||||
"""ACME "dns" challenge.
|
||||
|
||||
:ivar unicode token:
|
||||
|
||||
"""
|
||||
typ = "dns"
|
||||
token = jose.Field("token")
|
||||
|
||||
|
||||
@ChallengeResponse.register
|
||||
class DNSResponse(ChallengeResponse):
|
||||
"""ACME "dns" challenge response."""
|
||||
typ = "dns"
|
||||
611
acme/acme/challenges_test.py
Normal file
611
acme/acme/challenges_test.py
Normal file
|
|
@ -0,0 +1,611 @@
|
|||
"""Tests for acme.challenges."""
|
||||
import unittest
|
||||
|
||||
import mock
|
||||
import OpenSSL
|
||||
import requests
|
||||
|
||||
from six.moves.urllib import parse as urllib_parse # pylint: disable=import-error
|
||||
|
||||
from acme import errors
|
||||
from acme import jose
|
||||
from acme import other
|
||||
from acme import test_util
|
||||
|
||||
|
||||
CERT = test_util.load_cert('cert.pem')
|
||||
KEY = test_util.load_rsa_private_key('rsa512_key.pem')
|
||||
|
||||
|
||||
class SimpleHTTPTest(unittest.TestCase):
|
||||
|
||||
def setUp(self):
|
||||
from acme.challenges import SimpleHTTP
|
||||
self.msg = SimpleHTTP(
|
||||
token='evaGxfADs6pSRb2LAv9IZf17Dt3juxGJ+PCt92wr+oA')
|
||||
self.jmsg = {
|
||||
'type': 'simpleHttp',
|
||||
'token': 'evaGxfADs6pSRb2LAv9IZf17Dt3juxGJ+PCt92wr+oA',
|
||||
}
|
||||
|
||||
def test_to_partial_json(self):
|
||||
self.assertEqual(self.jmsg, self.msg.to_partial_json())
|
||||
|
||||
def test_from_json(self):
|
||||
from acme.challenges import SimpleHTTP
|
||||
self.assertEqual(self.msg, SimpleHTTP.from_json(self.jmsg))
|
||||
|
||||
def test_from_json_hashable(self):
|
||||
from acme.challenges import SimpleHTTP
|
||||
hash(SimpleHTTP.from_json(self.jmsg))
|
||||
|
||||
|
||||
class SimpleHTTPResponseTest(unittest.TestCase):
|
||||
# pylint: disable=too-many-instance-attributes
|
||||
|
||||
def setUp(self):
|
||||
from acme.challenges import SimpleHTTPResponse
|
||||
self.msg_http = SimpleHTTPResponse(
|
||||
path='6tbIMBC5Anhl5bOlWT5ZFA', tls=False)
|
||||
self.msg_https = SimpleHTTPResponse(path='6tbIMBC5Anhl5bOlWT5ZFA')
|
||||
self.jmsg_http = {
|
||||
'resource': 'challenge',
|
||||
'type': 'simpleHttp',
|
||||
'path': '6tbIMBC5Anhl5bOlWT5ZFA',
|
||||
'tls': False,
|
||||
}
|
||||
self.jmsg_https = {
|
||||
'resource': 'challenge',
|
||||
'type': 'simpleHttp',
|
||||
'path': '6tbIMBC5Anhl5bOlWT5ZFA',
|
||||
'tls': True,
|
||||
}
|
||||
|
||||
from acme.challenges import SimpleHTTP
|
||||
self.chall = SimpleHTTP(token="foo")
|
||||
self.resp_http = SimpleHTTPResponse(path="bar", tls=False)
|
||||
self.resp_https = SimpleHTTPResponse(path="bar", tls=True)
|
||||
self.good_headers = {'Content-Type': SimpleHTTPResponse.CONTENT_TYPE}
|
||||
|
||||
def test_good_path(self):
|
||||
self.assertTrue(self.msg_http.good_path)
|
||||
self.assertTrue(self.msg_https.good_path)
|
||||
self.assertFalse(
|
||||
self.msg_http.update(path=(self.msg_http.path * 10)).good_path)
|
||||
|
||||
def test_scheme(self):
|
||||
self.assertEqual('http', self.msg_http.scheme)
|
||||
self.assertEqual('https', self.msg_https.scheme)
|
||||
|
||||
def test_port(self):
|
||||
self.assertEqual(80, self.msg_http.port)
|
||||
self.assertEqual(443, self.msg_https.port)
|
||||
|
||||
def test_uri(self):
|
||||
self.assertEqual(
|
||||
'http://example.com/.well-known/acme-challenge/'
|
||||
'6tbIMBC5Anhl5bOlWT5ZFA', self.msg_http.uri('example.com'))
|
||||
self.assertEqual(
|
||||
'https://example.com/.well-known/acme-challenge/'
|
||||
'6tbIMBC5Anhl5bOlWT5ZFA', self.msg_https.uri('example.com'))
|
||||
|
||||
def test_to_partial_json(self):
|
||||
self.assertEqual(self.jmsg_http, self.msg_http.to_partial_json())
|
||||
self.assertEqual(self.jmsg_https, self.msg_https.to_partial_json())
|
||||
|
||||
def test_from_json(self):
|
||||
from acme.challenges import SimpleHTTPResponse
|
||||
self.assertEqual(
|
||||
self.msg_http, SimpleHTTPResponse.from_json(self.jmsg_http))
|
||||
self.assertEqual(
|
||||
self.msg_https, SimpleHTTPResponse.from_json(self.jmsg_https))
|
||||
|
||||
def test_from_json_hashable(self):
|
||||
from acme.challenges import SimpleHTTPResponse
|
||||
hash(SimpleHTTPResponse.from_json(self.jmsg_http))
|
||||
hash(SimpleHTTPResponse.from_json(self.jmsg_https))
|
||||
|
||||
@mock.patch("acme.challenges.requests.get")
|
||||
def test_simple_verify_good_token(self, mock_get):
|
||||
for resp in self.resp_http, self.resp_https:
|
||||
mock_get.reset_mock()
|
||||
mock_get.return_value = mock.MagicMock(
|
||||
text=self.chall.token, headers=self.good_headers)
|
||||
self.assertTrue(resp.simple_verify(self.chall, "local"))
|
||||
mock_get.assert_called_once_with(resp.uri("local"), verify=False)
|
||||
|
||||
@mock.patch("acme.challenges.requests.get")
|
||||
def test_simple_verify_bad_token(self, mock_get):
|
||||
mock_get.return_value = mock.MagicMock(
|
||||
text=self.chall.token + "!", headers=self.good_headers)
|
||||
self.assertFalse(self.resp_http.simple_verify(self.chall, "local"))
|
||||
|
||||
@mock.patch("acme.challenges.requests.get")
|
||||
def test_simple_verify_bad_content_type(self, mock_get):
|
||||
mock_get().text = self.chall.token
|
||||
self.assertFalse(self.resp_http.simple_verify(self.chall, "local"))
|
||||
|
||||
@mock.patch("acme.challenges.requests.get")
|
||||
def test_simple_verify_connection_error(self, mock_get):
|
||||
mock_get.side_effect = requests.exceptions.RequestException
|
||||
self.assertFalse(self.resp_http.simple_verify(self.chall, "local"))
|
||||
|
||||
@mock.patch("acme.challenges.requests.get")
|
||||
def test_simple_verify_port(self, mock_get):
|
||||
self.resp_http.simple_verify(self.chall, "local", 4430)
|
||||
self.assertEqual("local:4430", urllib_parse.urlparse(
|
||||
mock_get.mock_calls[0][1][0]).netloc)
|
||||
|
||||
|
||||
class DVSNITest(unittest.TestCase):
|
||||
|
||||
def setUp(self):
|
||||
from acme.challenges import DVSNI
|
||||
self.msg = DVSNI(
|
||||
r=b"O*\xb4-\xad\xec\x95>\xed\xa9\r0\x94\xe8\x97\x9c&6"
|
||||
b"\xbf'\xb3\xed\x9a9nX\x0f'\\m\xe7\x12",
|
||||
nonce=b'\xa8-_\xf8\xeft\r\x12\x88\x1fm<"w\xab.')
|
||||
self.jmsg = {
|
||||
'type': 'dvsni',
|
||||
'r': 'Tyq0La3slT7tqQ0wlOiXnCY2vyez7Zo5blgPJ1xt5xI',
|
||||
'nonce': 'a82d5ff8ef740d12881f6d3c2277ab2e',
|
||||
}
|
||||
|
||||
def test_nonce_domain(self):
|
||||
self.assertEqual(b'a82d5ff8ef740d12881f6d3c2277ab2e.acme.invalid',
|
||||
self.msg.nonce_domain)
|
||||
|
||||
def test_to_partial_json(self):
|
||||
self.assertEqual(self.jmsg, self.msg.to_partial_json())
|
||||
|
||||
def test_from_json(self):
|
||||
from acme.challenges import DVSNI
|
||||
self.assertEqual(self.msg, DVSNI.from_json(self.jmsg))
|
||||
|
||||
def test_from_json_hashable(self):
|
||||
from acme.challenges import DVSNI
|
||||
hash(DVSNI.from_json(self.jmsg))
|
||||
|
||||
def test_from_json_invalid_r_length(self):
|
||||
from acme.challenges import DVSNI
|
||||
self.jmsg['r'] = 'abcd'
|
||||
self.assertRaises(
|
||||
jose.DeserializationError, DVSNI.from_json, self.jmsg)
|
||||
|
||||
def test_from_json_invalid_nonce_length(self):
|
||||
from acme.challenges import DVSNI
|
||||
self.jmsg['nonce'] = 'abcd'
|
||||
self.assertRaises(
|
||||
jose.DeserializationError, DVSNI.from_json, self.jmsg)
|
||||
|
||||
@mock.patch('acme.challenges.socket.gethostbyname')
|
||||
@mock.patch('acme.challenges.crypto_util._probe_sni')
|
||||
def test_probe_cert(self, mock_probe_sni, mock_gethostbyname):
|
||||
mock_gethostbyname.return_value = '127.0.0.1'
|
||||
self.msg.probe_cert('foo.com')
|
||||
mock_gethostbyname.assert_called_once_with('foo.com')
|
||||
mock_probe_sni.assert_called_once_with(
|
||||
host='127.0.0.1', port=self.msg.PORT,
|
||||
name=b'a82d5ff8ef740d12881f6d3c2277ab2e.acme.invalid')
|
||||
|
||||
self.msg.probe_cert('foo.com', host='8.8.8.8')
|
||||
mock_probe_sni.assert_called_with(
|
||||
host='8.8.8.8', port=mock.ANY, name=mock.ANY)
|
||||
|
||||
self.msg.probe_cert('foo.com', port=1234)
|
||||
mock_probe_sni.assert_called_with(
|
||||
host=mock.ANY, port=1234, name=mock.ANY)
|
||||
|
||||
self.msg.probe_cert('foo.com', bar='baz')
|
||||
mock_probe_sni.assert_called_with(
|
||||
host=mock.ANY, port=mock.ANY, name=mock.ANY, bar='baz')
|
||||
|
||||
self.msg.probe_cert('foo.com', name=b'xxx')
|
||||
mock_probe_sni.assert_called_with(
|
||||
host=mock.ANY, port=mock.ANY,
|
||||
name=b'a82d5ff8ef740d12881f6d3c2277ab2e.acme.invalid')
|
||||
|
||||
|
||||
class DVSNIResponseTest(unittest.TestCase):
|
||||
|
||||
def setUp(self):
|
||||
from acme.challenges import DVSNIResponse
|
||||
# pylint: disable=invalid-name
|
||||
s = '9dbjsl3gTAtOnEtKFEmhS6Mj-ajNjDcOmRkp3Lfzm3c'
|
||||
self.msg = DVSNIResponse(s=jose.decode_b64jose(s))
|
||||
self.jmsg = {
|
||||
'resource': 'challenge',
|
||||
'type': 'dvsni',
|
||||
's': s,
|
||||
}
|
||||
|
||||
from acme.challenges import DVSNI
|
||||
self.chall = DVSNI(
|
||||
r=jose.decode_b64jose('Tyq0La3slT7tqQ0wlOiXnCY2vyez7Zo5blgPJ1xt5xI'),
|
||||
nonce=jose.decode_b64jose('a82d5ff8ef740d12881f6d3c2277ab2e'))
|
||||
self.z = (b'38e612b0397cc2624a07d351d7ef50e4'
|
||||
b'6134c0213d9ed52f7d7c611acaeed41b')
|
||||
self.domain = 'foo.com'
|
||||
self.key = test_util.load_pyopenssl_private_key('rsa512_key.pem')
|
||||
self.public_key = test_util.load_rsa_private_key(
|
||||
'rsa512_key.pem').public_key()
|
||||
|
||||
def test_z_and_domain(self):
|
||||
# pylint: disable=invalid-name
|
||||
self.assertEqual(self.z, self.msg.z(self.chall))
|
||||
self.assertEqual(
|
||||
self.z + b'.acme.invalid', self.msg.z_domain(self.chall))
|
||||
|
||||
def test_to_partial_json(self):
|
||||
self.assertEqual(self.jmsg, self.msg.to_partial_json())
|
||||
|
||||
def test_from_json(self):
|
||||
from acme.challenges import DVSNIResponse
|
||||
self.assertEqual(self.msg, DVSNIResponse.from_json(self.jmsg))
|
||||
|
||||
def test_from_json_hashable(self):
|
||||
from acme.challenges import DVSNIResponse
|
||||
hash(DVSNIResponse.from_json(self.jmsg))
|
||||
|
||||
@mock.patch('acme.challenges.DVSNIResponse.verify_cert')
|
||||
def test_simple_verify(self, mock_verify_cert):
|
||||
chall = mock.Mock()
|
||||
chall.probe_cert.return_value = mock.sentinel.cert
|
||||
mock_verify_cert.return_value = 'x'
|
||||
self.assertEqual('x', self.msg.simple_verify(
|
||||
chall, mock.sentinel.domain, mock.sentinel.key))
|
||||
chall.probe_cert.assert_called_once_with(domain=mock.sentinel.domain)
|
||||
self.msg.verify_cert.assert_called_once_with(
|
||||
chall, mock.sentinel.domain, mock.sentinel.key,
|
||||
mock.sentinel.cert)
|
||||
|
||||
def test_simple_verify_false_on_probe_error(self):
|
||||
chall = mock.Mock()
|
||||
chall.probe_cert.side_effect = errors.Error
|
||||
self.assertFalse(self.msg.simple_verify(
|
||||
chall=chall, domain=None, public_key=None))
|
||||
|
||||
def test_gen_verify_cert_postive_no_key(self):
|
||||
cert = self.msg.gen_cert(self.chall, self.domain, self.key)
|
||||
self.assertTrue(self.msg.verify_cert(
|
||||
self.chall, self.domain, public_key=None, cert=cert))
|
||||
|
||||
def test_gen_verify_cert_postive_with_key(self):
|
||||
cert = self.msg.gen_cert(self.chall, self.domain, self.key)
|
||||
self.assertTrue(self.msg.verify_cert(
|
||||
self.chall, self.domain, public_key=self.public_key, cert=cert))
|
||||
|
||||
def test_gen_verify_cert_negative_with_wrong_key(self):
|
||||
cert = self.msg.gen_cert(self.chall, self.domain, self.key)
|
||||
key = test_util.load_rsa_private_key('rsa256_key.pem').public_key()
|
||||
self.assertFalse(self.msg.verify_cert(
|
||||
self.chall, self.domain, public_key=key, cert=cert))
|
||||
|
||||
def test_gen_verify_cert_negative(self):
|
||||
cert = self.msg.gen_cert(self.chall, self.domain + 'x', self.key)
|
||||
self.assertFalse(self.msg.verify_cert(
|
||||
self.chall, self.domain, public_key=None, cert=cert))
|
||||
|
||||
|
||||
class RecoveryContactTest(unittest.TestCase):
|
||||
|
||||
def setUp(self):
|
||||
from acme.challenges import RecoveryContact
|
||||
self.msg = RecoveryContact(
|
||||
activation_url='https://example.ca/sendrecovery/a5bd99383fb0',
|
||||
success_url='https://example.ca/confirmrecovery/bb1b9928932',
|
||||
contact='c********n@example.com')
|
||||
self.jmsg = {
|
||||
'type': 'recoveryContact',
|
||||
'activationURL' : 'https://example.ca/sendrecovery/a5bd99383fb0',
|
||||
'successURL' : 'https://example.ca/confirmrecovery/bb1b9928932',
|
||||
'contact' : 'c********n@example.com',
|
||||
}
|
||||
|
||||
def test_to_partial_json(self):
|
||||
self.assertEqual(self.jmsg, self.msg.to_partial_json())
|
||||
|
||||
def test_from_json(self):
|
||||
from acme.challenges import RecoveryContact
|
||||
self.assertEqual(self.msg, RecoveryContact.from_json(self.jmsg))
|
||||
|
||||
def test_from_json_hashable(self):
|
||||
from acme.challenges import RecoveryContact
|
||||
hash(RecoveryContact.from_json(self.jmsg))
|
||||
|
||||
def test_json_without_optionals(self):
|
||||
del self.jmsg['activationURL']
|
||||
del self.jmsg['successURL']
|
||||
del self.jmsg['contact']
|
||||
|
||||
from acme.challenges import RecoveryContact
|
||||
msg = RecoveryContact.from_json(self.jmsg)
|
||||
|
||||
self.assertTrue(msg.activation_url is None)
|
||||
self.assertTrue(msg.success_url is None)
|
||||
self.assertTrue(msg.contact is None)
|
||||
self.assertEqual(self.jmsg, msg.to_partial_json())
|
||||
|
||||
|
||||
class RecoveryContactResponseTest(unittest.TestCase):
|
||||
|
||||
def setUp(self):
|
||||
from acme.challenges import RecoveryContactResponse
|
||||
self.msg = RecoveryContactResponse(token='23029d88d9e123e')
|
||||
self.jmsg = {
|
||||
'resource': 'challenge',
|
||||
'type': 'recoveryContact',
|
||||
'token': '23029d88d9e123e',
|
||||
}
|
||||
|
||||
def test_to_partial_json(self):
|
||||
self.assertEqual(self.jmsg, self.msg.to_partial_json())
|
||||
|
||||
def test_from_json(self):
|
||||
from acme.challenges import RecoveryContactResponse
|
||||
self.assertEqual(
|
||||
self.msg, RecoveryContactResponse.from_json(self.jmsg))
|
||||
|
||||
def test_from_json_hashable(self):
|
||||
from acme.challenges import RecoveryContactResponse
|
||||
hash(RecoveryContactResponse.from_json(self.jmsg))
|
||||
|
||||
def test_json_without_optionals(self):
|
||||
del self.jmsg['token']
|
||||
|
||||
from acme.challenges import RecoveryContactResponse
|
||||
msg = RecoveryContactResponse.from_json(self.jmsg)
|
||||
|
||||
self.assertTrue(msg.token is None)
|
||||
self.assertEqual(self.jmsg, msg.to_partial_json())
|
||||
|
||||
|
||||
class RecoveryTokenTest(unittest.TestCase):
|
||||
|
||||
def setUp(self):
|
||||
from acme.challenges import RecoveryToken
|
||||
self.msg = RecoveryToken()
|
||||
self.jmsg = {'type': 'recoveryToken'}
|
||||
|
||||
def test_to_partial_json(self):
|
||||
self.assertEqual(self.jmsg, self.msg.to_partial_json())
|
||||
|
||||
def test_from_json(self):
|
||||
from acme.challenges import RecoveryToken
|
||||
self.assertEqual(self.msg, RecoveryToken.from_json(self.jmsg))
|
||||
|
||||
def test_from_json_hashable(self):
|
||||
from acme.challenges import RecoveryToken
|
||||
hash(RecoveryToken.from_json(self.jmsg))
|
||||
|
||||
|
||||
class RecoveryTokenResponseTest(unittest.TestCase):
|
||||
|
||||
def setUp(self):
|
||||
from acme.challenges import RecoveryTokenResponse
|
||||
self.msg = RecoveryTokenResponse(token='23029d88d9e123e')
|
||||
self.jmsg = {
|
||||
'resource': 'challenge',
|
||||
'type': 'recoveryToken',
|
||||
'token': '23029d88d9e123e'
|
||||
}
|
||||
|
||||
def test_to_partial_json(self):
|
||||
self.assertEqual(self.jmsg, self.msg.to_partial_json())
|
||||
|
||||
def test_from_json(self):
|
||||
from acme.challenges import RecoveryTokenResponse
|
||||
self.assertEqual(
|
||||
self.msg, RecoveryTokenResponse.from_json(self.jmsg))
|
||||
|
||||
def test_from_json_hashable(self):
|
||||
from acme.challenges import RecoveryTokenResponse
|
||||
hash(RecoveryTokenResponse.from_json(self.jmsg))
|
||||
|
||||
def test_json_without_optionals(self):
|
||||
del self.jmsg['token']
|
||||
|
||||
from acme.challenges import RecoveryTokenResponse
|
||||
msg = RecoveryTokenResponse.from_json(self.jmsg)
|
||||
|
||||
self.assertTrue(msg.token is None)
|
||||
self.assertEqual(self.jmsg, msg.to_partial_json())
|
||||
|
||||
|
||||
class ProofOfPossessionHintsTest(unittest.TestCase):
|
||||
|
||||
def setUp(self):
|
||||
jwk = jose.JWKRSA(key=KEY.public_key())
|
||||
issuers = (
|
||||
'C=US, O=SuperT LLC, CN=SuperTrustworthy Public CA',
|
||||
'O=LessTrustworthy CA Inc, CN=LessTrustworthy But StillSecure',
|
||||
)
|
||||
cert_fingerprints = (
|
||||
'93416768eb85e33adc4277f4c9acd63e7418fcfe',
|
||||
'16d95b7b63f1972b980b14c20291f3c0d1855d95',
|
||||
'48b46570d9fc6358108af43ad1649484def0debf',
|
||||
)
|
||||
subject_key_identifiers = ('d0083162dcc4c8a23ecb8aecbd86120e56fd24e5')
|
||||
authorized_for = ('www.example.com', 'example.net')
|
||||
serial_numbers = (34234239832, 23993939911, 17)
|
||||
|
||||
from acme.challenges import ProofOfPossession
|
||||
self.msg = ProofOfPossession.Hints(
|
||||
jwk=jwk, issuers=issuers, cert_fingerprints=cert_fingerprints,
|
||||
certs=(CERT,), subject_key_identifiers=subject_key_identifiers,
|
||||
authorized_for=authorized_for, serial_numbers=serial_numbers)
|
||||
|
||||
self.jmsg_to = {
|
||||
'jwk': jwk,
|
||||
'certFingerprints': cert_fingerprints,
|
||||
'certs': (jose.encode_b64jose(OpenSSL.crypto.dump_certificate(
|
||||
OpenSSL.crypto.FILETYPE_ASN1, CERT)),),
|
||||
'subjectKeyIdentifiers': subject_key_identifiers,
|
||||
'serialNumbers': serial_numbers,
|
||||
'issuers': issuers,
|
||||
'authorizedFor': authorized_for,
|
||||
}
|
||||
self.jmsg_from = self.jmsg_to.copy()
|
||||
self.jmsg_from.update({'jwk': jwk.to_json()})
|
||||
|
||||
def test_to_partial_json(self):
|
||||
self.assertEqual(self.jmsg_to, self.msg.to_partial_json())
|
||||
|
||||
def test_from_json(self):
|
||||
from acme.challenges import ProofOfPossession
|
||||
self.assertEqual(
|
||||
self.msg, ProofOfPossession.Hints.from_json(self.jmsg_from))
|
||||
|
||||
def test_from_json_hashable(self):
|
||||
from acme.challenges import ProofOfPossession
|
||||
hash(ProofOfPossession.Hints.from_json(self.jmsg_from))
|
||||
|
||||
def test_json_without_optionals(self):
|
||||
for optional in ['certFingerprints', 'certs', 'subjectKeyIdentifiers',
|
||||
'serialNumbers', 'issuers', 'authorizedFor']:
|
||||
del self.jmsg_from[optional]
|
||||
del self.jmsg_to[optional]
|
||||
|
||||
from acme.challenges import ProofOfPossession
|
||||
msg = ProofOfPossession.Hints.from_json(self.jmsg_from)
|
||||
|
||||
self.assertEqual(msg.cert_fingerprints, ())
|
||||
self.assertEqual(msg.certs, ())
|
||||
self.assertEqual(msg.subject_key_identifiers, ())
|
||||
self.assertEqual(msg.serial_numbers, ())
|
||||
self.assertEqual(msg.issuers, ())
|
||||
self.assertEqual(msg.authorized_for, ())
|
||||
|
||||
self.assertEqual(self.jmsg_to, msg.to_partial_json())
|
||||
|
||||
|
||||
class ProofOfPossessionTest(unittest.TestCase):
|
||||
|
||||
def setUp(self):
|
||||
from acme.challenges import ProofOfPossession
|
||||
hints = ProofOfPossession.Hints(
|
||||
jwk=jose.JWKRSA(key=KEY.public_key()), cert_fingerprints=(),
|
||||
certs=(), serial_numbers=(), subject_key_identifiers=(),
|
||||
issuers=(), authorized_for=())
|
||||
self.msg = ProofOfPossession(
|
||||
alg=jose.RS256, hints=hints,
|
||||
nonce=b'xD\xf9\xb9\xdbU\xed\xaa\x17\xf1y|\x81\x88\x99 ')
|
||||
|
||||
self.jmsg_to = {
|
||||
'type': 'proofOfPossession',
|
||||
'alg': jose.RS256,
|
||||
'nonce': 'eET5udtV7aoX8Xl8gYiZIA',
|
||||
'hints': hints,
|
||||
}
|
||||
self.jmsg_from = {
|
||||
'type': 'proofOfPossession',
|
||||
'alg': jose.RS256.to_json(),
|
||||
'nonce': 'eET5udtV7aoX8Xl8gYiZIA',
|
||||
'hints': hints.to_json(),
|
||||
}
|
||||
|
||||
def test_to_partial_json(self):
|
||||
self.assertEqual(self.jmsg_to, self.msg.to_partial_json())
|
||||
|
||||
def test_from_json(self):
|
||||
from acme.challenges import ProofOfPossession
|
||||
self.assertEqual(
|
||||
self.msg, ProofOfPossession.from_json(self.jmsg_from))
|
||||
|
||||
def test_from_json_hashable(self):
|
||||
from acme.challenges import ProofOfPossession
|
||||
hash(ProofOfPossession.from_json(self.jmsg_from))
|
||||
|
||||
|
||||
class ProofOfPossessionResponseTest(unittest.TestCase):
|
||||
|
||||
def setUp(self):
|
||||
# acme-spec uses a confusing example in which both signature
|
||||
# nonce and challenge nonce are the same, don't make the same
|
||||
# mistake here...
|
||||
signature = other.Signature(
|
||||
alg=jose.RS256, jwk=jose.JWKRSA(key=KEY.public_key()),
|
||||
sig=b'\xa7\xc1\xe7\xe82o\xbc\xcd\xd0\x1e\x010#Z|\xaf\x15\x83'
|
||||
b'\x94\x8f#\x9b\nQo(\x80\x15,\x08\xfcz\x1d\xfd\xfd.\xaap'
|
||||
b'\xfa\x06\xd1\xa2f\x8d8X2>%d\xbd%\xe1T\xdd\xaa0\x18\xde'
|
||||
b'\x99\x08\xf0\x0e{',
|
||||
nonce=b'\x99\xc7Q\xb3f2\xbc\xdci\xfe\xd6\x98k\xc67\xdf',
|
||||
)
|
||||
|
||||
from acme.challenges import ProofOfPossessionResponse
|
||||
self.msg = ProofOfPossessionResponse(
|
||||
nonce=b'xD\xf9\xb9\xdbU\xed\xaa\x17\xf1y|\x81\x88\x99 ',
|
||||
signature=signature)
|
||||
|
||||
self.jmsg_to = {
|
||||
'resource': 'challenge',
|
||||
'type': 'proofOfPossession',
|
||||
'nonce': 'eET5udtV7aoX8Xl8gYiZIA',
|
||||
'signature': signature,
|
||||
}
|
||||
self.jmsg_from = {
|
||||
'resource': 'challenge',
|
||||
'type': 'proofOfPossession',
|
||||
'nonce': 'eET5udtV7aoX8Xl8gYiZIA',
|
||||
'signature': signature.to_json(),
|
||||
}
|
||||
|
||||
def test_verify(self):
|
||||
self.assertTrue(self.msg.verify())
|
||||
|
||||
def test_to_partial_json(self):
|
||||
self.assertEqual(self.jmsg_to, self.msg.to_partial_json())
|
||||
|
||||
def test_from_json(self):
|
||||
from acme.challenges import ProofOfPossessionResponse
|
||||
self.assertEqual(
|
||||
self.msg, ProofOfPossessionResponse.from_json(self.jmsg_from))
|
||||
|
||||
def test_from_json_hashable(self):
|
||||
from acme.challenges import ProofOfPossessionResponse
|
||||
hash(ProofOfPossessionResponse.from_json(self.jmsg_from))
|
||||
|
||||
|
||||
class DNSTest(unittest.TestCase):
|
||||
|
||||
def setUp(self):
|
||||
from acme.challenges import DNS
|
||||
self.msg = DNS(token='17817c66b60ce2e4012dfad92657527a')
|
||||
self.jmsg = {'type': 'dns', 'token': '17817c66b60ce2e4012dfad92657527a'}
|
||||
|
||||
def test_to_partial_json(self):
|
||||
self.assertEqual(self.jmsg, self.msg.to_partial_json())
|
||||
|
||||
def test_from_json(self):
|
||||
from acme.challenges import DNS
|
||||
self.assertEqual(self.msg, DNS.from_json(self.jmsg))
|
||||
|
||||
def test_from_json_hashable(self):
|
||||
from acme.challenges import DNS
|
||||
hash(DNS.from_json(self.jmsg))
|
||||
|
||||
|
||||
class DNSResponseTest(unittest.TestCase):
|
||||
|
||||
def setUp(self):
|
||||
from acme.challenges import DNSResponse
|
||||
self.msg = DNSResponse()
|
||||
self.jmsg = {
|
||||
'resource': 'challenge',
|
||||
'type': 'dns',
|
||||
}
|
||||
|
||||
def test_to_partial_json(self):
|
||||
self.assertEqual(self.jmsg, self.msg.to_partial_json())
|
||||
|
||||
def test_from_json(self):
|
||||
from acme.challenges import DNSResponse
|
||||
self.assertEqual(self.msg, DNSResponse.from_json(self.jmsg))
|
||||
|
||||
def test_from_json_hashable(self):
|
||||
from acme.challenges import DNSResponse
|
||||
hash(DNSResponse.from_json(self.jmsg))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main() # pragma: no cover
|
||||
582
acme/acme/client.py
Normal file
582
acme/acme/client.py
Normal file
|
|
@ -0,0 +1,582 @@
|
|||
"""ACME client API."""
|
||||
import datetime
|
||||
import heapq
|
||||
import logging
|
||||
import time
|
||||
|
||||
from six.moves import http_client # pylint: disable=import-error
|
||||
|
||||
import OpenSSL
|
||||
import requests
|
||||
import six
|
||||
import werkzeug
|
||||
|
||||
from acme import errors
|
||||
from acme import jose
|
||||
from acme import jws
|
||||
from acme import messages
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# https://urllib3.readthedocs.org/en/latest/security.html#insecureplatformwarning
|
||||
if six.PY2:
|
||||
requests.packages.urllib3.contrib.pyopenssl.inject_into_urllib3()
|
||||
|
||||
|
||||
class Client(object): # pylint: disable=too-many-instance-attributes
|
||||
"""ACME client.
|
||||
|
||||
.. todo::
|
||||
Clean up raised error types hierarchy, document, and handle (wrap)
|
||||
instances of `.DeserializationError` raised in `from_json()`.
|
||||
|
||||
:ivar str new_reg_uri: Location of new-reg
|
||||
:ivar key: `.JWK` (private)
|
||||
:ivar alg: `.JWASignature`
|
||||
:ivar bool verify_ssl: Verify SSL certificates?
|
||||
:ivar .ClientNetwork net: Client network. Useful for testing. If not
|
||||
supplied, it will be initialized using `key`, `alg` and
|
||||
`verify_ssl`.
|
||||
|
||||
"""
|
||||
DER_CONTENT_TYPE = 'application/pkix-cert'
|
||||
|
||||
def __init__(self, new_reg_uri, key, alg=jose.RS256,
|
||||
verify_ssl=True, net=None):
|
||||
self.new_reg_uri = new_reg_uri
|
||||
self.key = key
|
||||
self.net = ClientNetwork(key, alg, verify_ssl) if net is None else net
|
||||
|
||||
@classmethod
|
||||
def _regr_from_response(cls, response, uri=None, new_authzr_uri=None,
|
||||
terms_of_service=None):
|
||||
terms_of_service = (
|
||||
response.links['terms-of-service']['url']
|
||||
if 'terms-of-service' in response.links else terms_of_service)
|
||||
|
||||
if new_authzr_uri is None:
|
||||
try:
|
||||
new_authzr_uri = response.links['next']['url']
|
||||
except KeyError:
|
||||
raise errors.ClientError('"next" link missing')
|
||||
|
||||
return messages.RegistrationResource(
|
||||
body=messages.Registration.from_json(response.json()),
|
||||
uri=response.headers.get('Location', uri),
|
||||
new_authzr_uri=new_authzr_uri,
|
||||
terms_of_service=terms_of_service)
|
||||
|
||||
def register(self, new_reg=None):
|
||||
"""Register.
|
||||
|
||||
:param .NewRegistration new_reg:
|
||||
|
||||
:returns: Registration Resource.
|
||||
:rtype: `.RegistrationResource`
|
||||
|
||||
:raises .UnexpectedUpdate:
|
||||
|
||||
"""
|
||||
new_reg = messages.NewRegistration() if new_reg is None else new_reg
|
||||
assert isinstance(new_reg, messages.NewRegistration)
|
||||
|
||||
response = self.net.post(self.new_reg_uri, new_reg)
|
||||
# TODO: handle errors
|
||||
assert response.status_code == http_client.CREATED
|
||||
|
||||
# "Instance of 'Field' has no key/contact member" bug:
|
||||
# pylint: disable=no-member
|
||||
regr = self._regr_from_response(response)
|
||||
if (regr.body.key != self.key.public_key() or
|
||||
regr.body.contact != new_reg.contact):
|
||||
raise errors.UnexpectedUpdate(regr)
|
||||
|
||||
return regr
|
||||
|
||||
def update_registration(self, regr):
|
||||
"""Update registration.
|
||||
|
||||
:pram regr: Registration Resource.
|
||||
:type regr: `.RegistrationResource`
|
||||
|
||||
:returns: Updated Registration Resource.
|
||||
:rtype: `.RegistrationResource`
|
||||
|
||||
"""
|
||||
response = self.net.post(
|
||||
regr.uri, messages.UpdateRegistration(**dict(regr.body)))
|
||||
|
||||
# TODO: Boulder returns httplib.ACCEPTED
|
||||
#assert response.status_code == httplib.OK
|
||||
|
||||
# TODO: Boulder does not set Location or Link on update
|
||||
# (c.f. acme-spec #94)
|
||||
|
||||
updated_regr = self._regr_from_response(
|
||||
response, uri=regr.uri, new_authzr_uri=regr.new_authzr_uri,
|
||||
terms_of_service=regr.terms_of_service)
|
||||
if updated_regr != regr:
|
||||
raise errors.UnexpectedUpdate(regr)
|
||||
return updated_regr
|
||||
|
||||
def agree_to_tos(self, regr):
|
||||
"""Agree to the terms-of-service.
|
||||
|
||||
Agree to the terms-of-service in a Registration Resource.
|
||||
|
||||
:param regr: Registration Resource.
|
||||
:type regr: `.RegistrationResource`
|
||||
|
||||
:returns: Updated Registration Resource.
|
||||
:rtype: `.RegistrationResource`
|
||||
|
||||
"""
|
||||
return self.update_registration(
|
||||
regr.update(body=regr.body.update(agreement=regr.terms_of_service)))
|
||||
|
||||
def _authzr_from_response(self, response, identifier,
|
||||
uri=None, new_cert_uri=None):
|
||||
# pylint: disable=no-self-use
|
||||
if new_cert_uri is None:
|
||||
try:
|
||||
new_cert_uri = response.links['next']['url']
|
||||
except KeyError:
|
||||
raise errors.ClientError('"next" link missing')
|
||||
|
||||
authzr = messages.AuthorizationResource(
|
||||
body=messages.Authorization.from_json(response.json()),
|
||||
uri=response.headers.get('Location', uri),
|
||||
new_cert_uri=new_cert_uri)
|
||||
if authzr.body.identifier != identifier:
|
||||
raise errors.UnexpectedUpdate(authzr)
|
||||
return authzr
|
||||
|
||||
def request_challenges(self, identifier, new_authzr_uri):
|
||||
"""Request challenges.
|
||||
|
||||
:param identifier: Identifier to be challenged.
|
||||
:type identifier: `.messages.Identifier`
|
||||
|
||||
:param str new_authzr_uri: new-authorization URI
|
||||
|
||||
:returns: Authorization Resource.
|
||||
:rtype: `.AuthorizationResource`
|
||||
|
||||
"""
|
||||
new_authz = messages.NewAuthorization(identifier=identifier)
|
||||
response = self.net.post(new_authzr_uri, new_authz)
|
||||
# TODO: handle errors
|
||||
assert response.status_code == http_client.CREATED
|
||||
return self._authzr_from_response(response, identifier)
|
||||
|
||||
def request_domain_challenges(self, domain, new_authz_uri):
|
||||
"""Request challenges for domain names.
|
||||
|
||||
This is simply a convenience function that wraps around
|
||||
`request_challenges`, but works with domain names instead of
|
||||
generic identifiers.
|
||||
|
||||
:param str domain: Domain name to be challenged.
|
||||
:param str new_authzr_uri: new-authorization URI
|
||||
|
||||
:returns: Authorization Resource.
|
||||
:rtype: `.AuthorizationResource`
|
||||
|
||||
"""
|
||||
return self.request_challenges(messages.Identifier(
|
||||
typ=messages.IDENTIFIER_FQDN, value=domain), new_authz_uri)
|
||||
|
||||
def answer_challenge(self, challb, response):
|
||||
"""Answer challenge.
|
||||
|
||||
:param challb: Challenge Resource body.
|
||||
:type challb: `.ChallengeBody`
|
||||
|
||||
:param response: Corresponding Challenge response
|
||||
:type response: `.challenges.ChallengeResponse`
|
||||
|
||||
:returns: Challenge Resource with updated body.
|
||||
:rtype: `.ChallengeResource`
|
||||
|
||||
:raises .UnexpectedUpdate:
|
||||
|
||||
"""
|
||||
response = self.net.post(challb.uri, response)
|
||||
try:
|
||||
authzr_uri = response.links['up']['url']
|
||||
except KeyError:
|
||||
raise errors.ClientError('"up" Link header missing')
|
||||
challr = messages.ChallengeResource(
|
||||
authzr_uri=authzr_uri,
|
||||
body=messages.ChallengeBody.from_json(response.json()))
|
||||
# TODO: check that challr.uri == response.headers['Location']?
|
||||
if challr.uri != challb.uri:
|
||||
raise errors.UnexpectedUpdate(challr.uri)
|
||||
return challr
|
||||
|
||||
@classmethod
|
||||
def retry_after(cls, response, default):
|
||||
"""Compute next `poll` time based on response ``Retry-After`` header.
|
||||
|
||||
:param response: Response from `poll`.
|
||||
:type response: `requests.Response`
|
||||
|
||||
:param int default: Default value (in seconds), used when
|
||||
``Retry-After`` header is not present or invalid.
|
||||
|
||||
:returns: Time point when next `poll` should be performed.
|
||||
:rtype: `datetime.datetime`
|
||||
|
||||
"""
|
||||
retry_after = response.headers.get('Retry-After', str(default))
|
||||
try:
|
||||
seconds = int(retry_after)
|
||||
except ValueError:
|
||||
# pylint: disable=no-member
|
||||
decoded = werkzeug.parse_date(retry_after) # RFC1123
|
||||
if decoded is None:
|
||||
seconds = default
|
||||
else:
|
||||
return decoded
|
||||
|
||||
return datetime.datetime.now() + datetime.timedelta(seconds=seconds)
|
||||
|
||||
def poll(self, authzr):
|
||||
"""Poll Authorization Resource for status.
|
||||
|
||||
:param authzr: Authorization Resource
|
||||
:type authzr: `.AuthorizationResource`
|
||||
|
||||
:returns: Updated Authorization Resource and HTTP response.
|
||||
|
||||
:rtype: (`.AuthorizationResource`, `requests.Response`)
|
||||
|
||||
"""
|
||||
response = self.net.get(authzr.uri)
|
||||
updated_authzr = self._authzr_from_response(
|
||||
response, authzr.body.identifier, authzr.uri, authzr.new_cert_uri)
|
||||
# TODO: check and raise UnexpectedUpdate
|
||||
return updated_authzr, response
|
||||
|
||||
def request_issuance(self, csr, authzrs):
|
||||
"""Request issuance.
|
||||
|
||||
:param csr: CSR
|
||||
:type csr: `OpenSSL.crypto.X509Req` wrapped in `.ComparableX509`
|
||||
|
||||
:param authzrs: `list` of `.AuthorizationResource`
|
||||
|
||||
:returns: Issued certificate
|
||||
:rtype: `.messages.CertificateResource`
|
||||
|
||||
"""
|
||||
assert authzrs, "Authorizations list is empty"
|
||||
logger.debug("Requesting issuance...")
|
||||
|
||||
# TODO: assert len(authzrs) == number of SANs
|
||||
req = messages.CertificateRequest(
|
||||
csr=csr, authorizations=tuple(authzr.uri for authzr in authzrs))
|
||||
|
||||
content_type = self.DER_CONTENT_TYPE # TODO: add 'cert_type 'argument
|
||||
response = self.net.post(
|
||||
authzrs[0].new_cert_uri, # TODO: acme-spec #90
|
||||
req,
|
||||
content_type=content_type,
|
||||
headers={'Accept': content_type})
|
||||
|
||||
cert_chain_uri = response.links.get('up', {}).get('url')
|
||||
|
||||
try:
|
||||
uri = response.headers['Location']
|
||||
except KeyError:
|
||||
raise errors.ClientError('"Location" Header missing')
|
||||
|
||||
return messages.CertificateResource(
|
||||
uri=uri, authzrs=authzrs, cert_chain_uri=cert_chain_uri,
|
||||
body=jose.ComparableX509(OpenSSL.crypto.load_certificate(
|
||||
OpenSSL.crypto.FILETYPE_ASN1, response.content)))
|
||||
|
||||
def poll_and_request_issuance(self, csr, authzrs, mintime=5):
|
||||
"""Poll and request issuance.
|
||||
|
||||
This function polls all provided Authorization Resource URIs
|
||||
until all challenges are valid, respecting ``Retry-After`` HTTP
|
||||
headers, and then calls `request_issuance`.
|
||||
|
||||
.. todo:: add `max_attempts` or `timeout`
|
||||
|
||||
:param csr: CSR.
|
||||
:type csr: `OpenSSL.crypto.X509Req` wrapped in `.ComparableX509`
|
||||
|
||||
:param authzrs: `list` of `.AuthorizationResource`
|
||||
|
||||
:param int mintime: Minimum time before next attempt, used if
|
||||
``Retry-After`` is not present in the response.
|
||||
|
||||
:returns: ``(cert, updated_authzrs)`` `tuple` where ``cert`` is
|
||||
the issued certificate (`.messages.CertificateResource.),
|
||||
and ``updated_authzrs`` is a `tuple` consisting of updated
|
||||
Authorization Resources (`.AuthorizationResource`) as
|
||||
present in the responses from server, and in the same order
|
||||
as the input ``authzrs``.
|
||||
:rtype: `tuple`
|
||||
|
||||
"""
|
||||
# priority queue with datetime (based on Retry-After) as key,
|
||||
# and original Authorization Resource as value
|
||||
waiting = [(datetime.datetime.now(), authzr) for authzr in authzrs]
|
||||
# mapping between original Authorization Resource and the most
|
||||
# recently updated one
|
||||
updated = dict((authzr, authzr) for authzr in authzrs)
|
||||
|
||||
while waiting:
|
||||
# find the smallest Retry-After, and sleep if necessary
|
||||
when, authzr = heapq.heappop(waiting)
|
||||
now = datetime.datetime.now()
|
||||
if when > now:
|
||||
seconds = (when - now).seconds
|
||||
logger.debug('Sleeping for %d seconds', seconds)
|
||||
time.sleep(seconds)
|
||||
|
||||
# Note that we poll with the latest updated Authorization
|
||||
# URI, which might have a different URI than initial one
|
||||
updated_authzr, response = self.poll(updated[authzr])
|
||||
updated[authzr] = updated_authzr
|
||||
|
||||
# pylint: disable=no-member
|
||||
if updated_authzr.body.status != messages.STATUS_VALID:
|
||||
# push back to the priority queue, with updated retry_after
|
||||
heapq.heappush(waiting, (self.retry_after(
|
||||
response, default=mintime), authzr))
|
||||
|
||||
updated_authzrs = tuple(updated[authzr] for authzr in authzrs)
|
||||
return self.request_issuance(csr, updated_authzrs), updated_authzrs
|
||||
|
||||
def _get_cert(self, uri):
|
||||
"""Returns certificate from URI.
|
||||
|
||||
:param str uri: URI of certificate
|
||||
|
||||
:returns: tuple of the form
|
||||
(response, :class:`acme.jose.ComparableX509`)
|
||||
:rtype: tuple
|
||||
|
||||
"""
|
||||
content_type = self.DER_CONTENT_TYPE # TODO: make it a param
|
||||
response = self.net.get(uri, headers={'Accept': content_type},
|
||||
content_type=content_type)
|
||||
return response, jose.ComparableX509(OpenSSL.crypto.load_certificate(
|
||||
OpenSSL.crypto.FILETYPE_ASN1, response.content))
|
||||
|
||||
def check_cert(self, certr):
|
||||
"""Check for new cert.
|
||||
|
||||
:param certr: Certificate Resource
|
||||
:type certr: `.CertificateResource`
|
||||
|
||||
:returns: Updated Certificate Resource.
|
||||
:rtype: `.CertificateResource`
|
||||
|
||||
"""
|
||||
# TODO: acme-spec 5.1 table action should be renamed to
|
||||
# "refresh cert", and this method integrated with self.refresh
|
||||
response, cert = self._get_cert(certr.uri)
|
||||
if 'Location' not in response.headers:
|
||||
raise errors.ClientError('Location header missing')
|
||||
if response.headers['Location'] != certr.uri:
|
||||
raise errors.UnexpectedUpdate(response.text)
|
||||
return certr.update(body=cert)
|
||||
|
||||
def refresh(self, certr):
|
||||
"""Refresh certificate.
|
||||
|
||||
:param certr: Certificate Resource
|
||||
:type certr: `.CertificateResource`
|
||||
|
||||
:returns: Updated Certificate Resource.
|
||||
:rtype: `.CertificateResource`
|
||||
|
||||
"""
|
||||
# TODO: If a client sends a refresh request and the server is
|
||||
# not willing to refresh the certificate, the server MUST
|
||||
# respond with status code 403 (Forbidden)
|
||||
return self.check_cert(certr)
|
||||
|
||||
def fetch_chain(self, certr):
|
||||
"""Fetch chain for certificate.
|
||||
|
||||
:param certr: Certificate Resource
|
||||
:type certr: `.CertificateResource`
|
||||
|
||||
:returns: Certificate chain, or `None` if no "up" Link was provided.
|
||||
:rtype: `OpenSSL.crypto.X509` wrapped in `.ComparableX509`
|
||||
|
||||
"""
|
||||
if certr.cert_chain_uri is not None:
|
||||
return self._get_cert(certr.cert_chain_uri)[1]
|
||||
else:
|
||||
return None
|
||||
|
||||
def revoke(self, cert):
|
||||
"""Revoke certificate.
|
||||
|
||||
:param .ComparableX509 cert: `OpenSSL.crypto.X509` wrapped in
|
||||
`.ComparableX509`
|
||||
|
||||
:raises .ClientError: If revocation is unsuccessful.
|
||||
|
||||
"""
|
||||
response = self.net.post(messages.Revocation.url(self.new_reg_uri),
|
||||
messages.Revocation(certificate=cert))
|
||||
if response.status_code != http_client.OK:
|
||||
raise errors.ClientError(
|
||||
'Successful revocation must return HTTP OK status')
|
||||
|
||||
|
||||
class ClientNetwork(object):
|
||||
"""Client network."""
|
||||
JSON_CONTENT_TYPE = 'application/json'
|
||||
JSON_ERROR_CONTENT_TYPE = 'application/problem+json'
|
||||
REPLAY_NONCE_HEADER = 'Replay-Nonce'
|
||||
|
||||
def __init__(self, key, alg=jose.RS256, verify_ssl=True):
|
||||
self.key = key
|
||||
self.alg = alg
|
||||
self.verify_ssl = verify_ssl
|
||||
self._nonces = set()
|
||||
|
||||
def _wrap_in_jws(self, obj, nonce):
|
||||
"""Wrap `JSONDeSerializable` object in JWS.
|
||||
|
||||
.. todo:: Implement ``acmePath``.
|
||||
|
||||
:param .JSONDeSerializable obj:
|
||||
:param bytes nonce:
|
||||
:rtype: `.JWS`
|
||||
|
||||
"""
|
||||
jobj = obj.json_dumps().encode()
|
||||
logger.debug('Serialized JSON: %s', jobj)
|
||||
return jws.JWS.sign(
|
||||
payload=jobj, key=self.key, alg=self.alg, nonce=nonce).json_dumps()
|
||||
|
||||
@classmethod
|
||||
def _check_response(cls, response, content_type=None):
|
||||
"""Check response content and its type.
|
||||
|
||||
.. note::
|
||||
Checking is not strict: wrong server response ``Content-Type``
|
||||
HTTP header is ignored if response is an expected JSON object
|
||||
(c.f. Boulder #56).
|
||||
|
||||
:param str content_type: Expected Content-Type response header.
|
||||
If JSON is expected and not present in server response, this
|
||||
function will raise an error. Otherwise, wrong Content-Type
|
||||
is ignored, but logged.
|
||||
|
||||
:raises .messages.Error: If server response body
|
||||
carries HTTP Problem (draft-ietf-appsawg-http-problem-00).
|
||||
:raises .ClientError: In case of other networking errors.
|
||||
|
||||
"""
|
||||
logger.debug('Received response %s (headers: %s): %r',
|
||||
response, response.headers, response.content)
|
||||
|
||||
response_ct = response.headers.get('Content-Type')
|
||||
try:
|
||||
# TODO: response.json() is called twice, once here, and
|
||||
# once in _get and _post clients
|
||||
jobj = response.json()
|
||||
except ValueError as error:
|
||||
jobj = None
|
||||
|
||||
if not response.ok:
|
||||
if jobj is not None:
|
||||
if response_ct != cls.JSON_ERROR_CONTENT_TYPE:
|
||||
logger.debug(
|
||||
'Ignoring wrong Content-Type (%r) for JSON Error',
|
||||
response_ct)
|
||||
try:
|
||||
raise messages.Error.from_json(jobj)
|
||||
except jose.DeserializationError as error:
|
||||
# Couldn't deserialize JSON object
|
||||
raise errors.ClientError((response, error))
|
||||
else:
|
||||
# response is not JSON object
|
||||
raise errors.ClientError(response)
|
||||
else:
|
||||
if jobj is not None and response_ct != cls.JSON_CONTENT_TYPE:
|
||||
logger.debug(
|
||||
'Ignoring wrong Content-Type (%r) for JSON decodable '
|
||||
'response', response_ct)
|
||||
|
||||
if content_type == cls.JSON_CONTENT_TYPE and jobj is None:
|
||||
raise errors.ClientError(
|
||||
'Unexpected response Content-Type: {0}'.format(response_ct))
|
||||
|
||||
return response
|
||||
|
||||
def _send_request(self, method, url, *args, **kwargs):
|
||||
"""Send HTTP request.
|
||||
|
||||
Makes sure that `verify_ssl` is respected. Logs request and
|
||||
response (with headers). For allowed parameters please see
|
||||
`requests.request`.
|
||||
|
||||
:param str method: method for the new `requests.Request` object
|
||||
:param str url: URL for the new `requests.Request` object
|
||||
|
||||
:raises requests.exceptions.RequestException: in case of any problems
|
||||
|
||||
:returns: HTTP Response
|
||||
:rtype: `requests.Response`
|
||||
|
||||
|
||||
"""
|
||||
logging.debug('Sending %s request to %s', method, url)
|
||||
kwargs['verify'] = self.verify_ssl
|
||||
response = requests.request(method, url, *args, **kwargs)
|
||||
logging.debug('Received %s. Headers: %s. Content: %r',
|
||||
response, response.headers, response.content)
|
||||
return response
|
||||
|
||||
def head(self, *args, **kwargs):
|
||||
"""Send HEAD request without checking the response.
|
||||
|
||||
Note, that `_check_response` is not called, as it is expected
|
||||
that status code other than successfuly 2xx will be returned, or
|
||||
messages2.Error will be raised by the server.
|
||||
|
||||
"""
|
||||
return self._send_request('HEAD', *args, **kwargs)
|
||||
|
||||
def get(self, url, content_type=JSON_CONTENT_TYPE, **kwargs):
|
||||
"""Send GET request and check response."""
|
||||
return self._check_response(
|
||||
self._send_request('GET', url, **kwargs), content_type=content_type)
|
||||
|
||||
def _add_nonce(self, response):
|
||||
if self.REPLAY_NONCE_HEADER in response.headers:
|
||||
nonce = response.headers[self.REPLAY_NONCE_HEADER]
|
||||
try:
|
||||
decoded_nonce = jws.Header._fields['nonce'].decode(nonce)
|
||||
except jose.DeserializationError as error:
|
||||
raise errors.BadNonce(nonce, error)
|
||||
logger.debug('Storing nonce: %r', decoded_nonce)
|
||||
self._nonces.add(decoded_nonce)
|
||||
else:
|
||||
raise errors.MissingNonce(response)
|
||||
|
||||
def _get_nonce(self, url):
|
||||
if not self._nonces:
|
||||
logging.debug('Requesting fresh nonce')
|
||||
self._add_nonce(self.head(url))
|
||||
return self._nonces.pop()
|
||||
|
||||
def post(self, url, obj, content_type=JSON_CONTENT_TYPE, **kwargs):
|
||||
"""POST object wrapped in `.JWS` and check response."""
|
||||
data = self._wrap_in_jws(obj, self._get_nonce(url))
|
||||
response = self._send_request('POST', url, data=data, **kwargs)
|
||||
self._add_nonce(response)
|
||||
return self._check_response(response, content_type=content_type)
|
||||
551
acme/acme/client_test.py
Normal file
551
acme/acme/client_test.py
Normal file
|
|
@ -0,0 +1,551 @@
|
|||
"""Tests for acme.client."""
|
||||
import datetime
|
||||
import json
|
||||
import unittest
|
||||
|
||||
from six.moves import http_client # pylint: disable=import-error
|
||||
|
||||
import mock
|
||||
import requests
|
||||
|
||||
from acme import challenges
|
||||
from acme import errors
|
||||
from acme import jose
|
||||
from acme import jws as acme_jws
|
||||
from acme import messages
|
||||
from acme import messages_test
|
||||
from acme import test_util
|
||||
|
||||
|
||||
CERT_DER = test_util.load_vector('cert.der')
|
||||
KEY = jose.JWKRSA.load(test_util.load_vector('rsa512_key.pem'))
|
||||
KEY2 = jose.JWKRSA.load(test_util.load_vector('rsa256_key.pem'))
|
||||
|
||||
|
||||
class ClientTest(unittest.TestCase):
|
||||
"""Tests for acme.client.Client."""
|
||||
# pylint: disable=too-many-instance-attributes,too-many-public-methods
|
||||
|
||||
def setUp(self):
|
||||
self.response = mock.MagicMock(
|
||||
ok=True, status_code=http_client.OK, headers={}, links={})
|
||||
self.net = mock.MagicMock()
|
||||
self.net.post.return_value = self.response
|
||||
self.net.get.return_value = self.response
|
||||
|
||||
from acme.client import Client
|
||||
self.client = Client(
|
||||
new_reg_uri='https://www.letsencrypt-demo.org/acme/new-reg',
|
||||
key=KEY, alg=jose.RS256, net=self.net)
|
||||
|
||||
self.identifier = messages.Identifier(
|
||||
typ=messages.IDENTIFIER_FQDN, value='example.com')
|
||||
|
||||
# Registration
|
||||
self.contact = ('mailto:cert-admin@example.com', 'tel:+12025551212')
|
||||
reg = messages.Registration(
|
||||
contact=self.contact, key=KEY.public_key(), recovery_token='t')
|
||||
self.new_reg = messages.NewRegistration(**dict(reg))
|
||||
self.regr = messages.RegistrationResource(
|
||||
body=reg, uri='https://www.letsencrypt-demo.org/acme/reg/1',
|
||||
new_authzr_uri='https://www.letsencrypt-demo.org/acme/new-reg',
|
||||
terms_of_service='https://www.letsencrypt-demo.org/tos')
|
||||
|
||||
# Authorization
|
||||
authzr_uri = 'https://www.letsencrypt-demo.org/acme/authz/1'
|
||||
challb = messages.ChallengeBody(
|
||||
uri=(authzr_uri + '/1'), status=messages.STATUS_VALID,
|
||||
chall=challenges.DNS(token='foo'))
|
||||
self.challr = messages.ChallengeResource(
|
||||
body=challb, authzr_uri=authzr_uri)
|
||||
self.authz = messages.Authorization(
|
||||
identifier=messages.Identifier(
|
||||
typ=messages.IDENTIFIER_FQDN, value='example.com'),
|
||||
challenges=(challb,), combinations=None)
|
||||
self.authzr = messages.AuthorizationResource(
|
||||
body=self.authz, uri=authzr_uri,
|
||||
new_cert_uri='https://www.letsencrypt-demo.org/acme/new-cert')
|
||||
|
||||
# Request issuance
|
||||
self.certr = messages.CertificateResource(
|
||||
body=messages_test.CERT, authzrs=(self.authzr,),
|
||||
uri='https://www.letsencrypt-demo.org/acme/cert/1',
|
||||
cert_chain_uri='https://www.letsencrypt-demo.org/ca')
|
||||
|
||||
def test_register(self):
|
||||
# "Instance of 'Field' has no to_json/update member" bug:
|
||||
# pylint: disable=no-member
|
||||
self.response.status_code = http_client.CREATED
|
||||
self.response.json.return_value = self.regr.body.to_json()
|
||||
self.response.headers['Location'] = self.regr.uri
|
||||
self.response.links.update({
|
||||
'next': {'url': self.regr.new_authzr_uri},
|
||||
'terms-of-service': {'url': self.regr.terms_of_service},
|
||||
})
|
||||
|
||||
self.assertEqual(self.regr, self.client.register(self.new_reg))
|
||||
# TODO: test POST call arguments
|
||||
|
||||
# TODO: split here and separate test
|
||||
reg_wrong_key = self.regr.body.update(key=KEY2.public_key())
|
||||
self.response.json.return_value = reg_wrong_key.to_json()
|
||||
self.assertRaises(
|
||||
errors.UnexpectedUpdate, self.client.register, self.new_reg)
|
||||
|
||||
def test_register_missing_next(self):
|
||||
self.response.status_code = http_client.CREATED
|
||||
self.assertRaises(
|
||||
errors.ClientError, self.client.register, self.new_reg)
|
||||
|
||||
def test_update_registration(self):
|
||||
# "Instance of 'Field' has no to_json/update member" bug:
|
||||
# pylint: disable=no-member
|
||||
self.response.headers['Location'] = self.regr.uri
|
||||
self.response.json.return_value = self.regr.body.to_json()
|
||||
self.assertEqual(self.regr, self.client.update_registration(self.regr))
|
||||
# TODO: test POST call arguments
|
||||
|
||||
# TODO: split here and separate test
|
||||
self.response.json.return_value = self.regr.body.update(
|
||||
contact=()).to_json()
|
||||
self.assertRaises(
|
||||
errors.UnexpectedUpdate, self.client.update_registration, self.regr)
|
||||
|
||||
def test_agree_to_tos(self):
|
||||
self.client.update_registration = mock.Mock()
|
||||
self.client.agree_to_tos(self.regr)
|
||||
regr = self.client.update_registration.call_args[0][0]
|
||||
self.assertEqual(self.regr.terms_of_service, regr.body.agreement)
|
||||
|
||||
def test_request_challenges(self):
|
||||
self.response.status_code = http_client.CREATED
|
||||
self.response.headers['Location'] = self.authzr.uri
|
||||
self.response.json.return_value = self.authz.to_json()
|
||||
self.response.links = {
|
||||
'next': {'url': self.authzr.new_cert_uri},
|
||||
}
|
||||
|
||||
self.client.request_challenges(self.identifier, self.authzr.uri)
|
||||
# TODO: test POST call arguments
|
||||
|
||||
# TODO: split here and separate test
|
||||
self.response.json.return_value = self.authz.update(
|
||||
identifier=self.identifier.update(value='foo')).to_json()
|
||||
self.assertRaises(
|
||||
errors.UnexpectedUpdate, self.client.request_challenges,
|
||||
self.identifier, self.authzr.uri)
|
||||
|
||||
def test_request_challenges_missing_next(self):
|
||||
self.response.status_code = http_client.CREATED
|
||||
self.assertRaises(
|
||||
errors.ClientError, self.client.request_challenges,
|
||||
self.identifier, self.regr)
|
||||
|
||||
def test_request_domain_challenges(self):
|
||||
self.client.request_challenges = mock.MagicMock()
|
||||
self.assertEqual(
|
||||
self.client.request_challenges(self.identifier),
|
||||
self.client.request_domain_challenges('example.com', self.regr))
|
||||
|
||||
def test_answer_challenge(self):
|
||||
self.response.links['up'] = {'url': self.challr.authzr_uri}
|
||||
self.response.json.return_value = self.challr.body.to_json()
|
||||
|
||||
chall_response = challenges.DNSResponse()
|
||||
|
||||
self.client.answer_challenge(self.challr.body, chall_response)
|
||||
|
||||
# TODO: split here and separate test
|
||||
self.assertRaises(errors.UnexpectedUpdate, self.client.answer_challenge,
|
||||
self.challr.body.update(uri='foo'), chall_response)
|
||||
|
||||
def test_answer_challenge_missing_next(self):
|
||||
self.assertRaises(errors.ClientError, self.client.answer_challenge,
|
||||
self.challr.body, challenges.DNSResponse())
|
||||
|
||||
def test_retry_after_date(self):
|
||||
self.response.headers['Retry-After'] = 'Fri, 31 Dec 1999 23:59:59 GMT'
|
||||
self.assertEqual(
|
||||
datetime.datetime(1999, 12, 31, 23, 59, 59),
|
||||
self.client.retry_after(response=self.response, default=10))
|
||||
|
||||
@mock.patch('acme.client.datetime')
|
||||
def test_retry_after_invalid(self, dt_mock):
|
||||
dt_mock.datetime.now.return_value = datetime.datetime(2015, 3, 27)
|
||||
dt_mock.timedelta = datetime.timedelta
|
||||
|
||||
self.response.headers['Retry-After'] = 'foooo'
|
||||
self.assertEqual(
|
||||
datetime.datetime(2015, 3, 27, 0, 0, 10),
|
||||
self.client.retry_after(response=self.response, default=10))
|
||||
|
||||
@mock.patch('acme.client.datetime')
|
||||
def test_retry_after_seconds(self, dt_mock):
|
||||
dt_mock.datetime.now.return_value = datetime.datetime(2015, 3, 27)
|
||||
dt_mock.timedelta = datetime.timedelta
|
||||
|
||||
self.response.headers['Retry-After'] = '50'
|
||||
self.assertEqual(
|
||||
datetime.datetime(2015, 3, 27, 0, 0, 50),
|
||||
self.client.retry_after(response=self.response, default=10))
|
||||
|
||||
@mock.patch('acme.client.datetime')
|
||||
def test_retry_after_missing(self, dt_mock):
|
||||
dt_mock.datetime.now.return_value = datetime.datetime(2015, 3, 27)
|
||||
dt_mock.timedelta = datetime.timedelta
|
||||
|
||||
self.assertEqual(
|
||||
datetime.datetime(2015, 3, 27, 0, 0, 10),
|
||||
self.client.retry_after(response=self.response, default=10))
|
||||
|
||||
def test_poll(self):
|
||||
self.response.json.return_value = self.authzr.body.to_json()
|
||||
self.assertEqual((self.authzr, self.response),
|
||||
self.client.poll(self.authzr))
|
||||
|
||||
# TODO: split here and separate test
|
||||
self.response.json.return_value = self.authz.update(
|
||||
identifier=self.identifier.update(value='foo')).to_json()
|
||||
self.assertRaises(
|
||||
errors.UnexpectedUpdate, self.client.poll, self.authzr)
|
||||
|
||||
def test_request_issuance(self):
|
||||
self.response.content = CERT_DER
|
||||
self.response.headers['Location'] = self.certr.uri
|
||||
self.response.links['up'] = {'url': self.certr.cert_chain_uri}
|
||||
self.assertEqual(self.certr, self.client.request_issuance(
|
||||
messages_test.CSR, (self.authzr,)))
|
||||
# TODO: check POST args
|
||||
|
||||
def test_request_issuance_missing_up(self):
|
||||
self.response.content = CERT_DER
|
||||
self.response.headers['Location'] = self.certr.uri
|
||||
self.assertEqual(
|
||||
self.certr.update(cert_chain_uri=None),
|
||||
self.client.request_issuance(messages_test.CSR, (self.authzr,)))
|
||||
|
||||
def test_request_issuance_missing_location(self):
|
||||
self.assertRaises(
|
||||
errors.ClientError, self.client.request_issuance,
|
||||
messages_test.CSR, (self.authzr,))
|
||||
|
||||
@mock.patch('acme.client.datetime')
|
||||
@mock.patch('acme.client.time')
|
||||
def test_poll_and_request_issuance(self, time_mock, dt_mock):
|
||||
# clock.dt | pylint: disable=no-member
|
||||
clock = mock.MagicMock(dt=datetime.datetime(2015, 3, 27))
|
||||
|
||||
def sleep(seconds):
|
||||
"""increment clock"""
|
||||
clock.dt += datetime.timedelta(seconds=seconds)
|
||||
time_mock.sleep.side_effect = sleep
|
||||
|
||||
def now():
|
||||
"""return current clock value"""
|
||||
return clock.dt
|
||||
dt_mock.datetime.now.side_effect = now
|
||||
dt_mock.timedelta = datetime.timedelta
|
||||
|
||||
def poll(authzr): # pylint: disable=missing-docstring
|
||||
# record poll start time based on the current clock value
|
||||
authzr.times.append(clock.dt)
|
||||
|
||||
# suppose it takes 2 seconds for server to produce the
|
||||
# result, increment clock
|
||||
clock.dt += datetime.timedelta(seconds=2)
|
||||
|
||||
if not authzr.retries: # no more retries
|
||||
done = mock.MagicMock(uri=authzr.uri, times=authzr.times)
|
||||
done.body.status = messages.STATUS_VALID
|
||||
return done, []
|
||||
|
||||
# response (2nd result tuple element) is reduced to only
|
||||
# Retry-After header contents represented as integer
|
||||
# seconds; authzr.retries is a list of Retry-After
|
||||
# headers, head(retries) is peeled of as a current
|
||||
# Retry-After header, and tail(retries) is persisted for
|
||||
# later poll() calls
|
||||
return (mock.MagicMock(retries=authzr.retries[1:],
|
||||
uri=authzr.uri + '.', times=authzr.times),
|
||||
authzr.retries[0])
|
||||
self.client.poll = mock.MagicMock(side_effect=poll)
|
||||
|
||||
mintime = 7
|
||||
|
||||
def retry_after(response, default): # pylint: disable=missing-docstring
|
||||
# check that poll_and_request_issuance correctly passes mintime
|
||||
self.assertEqual(default, mintime)
|
||||
return clock.dt + datetime.timedelta(seconds=response)
|
||||
self.client.retry_after = mock.MagicMock(side_effect=retry_after)
|
||||
|
||||
def request_issuance(csr, authzrs): # pylint: disable=missing-docstring
|
||||
return csr, authzrs
|
||||
self.client.request_issuance = mock.MagicMock(
|
||||
side_effect=request_issuance)
|
||||
|
||||
csr = mock.MagicMock()
|
||||
authzrs = (
|
||||
mock.MagicMock(uri='a', times=[], retries=(8, 20, 30)),
|
||||
mock.MagicMock(uri='b', times=[], retries=(5,)),
|
||||
)
|
||||
|
||||
cert, updated_authzrs = self.client.poll_and_request_issuance(
|
||||
csr, authzrs, mintime=mintime)
|
||||
self.assertTrue(cert[0] is csr)
|
||||
self.assertTrue(cert[1] is updated_authzrs)
|
||||
self.assertEqual(updated_authzrs[0].uri, 'a...')
|
||||
self.assertEqual(updated_authzrs[1].uri, 'b.')
|
||||
self.assertEqual(updated_authzrs[0].times, [
|
||||
datetime.datetime(2015, 3, 27),
|
||||
# a is scheduled for 10, but b is polling [9..11), so it
|
||||
# will be picked up as soon as b is finished, without
|
||||
# additional sleeping
|
||||
datetime.datetime(2015, 3, 27, 0, 0, 11),
|
||||
datetime.datetime(2015, 3, 27, 0, 0, 33),
|
||||
datetime.datetime(2015, 3, 27, 0, 1, 5),
|
||||
])
|
||||
self.assertEqual(updated_authzrs[1].times, [
|
||||
datetime.datetime(2015, 3, 27, 0, 0, 2),
|
||||
datetime.datetime(2015, 3, 27, 0, 0, 9),
|
||||
])
|
||||
self.assertEqual(clock.dt, datetime.datetime(2015, 3, 27, 0, 1, 7))
|
||||
|
||||
def test_check_cert(self):
|
||||
self.response.headers['Location'] = self.certr.uri
|
||||
self.response.content = CERT_DER
|
||||
self.assertEqual(self.certr.update(body=messages_test.CERT),
|
||||
self.client.check_cert(self.certr))
|
||||
|
||||
# TODO: split here and separate test
|
||||
self.response.headers['Location'] = 'foo'
|
||||
self.assertRaises(
|
||||
errors.UnexpectedUpdate, self.client.check_cert, self.certr)
|
||||
|
||||
def test_check_cert_missing_location(self):
|
||||
self.response.content = CERT_DER
|
||||
self.assertRaises(
|
||||
errors.ClientError, self.client.check_cert, self.certr)
|
||||
|
||||
def test_refresh(self):
|
||||
self.client.check_cert = mock.MagicMock()
|
||||
self.assertEqual(
|
||||
self.client.check_cert(self.certr), self.client.refresh(self.certr))
|
||||
|
||||
def test_fetch_chain(self):
|
||||
# pylint: disable=protected-access
|
||||
self.client._get_cert = mock.MagicMock()
|
||||
self.client._get_cert.return_value = ("response", "certificate")
|
||||
self.assertEqual(self.client._get_cert(self.certr.cert_chain_uri)[1],
|
||||
self.client.fetch_chain(self.certr))
|
||||
|
||||
def test_fetch_chain_no_up_link(self):
|
||||
self.assertTrue(self.client.fetch_chain(self.certr.update(
|
||||
cert_chain_uri=None)) is None)
|
||||
|
||||
def test_revoke(self):
|
||||
self.client.revoke(self.certr.body)
|
||||
self.net.post.assert_called_once_with(messages.Revocation.url(
|
||||
self.client.new_reg_uri), mock.ANY)
|
||||
|
||||
def test_revoke_bad_status_raises_error(self):
|
||||
self.response.status_code = http_client.METHOD_NOT_ALLOWED
|
||||
self.assertRaises(errors.ClientError, self.client.revoke, self.certr)
|
||||
|
||||
|
||||
class ClientNetworkTest(unittest.TestCase):
|
||||
"""Tests for acme.client.ClientNetwork."""
|
||||
|
||||
def setUp(self):
|
||||
self.verify_ssl = mock.MagicMock()
|
||||
self.wrap_in_jws = mock.MagicMock(return_value=mock.sentinel.wrapped)
|
||||
|
||||
from acme.client import ClientNetwork
|
||||
self.net = ClientNetwork(
|
||||
key=KEY, alg=jose.RS256, verify_ssl=self.verify_ssl)
|
||||
|
||||
self.response = mock.MagicMock(ok=True, status_code=http_client.OK)
|
||||
self.response.headers = {}
|
||||
self.response.links = {}
|
||||
|
||||
def test_init(self):
|
||||
self.assertTrue(self.net.verify_ssl is self.verify_ssl)
|
||||
|
||||
def test_wrap_in_jws(self):
|
||||
class MockJSONDeSerializable(jose.JSONDeSerializable):
|
||||
# pylint: disable=missing-docstring
|
||||
def __init__(self, value):
|
||||
self.value = value
|
||||
def to_partial_json(self):
|
||||
return {'foo': self.value}
|
||||
@classmethod
|
||||
def from_json(cls, value):
|
||||
pass # pragma: no cover
|
||||
# pylint: disable=protected-access
|
||||
jws_dump = self.net._wrap_in_jws(
|
||||
MockJSONDeSerializable('foo'), nonce=b'Tg')
|
||||
jws = acme_jws.JWS.json_loads(jws_dump)
|
||||
self.assertEqual(json.loads(jws.payload.decode()), {'foo': 'foo'})
|
||||
self.assertEqual(jws.signature.combined.nonce, b'Tg')
|
||||
|
||||
def test_check_response_not_ok_jobj_no_error(self):
|
||||
self.response.ok = False
|
||||
self.response.json.return_value = {}
|
||||
# pylint: disable=protected-access
|
||||
self.assertRaises(
|
||||
errors.ClientError, self.net._check_response, self.response)
|
||||
|
||||
def test_check_response_not_ok_jobj_error(self):
|
||||
self.response.ok = False
|
||||
self.response.json.return_value = messages.Error(
|
||||
detail='foo', typ='serverInternal', title='some title').to_json()
|
||||
# pylint: disable=protected-access
|
||||
self.assertRaises(
|
||||
messages.Error, self.net._check_response, self.response)
|
||||
|
||||
def test_check_response_not_ok_no_jobj(self):
|
||||
self.response.ok = False
|
||||
self.response.json.side_effect = ValueError
|
||||
# pylint: disable=protected-access
|
||||
self.assertRaises(
|
||||
errors.ClientError, self.net._check_response, self.response)
|
||||
|
||||
def test_check_response_ok_no_jobj_ct_required(self):
|
||||
self.response.json.side_effect = ValueError
|
||||
for response_ct in [self.net.JSON_CONTENT_TYPE, 'foo']:
|
||||
self.response.headers['Content-Type'] = response_ct
|
||||
# pylint: disable=protected-access
|
||||
self.assertRaises(
|
||||
errors.ClientError, self.net._check_response, self.response,
|
||||
content_type=self.net.JSON_CONTENT_TYPE)
|
||||
|
||||
def test_check_response_ok_no_jobj_no_ct(self):
|
||||
self.response.json.side_effect = ValueError
|
||||
for response_ct in [self.net.JSON_CONTENT_TYPE, 'foo']:
|
||||
self.response.headers['Content-Type'] = response_ct
|
||||
# pylint: disable=protected-access,no-value-for-parameter
|
||||
self.assertEqual(
|
||||
self.response, self.net._check_response(self.response))
|
||||
|
||||
def test_check_response_jobj(self):
|
||||
self.response.json.return_value = {}
|
||||
for response_ct in [self.net.JSON_CONTENT_TYPE, 'foo']:
|
||||
self.response.headers['Content-Type'] = response_ct
|
||||
# pylint: disable=protected-access,no-value-for-parameter
|
||||
self.assertEqual(
|
||||
self.response, self.net._check_response(self.response))
|
||||
|
||||
@mock.patch('acme.client.requests')
|
||||
def test_send_request(self, mock_requests):
|
||||
mock_requests.request.return_value = self.response
|
||||
# pylint: disable=protected-access
|
||||
self.assertEqual(self.response, self.net._send_request(
|
||||
'HEAD', 'url', 'foo', bar='baz'))
|
||||
mock_requests.request.assert_called_once_with(
|
||||
'HEAD', 'url', 'foo', verify=mock.ANY, bar='baz')
|
||||
|
||||
@mock.patch('acme.client.requests')
|
||||
def test_send_request_verify_ssl(self, mock_requests):
|
||||
# pylint: disable=protected-access
|
||||
for verify in True, False:
|
||||
mock_requests.request.reset_mock()
|
||||
mock_requests.request.return_value = self.response
|
||||
self.net.verify_ssl = verify
|
||||
# pylint: disable=protected-access
|
||||
self.assertEqual(
|
||||
self.response, self.net._send_request('GET', 'url'))
|
||||
mock_requests.request.assert_called_once_with(
|
||||
'GET', 'url', verify=verify)
|
||||
|
||||
@mock.patch('acme.client.requests')
|
||||
def test_requests_error_passthrough(self, mock_requests):
|
||||
mock_requests.exceptions = requests.exceptions
|
||||
mock_requests.request.side_effect = requests.exceptions.RequestException
|
||||
# pylint: disable=protected-access
|
||||
self.assertRaises(requests.exceptions.RequestException,
|
||||
self.net._send_request, 'GET', 'uri')
|
||||
|
||||
|
||||
class ClientNetworkWithMockedResponseTest(unittest.TestCase):
|
||||
"""Tests for acme.client.ClientNetwork which mock out response."""
|
||||
# pylint: disable=too-many-instance-attributes
|
||||
|
||||
def setUp(self):
|
||||
from acme.client import ClientNetwork
|
||||
self.net = ClientNetwork(key=None, alg=None)
|
||||
|
||||
self.response = mock.MagicMock(ok=True, status_code=http_client.OK)
|
||||
self.response.headers = {}
|
||||
self.response.links = {}
|
||||
self.checked_response = mock.MagicMock()
|
||||
self.obj = mock.MagicMock()
|
||||
self.wrapped_obj = mock.MagicMock()
|
||||
self.content_type = mock.sentinel.content_type
|
||||
|
||||
self.all_nonces = [jose.b64encode(b'Nonce'), jose.b64encode(b'Nonce2')]
|
||||
self.available_nonces = self.all_nonces[:]
|
||||
def send_request(*args, **kwargs):
|
||||
# pylint: disable=unused-argument,missing-docstring
|
||||
if self.available_nonces:
|
||||
self.response.headers = {
|
||||
self.net.REPLAY_NONCE_HEADER:
|
||||
self.available_nonces.pop().decode()}
|
||||
else:
|
||||
self.response.headers = {}
|
||||
return self.response
|
||||
|
||||
# pylint: disable=protected-access
|
||||
self.net._send_request = self.send_request = mock.MagicMock(
|
||||
side_effect=send_request)
|
||||
self.net._check_response = self.check_response
|
||||
self.net._wrap_in_jws = mock.MagicMock(return_value=self.wrapped_obj)
|
||||
|
||||
def check_response(self, response, content_type):
|
||||
# pylint: disable=missing-docstring
|
||||
self.assertEqual(self.response, response)
|
||||
self.assertEqual(self.content_type, content_type)
|
||||
return self.checked_response
|
||||
|
||||
def test_head(self):
|
||||
self.assertEqual(self.response, self.net.head('url', 'foo', bar='baz'))
|
||||
self.send_request.assert_called_once_with(
|
||||
'HEAD', 'url', 'foo', bar='baz')
|
||||
|
||||
def test_get(self):
|
||||
self.assertEqual(self.checked_response, self.net.get(
|
||||
'url', content_type=self.content_type, bar='baz'))
|
||||
self.send_request.assert_called_once_with('GET', 'url', bar='baz')
|
||||
|
||||
def test_post(self):
|
||||
# pylint: disable=protected-access
|
||||
self.assertEqual(self.checked_response, self.net.post(
|
||||
'uri', self.obj, content_type=self.content_type))
|
||||
self.net._wrap_in_jws.assert_called_once_with(
|
||||
self.obj, jose.b64decode(self.all_nonces.pop()))
|
||||
|
||||
assert not self.available_nonces
|
||||
self.assertRaises(errors.MissingNonce, self.net.post,
|
||||
'uri', self.obj, content_type=self.content_type)
|
||||
self.net._wrap_in_jws.assert_called_with(
|
||||
self.obj, jose.b64decode(self.all_nonces.pop()))
|
||||
|
||||
def test_post_wrong_initial_nonce(self): # HEAD
|
||||
self.available_nonces = [b'f', jose.b64encode(b'good')]
|
||||
self.assertRaises(errors.BadNonce, self.net.post, 'uri',
|
||||
self.obj, content_type=self.content_type)
|
||||
|
||||
def test_post_wrong_post_response_nonce(self):
|
||||
self.available_nonces = [jose.b64encode(b'good'), b'f']
|
||||
self.assertRaises(errors.BadNonce, self.net.post, 'uri',
|
||||
self.obj, content_type=self.content_type)
|
||||
|
||||
def test_head_get_post_error_passthrough(self):
|
||||
self.send_request.side_effect = requests.exceptions.RequestException
|
||||
for method in self.net.head, self.net.get:
|
||||
self.assertRaises(
|
||||
requests.exceptions.RequestException, method, 'GET', 'uri')
|
||||
self.assertRaises(requests.exceptions.RequestException,
|
||||
self.net.post, 'uri', obj=self.obj)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main() # pragma: no cover
|
||||
195
acme/acme/crypto_util.py
Normal file
195
acme/acme/crypto_util.py
Normal file
|
|
@ -0,0 +1,195 @@
|
|||
"""Crypto utilities."""
|
||||
import contextlib
|
||||
import logging
|
||||
import socket
|
||||
import sys
|
||||
|
||||
from six.moves import range # pylint: disable=import-error,redefined-builtin
|
||||
|
||||
import OpenSSL
|
||||
|
||||
from acme import errors
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# DVSNI certificate serving and probing is not affected by SSL
|
||||
# vulnerabilities: prober needs to check certificate for expected
|
||||
# contents anyway. Working SNI is the only thing that's necessary for
|
||||
# the challenge and thus scoping down SSL/TLS method (version) would
|
||||
# cause interoperability issues: TLSv1_METHOD is only compatible with
|
||||
# TLSv1_METHOD, while SSLv23_METHOD is compatible with all other
|
||||
# methods, including TLSv2_METHOD (read more at
|
||||
# https://www.openssl.org/docs/ssl/SSLv23_method.html). _serve_sni
|
||||
# should be changed to use "set_options" to disable SSLv2 and SSLv3,
|
||||
# in case it's used for things other than probing/serving!
|
||||
_DEFAULT_DVSNI_SSL_METHOD = OpenSSL.SSL.SSLv23_METHOD
|
||||
|
||||
|
||||
def _serve_sni(certs, sock, reuseaddr=True, method=_DEFAULT_DVSNI_SSL_METHOD,
|
||||
accept=None):
|
||||
"""Start SNI-enabled server, that drops connection after handshake.
|
||||
|
||||
:param certs: Mapping from SNI name to ``(key, cert)`` `tuple`.
|
||||
:param sock: Already bound socket.
|
||||
:param bool reuseaddr: Should `socket.SO_REUSEADDR` be set?
|
||||
:param method: See `OpenSSL.SSL.Context` for allowed values.
|
||||
:param accept: Callable that doesn't take any arguments and
|
||||
returns ``True`` if more connections should be served.
|
||||
|
||||
"""
|
||||
def _pick_certificate(connection):
|
||||
try:
|
||||
key, cert = certs[connection.get_servername()]
|
||||
except KeyError:
|
||||
return
|
||||
new_context = OpenSSL.SSL.Context(method)
|
||||
new_context.use_privatekey(key)
|
||||
new_context.use_certificate(cert)
|
||||
connection.set_context(new_context)
|
||||
|
||||
if reuseaddr:
|
||||
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||
sock.listen(1) # TODO: add func arg?
|
||||
|
||||
while accept is None or accept():
|
||||
server, addr = sock.accept()
|
||||
logger.debug('Received connection from %s', addr)
|
||||
|
||||
with contextlib.closing(server):
|
||||
context = OpenSSL.SSL.Context(method)
|
||||
context.set_tlsext_servername_callback(_pick_certificate)
|
||||
|
||||
server_ssl = OpenSSL.SSL.Connection(context, server)
|
||||
server_ssl.set_accept_state()
|
||||
try:
|
||||
server_ssl.do_handshake()
|
||||
server_ssl.shutdown()
|
||||
except OpenSSL.SSL.Error as error:
|
||||
raise errors.Error(error)
|
||||
|
||||
|
||||
def _probe_sni(name, host, port=443, timeout=300,
|
||||
method=_DEFAULT_DVSNI_SSL_METHOD, source_address=('0', 0)):
|
||||
"""Probe SNI server for SSL certificate.
|
||||
|
||||
:param bytes name: Byte string to send as the server name in the
|
||||
client hello message.
|
||||
:param bytes host: Host to connect to.
|
||||
:param int port: Port to connect to.
|
||||
:param int timeout: Timeout in seconds.
|
||||
:param method: See `OpenSSL.SSL.Context` for allowed values.
|
||||
:param tuple source_address: Enables multi-path probing (selection
|
||||
of source interface). See `socket.creation_connection` for more
|
||||
info. Available only in Python 2.7+.
|
||||
|
||||
:raises acme.errors.Error: In case of any problems.
|
||||
|
||||
:returns: SSL certificate presented by the server.
|
||||
:rtype: OpenSSL.crypto.X509
|
||||
|
||||
"""
|
||||
context = OpenSSL.SSL.Context(method)
|
||||
context.set_timeout(timeout)
|
||||
|
||||
socket_kwargs = {} if sys.version_info < (2, 7) else {
|
||||
'source_address': source_address}
|
||||
|
||||
try:
|
||||
# pylint: disable=star-args
|
||||
sock = socket.create_connection((host, port), **socket_kwargs)
|
||||
except socket.error as error:
|
||||
raise errors.Error(error)
|
||||
|
||||
with contextlib.closing(sock) as client:
|
||||
client_ssl = OpenSSL.SSL.Connection(context, client)
|
||||
client_ssl.set_connect_state()
|
||||
client_ssl.set_tlsext_host_name(name) # pyOpenSSL>=0.13
|
||||
try:
|
||||
client_ssl.do_handshake()
|
||||
client_ssl.shutdown()
|
||||
except OpenSSL.SSL.Error as error:
|
||||
raise errors.Error(error)
|
||||
return client_ssl.get_peer_certificate()
|
||||
|
||||
|
||||
def _pyopenssl_cert_or_req_san(cert_or_req):
|
||||
"""Get Subject Alternative Names from certificate or CSR using pyOpenSSL.
|
||||
|
||||
.. todo:: Implement directly in PyOpenSSL!
|
||||
|
||||
.. note:: Although this is `acme` internal API, it is used by
|
||||
`letsencrypt`.
|
||||
|
||||
:param cert_or_req: Certificate or CSR.
|
||||
:type cert_or_req: `OpenSSL.crypto.X509` or `OpenSSL.crypto.X509Req`.
|
||||
|
||||
:returns: A list of Subject Alternative Names.
|
||||
:rtype: `list` of `unicode`
|
||||
|
||||
"""
|
||||
# constants based on implementation of
|
||||
# OpenSSL.crypto.X509Error._subjectAltNameString
|
||||
parts_separator = ", "
|
||||
part_separator = ":"
|
||||
extension_short_name = b"subjectAltName"
|
||||
|
||||
if hasattr(cert_or_req, 'get_extensions'): # X509Req
|
||||
extensions = cert_or_req.get_extensions()
|
||||
else: # X509
|
||||
extensions = [cert_or_req.get_extension(i)
|
||||
for i in range(cert_or_req.get_extension_count())]
|
||||
|
||||
# pylint: disable=protected-access,no-member
|
||||
label = OpenSSL.crypto.X509Extension._prefixes[OpenSSL.crypto._lib.GEN_DNS]
|
||||
assert parts_separator not in label
|
||||
prefix = label + part_separator
|
||||
|
||||
san_extensions = [
|
||||
ext._subjectAltNameString().split(parts_separator)
|
||||
for ext in extensions if ext.get_short_name() == extension_short_name]
|
||||
# WARNING: this function assumes that no SAN can include
|
||||
# parts_separator, hence the split!
|
||||
|
||||
return [part.split(part_separator)[1] for parts in san_extensions
|
||||
for part in parts if part.startswith(prefix)]
|
||||
|
||||
|
||||
def gen_ss_cert(key, domains, not_before=None, validity=(7 * 24 * 60 * 60)):
|
||||
"""Generate new self-signed certificate.
|
||||
|
||||
:type domains: `list` of `unicode`
|
||||
:param OpenSSL.crypto.PKey key:
|
||||
|
||||
Uses key and contains all domains.
|
||||
|
||||
"""
|
||||
assert domains, "Must provide one or more hostnames for the cert."
|
||||
cert = OpenSSL.crypto.X509()
|
||||
cert.set_serial_number(1337)
|
||||
cert.set_version(2)
|
||||
|
||||
extensions = [
|
||||
OpenSSL.crypto.X509Extension(
|
||||
b"basicConstraints", True, b"CA:TRUE, pathlen:0"),
|
||||
]
|
||||
|
||||
cert.get_subject().CN = domains[0]
|
||||
# TODO: what to put into cert.get_subject()?
|
||||
cert.set_issuer(cert.get_subject())
|
||||
|
||||
if len(domains) > 1:
|
||||
extensions.append(OpenSSL.crypto.X509Extension(
|
||||
b"subjectAltName",
|
||||
critical=False,
|
||||
value=b", ".join(b"DNS:" + d.encode() for d in domains)
|
||||
))
|
||||
|
||||
cert.add_extensions(extensions)
|
||||
|
||||
cert.gmtime_adj_notBefore(0 if not_before is None else not_before)
|
||||
cert.gmtime_adj_notAfter(validity)
|
||||
|
||||
cert.set_pubkey(key)
|
||||
cert.sign(key, "sha256")
|
||||
return cert
|
||||
104
acme/acme/crypto_util_test.py
Normal file
104
acme/acme/crypto_util_test.py
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
"""Tests for acme.crypto_util."""
|
||||
import socket
|
||||
import threading
|
||||
import time
|
||||
import unittest
|
||||
|
||||
import mock
|
||||
import OpenSSL
|
||||
|
||||
from acme import errors
|
||||
from acme import jose
|
||||
from acme import test_util
|
||||
|
||||
|
||||
class ServeProbeSNITest(unittest.TestCase):
|
||||
"""Tests for acme.crypto_util._serve_sni/_probe_sni."""
|
||||
|
||||
def setUp(self):
|
||||
self.cert = test_util.load_cert('cert.pem')
|
||||
key = OpenSSL.crypto.load_privatekey(
|
||||
OpenSSL.crypto.FILETYPE_PEM,
|
||||
test_util.load_vector('rsa512_key.pem'))
|
||||
# pylint: disable=protected-access
|
||||
certs = {b'foo': (key, self.cert._wrapped)}
|
||||
|
||||
sock = socket.socket()
|
||||
sock.bind(('', 0)) # pick random port
|
||||
self.port = sock.getsockname()[1]
|
||||
|
||||
self.server = threading.Thread(target=self._run_server, args=(certs, sock))
|
||||
self.server.start()
|
||||
time.sleep(1) # TODO: avoid race conditions in other way
|
||||
|
||||
@classmethod
|
||||
def _run_server(cls, certs, sock):
|
||||
from acme.crypto_util import _serve_sni
|
||||
# TODO: improve testing of server errors and their conditions
|
||||
try:
|
||||
return _serve_sni(
|
||||
certs, sock, accept=mock.Mock(side_effect=[True, False]))
|
||||
except errors.Error:
|
||||
pass
|
||||
|
||||
def tearDown(self):
|
||||
self.server.join()
|
||||
|
||||
def _probe(self, name):
|
||||
from acme.crypto_util import _probe_sni
|
||||
return jose.ComparableX509(_probe_sni(
|
||||
name, host='127.0.0.1', port=self.port))
|
||||
|
||||
def test_probe_ok(self):
|
||||
self.assertEqual(self.cert, self._probe(b'foo'))
|
||||
|
||||
def test_probe_not_recognized_name(self):
|
||||
self.assertRaises(errors.Error, self._probe, b'bar')
|
||||
|
||||
def test_probe_connection_error(self):
|
||||
self._probe(b'foo')
|
||||
time.sleep(1) # TODO: avoid race conditions in other way
|
||||
self.assertRaises(errors.Error, self._probe, b'bar')
|
||||
|
||||
|
||||
class PyOpenSSLCertOrReqSANTest(unittest.TestCase):
|
||||
"""Test for acme.crypto_util._pyopenssl_cert_or_req_san."""
|
||||
|
||||
@classmethod
|
||||
def _call(cls, loader, name):
|
||||
# pylint: disable=protected-access
|
||||
from acme.crypto_util import _pyopenssl_cert_or_req_san
|
||||
return _pyopenssl_cert_or_req_san(loader(name))
|
||||
|
||||
def _call_cert(self, name):
|
||||
return self._call(test_util.load_cert, name)
|
||||
|
||||
def _call_csr(self, name):
|
||||
return self._call(test_util.load_csr, name)
|
||||
|
||||
def test_cert_no_sans(self):
|
||||
self.assertEqual(self._call_cert('cert.pem'), [])
|
||||
|
||||
def test_cert_two_sans(self):
|
||||
self.assertEqual(self._call_cert('cert-san.pem'),
|
||||
['example.com', 'www.example.com'])
|
||||
|
||||
def test_csr_no_sans(self):
|
||||
self.assertEqual(self._call_csr('csr-nosans.pem'), [])
|
||||
|
||||
def test_csr_one_san(self):
|
||||
self.assertEqual(self._call_csr('csr.pem'), ['example.com'])
|
||||
|
||||
def test_csr_two_sans(self):
|
||||
self.assertEqual(self._call_csr('csr-san.pem'),
|
||||
['example.com', 'www.example.com'])
|
||||
|
||||
def test_csr_six_sans(self):
|
||||
self.assertEqual(self._call_csr('csr-6sans.pem'),
|
||||
["example.com", "example.org", "example.net",
|
||||
"example.info", "subdomain.example.com",
|
||||
"other.subdomain.example.com"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main() # pragma: no cover
|
||||
53
acme/acme/errors.py
Normal file
53
acme/acme/errors.py
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
"""ACME errors."""
|
||||
from acme.jose import errors as jose_errors
|
||||
|
||||
|
||||
class Error(Exception):
|
||||
"""Generic ACME error."""
|
||||
|
||||
|
||||
class SchemaValidationError(jose_errors.DeserializationError):
|
||||
"""JSON schema ACME object validation error."""
|
||||
|
||||
|
||||
class ClientError(Error):
|
||||
"""Network error."""
|
||||
|
||||
|
||||
class UnexpectedUpdate(ClientError):
|
||||
"""Unexpected update error."""
|
||||
|
||||
|
||||
class NonceError(ClientError):
|
||||
"""Server response nonce error."""
|
||||
|
||||
|
||||
class BadNonce(NonceError):
|
||||
"""Bad nonce error."""
|
||||
def __init__(self, nonce, error, *args, **kwargs):
|
||||
super(BadNonce, self).__init__(*args, **kwargs)
|
||||
self.nonce = nonce
|
||||
self.error = error
|
||||
|
||||
def __str__(self):
|
||||
return 'Invalid nonce ({0!r}): {1}'.format(self.nonce, self.error)
|
||||
|
||||
|
||||
class MissingNonce(NonceError):
|
||||
"""Missing nonce error.
|
||||
|
||||
According to the specification an "ACME server MUST include an
|
||||
Replay-Nonce header field in each successful response to a POST it
|
||||
provides to a client (...)".
|
||||
|
||||
:ivar requests.Response response: HTTP Response
|
||||
|
||||
"""
|
||||
def __init__(self, response, *args, **kwargs):
|
||||
super(MissingNonce, self).__init__(*args, **kwargs)
|
||||
self.response = response
|
||||
|
||||
def __str__(self):
|
||||
return ('Server {0} response did not include a replay '
|
||||
'nonce, headers: {1}'.format(
|
||||
self.response.request.method, self.response.headers))
|
||||
33
acme/acme/errors_test.py
Normal file
33
acme/acme/errors_test.py
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
"""Tests for acme.errors."""
|
||||
import unittest
|
||||
|
||||
import mock
|
||||
|
||||
|
||||
class BadNonceTest(unittest.TestCase):
|
||||
"""Tests for acme.errors.BadNonce."""
|
||||
|
||||
def setUp(self):
|
||||
from acme.errors import BadNonce
|
||||
self.error = BadNonce(nonce="xxx", error="error")
|
||||
|
||||
def test_str(self):
|
||||
self.assertEqual("Invalid nonce ('xxx'): error", str(self.error))
|
||||
|
||||
|
||||
class MissingNonceTest(unittest.TestCase):
|
||||
"""Tests for acme.errors.MissingNonce."""
|
||||
|
||||
def setUp(self):
|
||||
from acme.errors import MissingNonce
|
||||
self.response = mock.MagicMock(headers={})
|
||||
self.response.request.method = 'FOO'
|
||||
self.error = MissingNonce(self.response)
|
||||
|
||||
def test_str(self):
|
||||
self.assertTrue("FOO" in str(self.error))
|
||||
self.assertTrue("{}" in str(self.error))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main() # pragma: no cover
|
||||
43
acme/acme/fields.py
Normal file
43
acme/acme/fields.py
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
"""ACME JSON fields."""
|
||||
import pyrfc3339
|
||||
|
||||
from acme import jose
|
||||
|
||||
|
||||
class RFC3339Field(jose.Field):
|
||||
"""RFC3339 field encoder/decoder.
|
||||
|
||||
Handles decoding/encoding between RFC3339 strings and aware (not
|
||||
naive) `datetime.datetime` objects
|
||||
(e.g. ``datetime.datetime.now(pytz.utc)``).
|
||||
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
def default_encoder(cls, value):
|
||||
return pyrfc3339.generate(value)
|
||||
|
||||
@classmethod
|
||||
def default_decoder(cls, value):
|
||||
try:
|
||||
return pyrfc3339.parse(value)
|
||||
except ValueError as error:
|
||||
raise jose.DeserializationError(error)
|
||||
|
||||
|
||||
class Resource(jose.Field):
|
||||
"""Resource MITM field."""
|
||||
|
||||
def __init__(self, resource_type, *args, **kwargs):
|
||||
self.resource_type = resource_type
|
||||
super(Resource, self).__init__(
|
||||
# TODO: omitempty used only to trick
|
||||
# JSONObjectWithFieldsMeta._defaults..., server implementation
|
||||
'resource', default=resource_type, *args, **kwargs)
|
||||
|
||||
def decode(self, value):
|
||||
if value != self.resource_type:
|
||||
raise jose.DeserializationError(
|
||||
'Wrong resource type: {0} instead of {1}'.format(
|
||||
value, self.resource_type))
|
||||
return value
|
||||
53
acme/acme/fields_test.py
Normal file
53
acme/acme/fields_test.py
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
"""Tests for acme.fields."""
|
||||
import datetime
|
||||
import unittest
|
||||
|
||||
import pytz
|
||||
|
||||
from acme import jose
|
||||
|
||||
|
||||
class RFC3339FieldTest(unittest.TestCase):
|
||||
"""Tests for acme.fields.RFC3339Field."""
|
||||
|
||||
def setUp(self):
|
||||
self.decoded = datetime.datetime(2015, 3, 27, tzinfo=pytz.utc)
|
||||
self.encoded = '2015-03-27T00:00:00Z'
|
||||
|
||||
def test_default_encoder(self):
|
||||
from acme.fields import RFC3339Field
|
||||
self.assertEqual(
|
||||
self.encoded, RFC3339Field.default_encoder(self.decoded))
|
||||
|
||||
def test_default_encoder_naive_fails(self):
|
||||
from acme.fields import RFC3339Field
|
||||
self.assertRaises(
|
||||
ValueError, RFC3339Field.default_encoder, datetime.datetime.now())
|
||||
|
||||
def test_default_decoder(self):
|
||||
from acme.fields import RFC3339Field
|
||||
self.assertEqual(
|
||||
self.decoded, RFC3339Field.default_decoder(self.encoded))
|
||||
|
||||
def test_default_decoder_raises_deserialization_error(self):
|
||||
from acme.fields import RFC3339Field
|
||||
self.assertRaises(
|
||||
jose.DeserializationError, RFC3339Field.default_decoder, '')
|
||||
|
||||
|
||||
class ResourceTest(unittest.TestCase):
|
||||
"""Tests for acme.fields.Resource."""
|
||||
|
||||
def setUp(self):
|
||||
from acme.fields import Resource
|
||||
self.field = Resource('x')
|
||||
|
||||
def test_decode_good(self):
|
||||
self.assertEqual('x', self.field.decode('x'))
|
||||
|
||||
def test_decode_wrong(self):
|
||||
self.assertRaises(jose.DeserializationError, self.field.decode, 'y')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main() # pragma: no cover
|
||||
82
acme/acme/jose/__init__.py
Normal file
82
acme/acme/jose/__init__.py
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
"""Javascript Object Signing and Encryption (jose).
|
||||
|
||||
This package is a Python implementation of the stadards developed by
|
||||
IETF `Javascript Object Signing and Encryption (Active WG)`_, in
|
||||
particular the following RFCs:
|
||||
|
||||
- `JSON Web Algorithms (JWA)`_
|
||||
- `JSON Web Key (JWK)`_
|
||||
- `JSON Web Signature (JWS)`_
|
||||
|
||||
|
||||
.. _`Javascript Object Signing and Encryption (Active WG)`:
|
||||
https://tools.ietf.org/wg/jose/
|
||||
|
||||
.. _`JSON Web Algorithms (JWA)`:
|
||||
https://datatracker.ietf.org/doc/draft-ietf-jose-json-web-algorithms/
|
||||
|
||||
.. _`JSON Web Key (JWK)`:
|
||||
https://datatracker.ietf.org/doc/draft-ietf-jose-json-web-key/
|
||||
|
||||
.. _`JSON Web Signature (JWS)`:
|
||||
https://datatracker.ietf.org/doc/draft-ietf-jose-json-web-signature/
|
||||
|
||||
"""
|
||||
from acme.jose.b64 import (
|
||||
b64decode,
|
||||
b64encode,
|
||||
)
|
||||
|
||||
from acme.jose.errors import (
|
||||
DeserializationError,
|
||||
SerializationError,
|
||||
Error,
|
||||
UnrecognizedTypeError,
|
||||
)
|
||||
|
||||
from acme.jose.interfaces import JSONDeSerializable
|
||||
|
||||
from acme.jose.json_util import (
|
||||
Field,
|
||||
JSONObjectWithFields,
|
||||
TypedJSONObjectWithFields,
|
||||
decode_b64jose,
|
||||
decode_cert,
|
||||
decode_csr,
|
||||
decode_hex16,
|
||||
encode_b64jose,
|
||||
encode_cert,
|
||||
encode_csr,
|
||||
encode_hex16,
|
||||
)
|
||||
|
||||
from acme.jose.jwa import (
|
||||
HS256,
|
||||
HS384,
|
||||
HS512,
|
||||
JWASignature,
|
||||
PS256,
|
||||
PS384,
|
||||
PS512,
|
||||
RS256,
|
||||
RS384,
|
||||
RS512,
|
||||
)
|
||||
|
||||
from acme.jose.jwk import (
|
||||
JWK,
|
||||
JWKRSA,
|
||||
)
|
||||
|
||||
from acme.jose.jws import (
|
||||
Header,
|
||||
JWS,
|
||||
Signature,
|
||||
)
|
||||
|
||||
from acme.jose.util import (
|
||||
ComparableX509,
|
||||
ComparableKey,
|
||||
ComparableRSAKey,
|
||||
ImmutableMap,
|
||||
)
|
||||
61
acme/acme/jose/b64.py
Normal file
61
acme/acme/jose/b64.py
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
"""JOSE Base64.
|
||||
|
||||
`JOSE Base64`_ is defined as:
|
||||
|
||||
- URL-safe Base64
|
||||
- padding stripped
|
||||
|
||||
|
||||
.. _`JOSE Base64`:
|
||||
https://tools.ietf.org/html/draft-ietf-jose-json-web-signature-37#appendix-C
|
||||
|
||||
.. Do NOT try to call this module "base64", as it will "shadow" the
|
||||
standard library.
|
||||
|
||||
"""
|
||||
import base64
|
||||
|
||||
import six
|
||||
|
||||
|
||||
def b64encode(data):
|
||||
"""JOSE Base64 encode.
|
||||
|
||||
:param data: Data to be encoded.
|
||||
:type data: `bytes`
|
||||
|
||||
:returns: JOSE Base64 string.
|
||||
:rtype: bytes
|
||||
|
||||
:raises TypeError: if `data` is of incorrect type
|
||||
|
||||
"""
|
||||
if not isinstance(data, six.binary_type):
|
||||
raise TypeError('argument should be {0}'.format(six.binary_type))
|
||||
return base64.urlsafe_b64encode(data).rstrip(b'=')
|
||||
|
||||
|
||||
def b64decode(data):
|
||||
"""JOSE Base64 decode.
|
||||
|
||||
:param data: Base64 string to be decoded. If it's unicode, then
|
||||
only ASCII characters are allowed.
|
||||
:type data: `bytes` or `unicode`
|
||||
|
||||
:returns: Decoded data.
|
||||
:rtype: bytes
|
||||
|
||||
:raises TypeError: if input is of incorrect type
|
||||
:raises ValueError: if input is unicode with non-ASCII characters
|
||||
|
||||
"""
|
||||
if isinstance(data, six.string_types):
|
||||
try:
|
||||
data = data.encode('ascii')
|
||||
except UnicodeEncodeError:
|
||||
raise ValueError(
|
||||
'unicode argument should contain only ASCII characters')
|
||||
elif not isinstance(data, six.binary_type):
|
||||
raise TypeError('argument should be a str or unicode')
|
||||
|
||||
return base64.urlsafe_b64decode(data + b'=' * (4 - (len(data) % 4)))
|
||||
77
acme/acme/jose/b64_test.py
Normal file
77
acme/acme/jose/b64_test.py
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
"""Tests for acme.jose.b64."""
|
||||
import unittest
|
||||
|
||||
import six
|
||||
|
||||
|
||||
# https://en.wikipedia.org/wiki/Base64#Examples
|
||||
B64_PADDING_EXAMPLES = {
|
||||
b'any carnal pleasure.': (b'YW55IGNhcm5hbCBwbGVhc3VyZS4', b'='),
|
||||
b'any carnal pleasure': (b'YW55IGNhcm5hbCBwbGVhc3VyZQ', b'=='),
|
||||
b'any carnal pleasur': (b'YW55IGNhcm5hbCBwbGVhc3Vy', b''),
|
||||
b'any carnal pleasu': (b'YW55IGNhcm5hbCBwbGVhc3U', b'='),
|
||||
b'any carnal pleas': (b'YW55IGNhcm5hbCBwbGVhcw', b'=='),
|
||||
}
|
||||
|
||||
|
||||
B64_URL_UNSAFE_EXAMPLES = {
|
||||
six.int2byte(251) + six.int2byte(239): b'--8',
|
||||
six.int2byte(255) * 2: b'__8',
|
||||
}
|
||||
|
||||
|
||||
class B64EncodeTest(unittest.TestCase):
|
||||
"""Tests for acme.jose.b64.b64encode."""
|
||||
|
||||
@classmethod
|
||||
def _call(cls, data):
|
||||
from acme.jose.b64 import b64encode
|
||||
return b64encode(data)
|
||||
|
||||
def test_empty(self):
|
||||
self.assertEqual(self._call(b''), b'')
|
||||
|
||||
def test_unsafe_url(self):
|
||||
for text, b64 in six.iteritems(B64_URL_UNSAFE_EXAMPLES):
|
||||
self.assertEqual(self._call(text), b64)
|
||||
|
||||
def test_different_paddings(self):
|
||||
for text, (b64, _) in six.iteritems(B64_PADDING_EXAMPLES):
|
||||
self.assertEqual(self._call(text), b64)
|
||||
|
||||
def test_unicode_fails_with_type_error(self):
|
||||
self.assertRaises(TypeError, self._call, u'some unicode')
|
||||
|
||||
|
||||
class B64DecodeTest(unittest.TestCase):
|
||||
"""Tests for acme.jose.b64.b64decode."""
|
||||
|
||||
@classmethod
|
||||
def _call(cls, data):
|
||||
from acme.jose.b64 import b64decode
|
||||
return b64decode(data)
|
||||
|
||||
def test_unsafe_url(self):
|
||||
for text, b64 in six.iteritems(B64_URL_UNSAFE_EXAMPLES):
|
||||
self.assertEqual(self._call(b64), text)
|
||||
|
||||
def test_input_without_padding(self):
|
||||
for text, (b64, _) in six.iteritems(B64_PADDING_EXAMPLES):
|
||||
self.assertEqual(self._call(b64), text)
|
||||
|
||||
def test_input_with_padding(self):
|
||||
for text, (b64, pad) in six.iteritems(B64_PADDING_EXAMPLES):
|
||||
self.assertEqual(self._call(b64 + pad), text)
|
||||
|
||||
def test_unicode_with_ascii(self):
|
||||
self.assertEqual(self._call(u'YQ'), b'a')
|
||||
|
||||
def test_non_ascii_unicode_fails(self):
|
||||
self.assertRaises(ValueError, self._call, u'\u0105')
|
||||
|
||||
def test_type_error_no_unicode_or_bytes(self):
|
||||
self.assertRaises(TypeError, self._call, object())
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main() # pragma: no cover
|
||||
31
acme/acme/jose/errors.py
Normal file
31
acme/acme/jose/errors.py
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
"""JOSE errors."""
|
||||
|
||||
|
||||
class Error(Exception):
|
||||
"""Generic JOSE Error."""
|
||||
|
||||
|
||||
class DeserializationError(Error):
|
||||
"""JSON deserialization error."""
|
||||
|
||||
|
||||
class SerializationError(Error):
|
||||
"""JSON serialization error."""
|
||||
|
||||
|
||||
class UnrecognizedTypeError(DeserializationError):
|
||||
"""Unrecognized type error.
|
||||
|
||||
:ivar str typ: The unrecognized type of the JSON object.
|
||||
:ivar jobj: Full JSON object.
|
||||
|
||||
"""
|
||||
|
||||
def __init__(self, typ, jobj):
|
||||
self.typ = typ
|
||||
self.jobj = jobj
|
||||
super(UnrecognizedTypeError, self).__init__(str(self))
|
||||
|
||||
def __str__(self):
|
||||
return '{0} was not recognized, full message: {1}'.format(
|
||||
self.typ, self.jobj)
|
||||
17
acme/acme/jose/errors_test.py
Normal file
17
acme/acme/jose/errors_test.py
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
"""Tests for acme.jose.errors."""
|
||||
import unittest
|
||||
|
||||
|
||||
class UnrecognizedTypeErrorTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
from acme.jose.errors import UnrecognizedTypeError
|
||||
self.error = UnrecognizedTypeError('foo', {'type': 'foo'})
|
||||
|
||||
def test_str(self):
|
||||
self.assertEqual(
|
||||
"foo was not recognized, full message: {'type': 'foo'}",
|
||||
str(self.error))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main() # pragma: no cover
|
||||
211
acme/acme/jose/interfaces.py
Normal file
211
acme/acme/jose/interfaces.py
Normal file
|
|
@ -0,0 +1,211 @@
|
|||
"""JOSE interfaces."""
|
||||
import abc
|
||||
import collections
|
||||
import json
|
||||
|
||||
import six
|
||||
|
||||
from acme.jose import util
|
||||
|
||||
# pylint: disable=no-self-argument,no-method-argument,no-init,inherit-non-class
|
||||
# pylint: disable=too-few-public-methods
|
||||
|
||||
|
||||
@six.add_metaclass(abc.ABCMeta)
|
||||
class JSONDeSerializable(object):
|
||||
# pylint: disable=too-few-public-methods
|
||||
"""Interface for (de)serializable JSON objects.
|
||||
|
||||
Please recall, that standard Python library implements
|
||||
:class:`json.JSONEncoder` and :class:`json.JSONDecoder` that perform
|
||||
translations based on respective :ref:`conversion tables
|
||||
<conversion-table>` that look pretty much like the one below (for
|
||||
complete tables see relevant Python documentation):
|
||||
|
||||
.. _conversion-table:
|
||||
|
||||
====== ======
|
||||
JSON Python
|
||||
====== ======
|
||||
object dict
|
||||
... ...
|
||||
====== ======
|
||||
|
||||
While the above **conversion table** is about translation of JSON
|
||||
documents to/from the basic Python types only,
|
||||
:class:`JSONDeSerializable` introduces the following two concepts:
|
||||
|
||||
serialization
|
||||
Turning an arbitrary Python object into Python object that can
|
||||
be encoded into a JSON document. **Full serialization** produces
|
||||
a Python object composed of only basic types as required by the
|
||||
:ref:`conversion table <conversion-table>`. **Partial
|
||||
serialization** (acomplished by :meth:`to_partial_json`)
|
||||
produces a Python object that might also be built from other
|
||||
:class:`JSONDeSerializable` objects.
|
||||
|
||||
deserialization
|
||||
Turning a decoded Python object (necessarily one of the basic
|
||||
types as required by the :ref:`conversion table
|
||||
<conversion-table>`) into an arbitrary Python object.
|
||||
|
||||
Serialization produces **serialized object** ("partially serialized
|
||||
object" or "fully serialized object" for partial and full
|
||||
serialization respectively) and deserialization produces
|
||||
**deserialized object**, both usually denoted in the source code as
|
||||
``jobj``.
|
||||
|
||||
Wording in the official Python documentation might be confusing
|
||||
after reading the above, but in the light of those definitions, one
|
||||
can view :meth:`json.JSONDecoder.decode` as decoder and
|
||||
deserializer of basic types, :meth:`json.JSONEncoder.default` as
|
||||
serializer of basic types, :meth:`json.JSONEncoder.encode` as
|
||||
serializer and encoder of basic types.
|
||||
|
||||
One could extend :mod:`json` to support arbitrary object
|
||||
(de)serialization either by:
|
||||
|
||||
- overriding :meth:`json.JSONDecoder.decode` and
|
||||
:meth:`json.JSONEncoder.default` in subclasses
|
||||
|
||||
- or passing ``object_hook`` argument (or ``object_hook_pairs``)
|
||||
to :func:`json.load`/:func:`json.loads` or ``default`` argument
|
||||
for :func:`json.dump`/:func:`json.dumps`.
|
||||
|
||||
Interestingly, ``default`` is required to perform only partial
|
||||
serialization, as :func:`json.dumps` applies ``default``
|
||||
recursively. This is the idea behind making :meth:`to_partial_json`
|
||||
produce only partial serialization, while providing custom
|
||||
:meth:`json_dumps` that dumps with ``default`` set to
|
||||
:meth:`json_dump_default`.
|
||||
|
||||
To make further documentation a bit more concrete, please, consider
|
||||
the following imaginatory implementation example::
|
||||
|
||||
class Foo(JSONDeSerializable):
|
||||
def to_partial_json(self):
|
||||
return 'foo'
|
||||
|
||||
@classmethod
|
||||
def from_json(cls, jobj):
|
||||
return Foo()
|
||||
|
||||
class Bar(JSONDeSerializable):
|
||||
def to_partial_json(self):
|
||||
return [Foo(), Foo()]
|
||||
|
||||
@classmethod
|
||||
def from_json(cls, jobj):
|
||||
return Bar()
|
||||
|
||||
"""
|
||||
|
||||
@abc.abstractmethod
|
||||
def to_partial_json(self): # pragma: no cover
|
||||
"""Partially serialize.
|
||||
|
||||
Following the example, **partial serialization** means the following::
|
||||
|
||||
assert isinstance(Bar().to_partial_json()[0], Foo)
|
||||
assert isinstance(Bar().to_partial_json()[1], Foo)
|
||||
|
||||
# in particular...
|
||||
assert Bar().to_partial_json() != ['foo', 'foo']
|
||||
|
||||
:raises acme.jose.errors.SerializationError:
|
||||
in case of any serialization error.
|
||||
:returns: Partially serializable object.
|
||||
|
||||
"""
|
||||
raise NotImplementedError()
|
||||
|
||||
def to_json(self):
|
||||
"""Fully serialize.
|
||||
|
||||
Again, following the example from before, **full serialization**
|
||||
means the following::
|
||||
|
||||
assert Bar().to_json() == ['foo', 'foo']
|
||||
|
||||
:raises acme.jose.errors.SerializationError:
|
||||
in case of any serialization error.
|
||||
:returns: Fully serialized object.
|
||||
|
||||
"""
|
||||
def _serialize(obj):
|
||||
if isinstance(obj, JSONDeSerializable):
|
||||
return _serialize(obj.to_partial_json())
|
||||
if isinstance(obj, six.string_types): # strings are Sequence
|
||||
return obj
|
||||
elif isinstance(obj, list):
|
||||
return [_serialize(subobj) for subobj in obj]
|
||||
elif isinstance(obj, collections.Sequence):
|
||||
# default to tuple, otherwise Mapping could get
|
||||
# unhashable list
|
||||
return tuple(_serialize(subobj) for subobj in obj)
|
||||
elif isinstance(obj, collections.Mapping):
|
||||
return dict((_serialize(key), _serialize(value))
|
||||
for key, value in six.iteritems(obj))
|
||||
else:
|
||||
return obj
|
||||
|
||||
return _serialize(self)
|
||||
|
||||
@util.abstractclassmethod
|
||||
def from_json(cls, jobj): # pylint: disable=unused-argument
|
||||
"""Deserialize a decoded JSON document.
|
||||
|
||||
:param jobj: Python object, composed of only other basic data
|
||||
types, as decoded from JSON document. Not necessarily
|
||||
:class:`dict` (as decoded from "JSON object" document).
|
||||
|
||||
:raises acme.jose.errors.DeserializationError:
|
||||
if decoding was unsuccessful, e.g. in case of unparseable
|
||||
X509 certificate, or wrong padding in JOSE base64 encoded
|
||||
string, etc.
|
||||
|
||||
"""
|
||||
# TypeError: Can't instantiate abstract class <cls> with
|
||||
# abstract methods from_json, to_partial_json
|
||||
return cls() # pylint: disable=abstract-class-instantiated
|
||||
|
||||
@classmethod
|
||||
def json_loads(cls, json_string):
|
||||
"""Deserialize from JSON document string."""
|
||||
return cls.from_json(json.loads(json_string))
|
||||
|
||||
def json_dumps(self, **kwargs):
|
||||
"""Dump to JSON string using proper serializer.
|
||||
|
||||
:returns: JSON document string.
|
||||
:rtype: str
|
||||
|
||||
"""
|
||||
return json.dumps(self, default=self.json_dump_default, **kwargs)
|
||||
|
||||
def json_dumps_pretty(self):
|
||||
"""Dump the object to pretty JSON document string.
|
||||
|
||||
:rtype: str
|
||||
|
||||
"""
|
||||
return self.json_dumps(sort_keys=True, indent=4, separators=(',', ': '))
|
||||
|
||||
@classmethod
|
||||
def json_dump_default(cls, python_object):
|
||||
"""Serialize Python object.
|
||||
|
||||
This function is meant to be passed as ``default`` to
|
||||
:func:`json.dump` or :func:`json.dumps`. They call
|
||||
``default(python_object)`` only for non-basic Python types, so
|
||||
this function necessarily raises :class:`TypeError` if
|
||||
``python_object`` is not an instance of
|
||||
:class:`IJSONSerializable`.
|
||||
|
||||
Please read the class docstring for more information.
|
||||
|
||||
"""
|
||||
if isinstance(python_object, JSONDeSerializable):
|
||||
return python_object.to_partial_json()
|
||||
else: # this branch is necessary, cannot just "return"
|
||||
raise TypeError(repr(python_object) + ' is not JSON serializable')
|
||||
114
acme/acme/jose/interfaces_test.py
Normal file
114
acme/acme/jose/interfaces_test.py
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
"""Tests for acme.jose.interfaces."""
|
||||
import unittest
|
||||
|
||||
|
||||
class JSONDeSerializableTest(unittest.TestCase):
|
||||
# pylint: disable=too-many-instance-attributes
|
||||
|
||||
def setUp(self):
|
||||
from acme.jose.interfaces import JSONDeSerializable
|
||||
|
||||
# pylint: disable=missing-docstring,invalid-name
|
||||
|
||||
class Basic(JSONDeSerializable):
|
||||
def __init__(self, v):
|
||||
self.v = v
|
||||
|
||||
def to_partial_json(self):
|
||||
return self.v
|
||||
|
||||
@classmethod
|
||||
def from_json(cls, jobj):
|
||||
return cls(jobj)
|
||||
|
||||
class Sequence(JSONDeSerializable):
|
||||
def __init__(self, x, y):
|
||||
self.x = x
|
||||
self.y = y
|
||||
|
||||
def to_partial_json(self):
|
||||
return [self.x, self.y]
|
||||
|
||||
@classmethod
|
||||
def from_json(cls, jobj):
|
||||
return cls(
|
||||
Basic.from_json(jobj[0]), Basic.from_json(jobj[1]))
|
||||
|
||||
class Mapping(JSONDeSerializable):
|
||||
def __init__(self, x, y):
|
||||
self.x = x
|
||||
self.y = y
|
||||
|
||||
def to_partial_json(self):
|
||||
return {self.x: self.y}
|
||||
|
||||
@classmethod
|
||||
def from_json(cls, jobj):
|
||||
pass # pragma: no cover
|
||||
|
||||
self.basic1 = Basic('foo1')
|
||||
self.basic2 = Basic('foo2')
|
||||
self.seq = Sequence(self.basic1, self.basic2)
|
||||
self.mapping = Mapping(self.basic1, self.basic2)
|
||||
self.nested = Basic([[self.basic1]])
|
||||
self.tuple = Basic(('foo',))
|
||||
|
||||
# pylint: disable=invalid-name
|
||||
self.Basic = Basic
|
||||
self.Sequence = Sequence
|
||||
self.Mapping = Mapping
|
||||
|
||||
def test_to_json_sequence(self):
|
||||
self.assertEqual(self.seq.to_json(), ['foo1', 'foo2'])
|
||||
|
||||
def test_to_json_mapping(self):
|
||||
self.assertEqual(self.mapping.to_json(), {'foo1': 'foo2'})
|
||||
|
||||
def test_to_json_other(self):
|
||||
mock_value = object()
|
||||
self.assertTrue(self.Basic(mock_value).to_json() is mock_value)
|
||||
|
||||
def test_to_json_nested(self):
|
||||
self.assertEqual(self.nested.to_json(), [['foo1']])
|
||||
|
||||
def test_to_json(self):
|
||||
self.assertEqual(self.tuple.to_json(), (('foo', )))
|
||||
|
||||
def test_from_json_not_implemented(self):
|
||||
from acme.jose.interfaces import JSONDeSerializable
|
||||
self.assertRaises(TypeError, JSONDeSerializable.from_json, 'xxx')
|
||||
|
||||
def test_json_loads(self):
|
||||
seq = self.Sequence.json_loads('["foo1", "foo2"]')
|
||||
self.assertTrue(isinstance(seq, self.Sequence))
|
||||
self.assertTrue(isinstance(seq.x, self.Basic))
|
||||
self.assertTrue(isinstance(seq.y, self.Basic))
|
||||
self.assertEqual(seq.x.v, 'foo1')
|
||||
self.assertEqual(seq.y.v, 'foo2')
|
||||
|
||||
def test_json_dumps(self):
|
||||
self.assertEqual('["foo1", "foo2"]', self.seq.json_dumps())
|
||||
|
||||
def test_json_dumps_pretty(self):
|
||||
self.assertEqual(
|
||||
self.seq.json_dumps_pretty(), '[\n "foo1",\n "foo2"\n]')
|
||||
|
||||
def test_json_dump_default(self):
|
||||
from acme.jose.interfaces import JSONDeSerializable
|
||||
|
||||
self.assertEqual(
|
||||
'foo1', JSONDeSerializable.json_dump_default(self.basic1))
|
||||
|
||||
jobj = JSONDeSerializable.json_dump_default(self.seq)
|
||||
self.assertEqual(len(jobj), 2)
|
||||
self.assertTrue(jobj[0] is self.basic1)
|
||||
self.assertTrue(jobj[1] is self.basic2)
|
||||
|
||||
def test_json_dump_default_type_error(self):
|
||||
from acme.jose.interfaces import JSONDeSerializable
|
||||
self.assertRaises(
|
||||
TypeError, JSONDeSerializable.json_dump_default, object())
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main() # pragma: no cover
|
||||
463
acme/acme/jose/json_util.py
Normal file
463
acme/acme/jose/json_util.py
Normal file
|
|
@ -0,0 +1,463 @@
|
|||
"""JSON (de)serialization framework.
|
||||
|
||||
The framework presented here is somewhat based on `Go's "json" package`_
|
||||
(especially the ``omitempty`` functionality).
|
||||
|
||||
.. _`Go's "json" package`: http://golang.org/pkg/encoding/json/
|
||||
|
||||
"""
|
||||
import abc
|
||||
import binascii
|
||||
import logging
|
||||
|
||||
import OpenSSL
|
||||
import six
|
||||
|
||||
from acme.jose import b64
|
||||
from acme.jose import errors
|
||||
from acme.jose import interfaces
|
||||
from acme.jose import util
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Field(object):
|
||||
"""JSON object field.
|
||||
|
||||
:class:`Field` is meant to be used together with
|
||||
:class:`JSONObjectWithFields`.
|
||||
|
||||
``encoder`` (``decoder``) is a callable that accepts a single
|
||||
parameter, i.e. a value to be encoded (decoded), and returns the
|
||||
serialized (deserialized) value. In case of errors it should raise
|
||||
:class:`~acme.jose.errors.SerializationError`
|
||||
(:class:`~acme.jose.errors.DeserializationError`).
|
||||
|
||||
Note, that ``decoder`` should perform partial serialization only.
|
||||
|
||||
:ivar str json_name: Name of the field when encoded to JSON.
|
||||
:ivar default: Default value (used when not present in JSON object).
|
||||
:ivar bool omitempty: If ``True`` and the field value is empty, then
|
||||
it will not be included in the serialized JSON object, and
|
||||
``default`` will be used for deserialization. Otherwise, if ``False``,
|
||||
field is considered as required, value will always be included in the
|
||||
serialized JSON objected, and it must also be present when
|
||||
deserializing.
|
||||
|
||||
"""
|
||||
__slots__ = ('json_name', 'default', 'omitempty', 'fdec', 'fenc')
|
||||
|
||||
def __init__(self, json_name, default=None, omitempty=False,
|
||||
decoder=None, encoder=None):
|
||||
# pylint: disable=too-many-arguments
|
||||
self.json_name = json_name
|
||||
self.default = default
|
||||
self.omitempty = omitempty
|
||||
|
||||
self.fdec = self.default_decoder if decoder is None else decoder
|
||||
self.fenc = self.default_encoder if encoder is None else encoder
|
||||
|
||||
@classmethod
|
||||
def _empty(cls, value):
|
||||
"""Is the provided value cosidered "empty" for this field?
|
||||
|
||||
This is useful for subclasses that might want to override the
|
||||
definition of being empty, e.g. for some more exotic data types.
|
||||
|
||||
"""
|
||||
return not isinstance(value, bool) and not value
|
||||
|
||||
def omit(self, value):
|
||||
"""Omit the value in output?"""
|
||||
return self._empty(value) and self.omitempty
|
||||
|
||||
def _update_params(self, **kwargs):
|
||||
current = dict(json_name=self.json_name, default=self.default,
|
||||
omitempty=self.omitempty,
|
||||
decoder=self.fdec, encoder=self.fenc)
|
||||
current.update(kwargs)
|
||||
return type(self)(**current) # pylint: disable=star-args
|
||||
|
||||
def decoder(self, fdec):
|
||||
"""Descriptor to change the decoder on JSON object field."""
|
||||
return self._update_params(decoder=fdec)
|
||||
|
||||
def encoder(self, fenc):
|
||||
"""Descriptor to change the encoder on JSON object field."""
|
||||
return self._update_params(encoder=fenc)
|
||||
|
||||
def decode(self, value):
|
||||
"""Decode a value, optionally with context JSON object."""
|
||||
return self.fdec(value)
|
||||
|
||||
def encode(self, value):
|
||||
"""Encode a value, optionally with context JSON object."""
|
||||
return self.fenc(value)
|
||||
|
||||
@classmethod
|
||||
def default_decoder(cls, value):
|
||||
"""Default decoder.
|
||||
|
||||
Recursively deserialize into immutable types (
|
||||
:class:`acme.jose.util.frozendict` instead of
|
||||
:func:`dict`, :func:`tuple` instead of :func:`list`).
|
||||
|
||||
"""
|
||||
# bases cases for different types returned by json.loads
|
||||
if isinstance(value, list):
|
||||
return tuple(cls.default_decoder(subvalue) for subvalue in value)
|
||||
elif isinstance(value, dict):
|
||||
return util.frozendict(
|
||||
dict((cls.default_decoder(key), cls.default_decoder(value))
|
||||
for key, value in six.iteritems(value)))
|
||||
else: # integer or string
|
||||
return value
|
||||
|
||||
@classmethod
|
||||
def default_encoder(cls, value):
|
||||
"""Default (passthrough) encoder."""
|
||||
# field.to_partial_json() is no good as encoder has to do partial
|
||||
# serialization only
|
||||
return value
|
||||
|
||||
|
||||
class JSONObjectWithFieldsMeta(abc.ABCMeta):
|
||||
"""Metaclass for :class:`JSONObjectWithFields` and its subclasses.
|
||||
|
||||
It makes sure that, for any class ``cls`` with ``__metaclass__``
|
||||
set to ``JSONObjectWithFieldsMeta``:
|
||||
|
||||
1. All fields (attributes of type :class:`Field`) in the class
|
||||
definition are moved to the ``cls._fields`` dictionary, where
|
||||
keys are field attribute names and values are fields themselves.
|
||||
|
||||
2. ``cls.__slots__`` is extended by all field attribute names
|
||||
(i.e. not :attr:`Field.json_name`). Original ``cls.__slots__``
|
||||
are stored in ``cls._orig_slots``.
|
||||
|
||||
In a consequence, for a field attribute name ``some_field``,
|
||||
``cls.some_field`` will be a slot descriptor and not an instance
|
||||
of :class:`Field`. For example::
|
||||
|
||||
some_field = Field('someField', default=())
|
||||
|
||||
class Foo(object):
|
||||
__metaclass__ = JSONObjectWithFieldsMeta
|
||||
__slots__ = ('baz',)
|
||||
some_field = some_field
|
||||
|
||||
assert Foo.__slots__ == ('some_field', 'baz')
|
||||
assert Foo._orig_slots == ()
|
||||
assert Foo.some_field is not Field
|
||||
|
||||
assert Foo._fields.keys() == ['some_field']
|
||||
assert Foo._fields['some_field'] is some_field
|
||||
|
||||
As an implementation note, this metaclass inherits from
|
||||
:class:`abc.ABCMeta` (and not the usual :class:`type`) to mitigate
|
||||
the metaclass conflict (:class:`ImmutableMap` and
|
||||
:class:`JSONDeSerializable`, parents of :class:`JSONObjectWithFields`,
|
||||
use :class:`abc.ABCMeta` as its metaclass).
|
||||
|
||||
"""
|
||||
|
||||
def __new__(mcs, name, bases, dikt):
|
||||
fields = {}
|
||||
|
||||
for base in bases:
|
||||
fields.update(getattr(base, '_fields', {}))
|
||||
# Do not reorder, this class might override fields from base classes!
|
||||
for key, value in tuple(six.iteritems(dikt)):
|
||||
# not six.iterkeys() (in-place edit!)
|
||||
if isinstance(value, Field):
|
||||
fields[key] = dikt.pop(key)
|
||||
|
||||
dikt['_orig_slots'] = dikt.get('__slots__', ())
|
||||
dikt['__slots__'] = tuple(
|
||||
list(dikt['_orig_slots']) + list(six.iterkeys(fields)))
|
||||
dikt['_fields'] = fields
|
||||
|
||||
return abc.ABCMeta.__new__(mcs, name, bases, dikt)
|
||||
|
||||
|
||||
@six.add_metaclass(JSONObjectWithFieldsMeta)
|
||||
class JSONObjectWithFields(util.ImmutableMap, interfaces.JSONDeSerializable):
|
||||
# pylint: disable=too-few-public-methods
|
||||
"""JSON object with fields.
|
||||
|
||||
Example::
|
||||
|
||||
class Foo(JSONObjectWithFields):
|
||||
bar = Field('Bar')
|
||||
empty = Field('Empty', omitempty=True)
|
||||
|
||||
@bar.encoder
|
||||
def bar(value):
|
||||
return value + 'bar'
|
||||
|
||||
@bar.decoder
|
||||
def bar(value):
|
||||
if not value.endswith('bar'):
|
||||
raise errors.DeserializationError('No bar suffix!')
|
||||
return value[:-3]
|
||||
|
||||
assert Foo(bar='baz').to_partial_json() == {'Bar': 'bazbar'}
|
||||
assert Foo.from_json({'Bar': 'bazbar'}) == Foo(bar='baz')
|
||||
assert (Foo.from_json({'Bar': 'bazbar', 'Empty': '!'})
|
||||
== Foo(bar='baz', empty='!'))
|
||||
assert Foo(bar='baz').bar == 'baz'
|
||||
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
def _defaults(cls):
|
||||
"""Get default fields values."""
|
||||
return dict([(slot, field.default) for slot, field
|
||||
in six.iteritems(cls._fields)])
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
# pylint: disable=star-args
|
||||
super(JSONObjectWithFields, self).__init__(
|
||||
**(dict(self._defaults(), **kwargs)))
|
||||
|
||||
def fields_to_partial_json(self):
|
||||
"""Serialize fields to JSON."""
|
||||
jobj = {}
|
||||
omitted = set()
|
||||
for slot, field in six.iteritems(self._fields):
|
||||
value = getattr(self, slot)
|
||||
|
||||
if field.omit(value):
|
||||
omitted.add((slot, value))
|
||||
else:
|
||||
try:
|
||||
jobj[field.json_name] = field.encode(value)
|
||||
except errors.SerializationError as error:
|
||||
raise errors.SerializationError(
|
||||
'Could not encode {0} ({1}): {2}'.format(
|
||||
slot, value, error))
|
||||
if omitted:
|
||||
# pylint: disable=star-args
|
||||
logger.debug('Omitted empty fields: %s', ', '.join(
|
||||
'{0!s}={1!r}'.format(*field) for field in omitted))
|
||||
return jobj
|
||||
|
||||
def to_partial_json(self):
|
||||
return self.fields_to_partial_json()
|
||||
|
||||
@classmethod
|
||||
def _check_required(cls, jobj):
|
||||
missing = set()
|
||||
for _, field in six.iteritems(cls._fields):
|
||||
if not field.omitempty and field.json_name not in jobj:
|
||||
missing.add(field.json_name)
|
||||
|
||||
if missing:
|
||||
raise errors.DeserializationError(
|
||||
'The following field are required: {0}'.format(
|
||||
','.join(missing)))
|
||||
|
||||
@classmethod
|
||||
def fields_from_json(cls, jobj):
|
||||
"""Deserialize fields from JSON."""
|
||||
cls._check_required(jobj)
|
||||
fields = {}
|
||||
for slot, field in six.iteritems(cls._fields):
|
||||
if field.json_name not in jobj and field.omitempty:
|
||||
fields[slot] = field.default
|
||||
else:
|
||||
value = jobj[field.json_name]
|
||||
try:
|
||||
fields[slot] = field.decode(value)
|
||||
except errors.DeserializationError as error:
|
||||
raise errors.DeserializationError(
|
||||
'Could not decode {0!r} ({1!r}): {2}'.format(
|
||||
slot, value, error))
|
||||
return fields
|
||||
|
||||
@classmethod
|
||||
def from_json(cls, jobj):
|
||||
return cls(**cls.fields_from_json(jobj))
|
||||
|
||||
|
||||
def encode_b64jose(data):
|
||||
"""Encode JOSE Base-64 field.
|
||||
|
||||
:param bytes data:
|
||||
:rtype: `unicode`
|
||||
|
||||
"""
|
||||
# b64encode produces ASCII characters only
|
||||
return b64.b64encode(data).decode('ascii')
|
||||
|
||||
def decode_b64jose(data, size=None, minimum=False):
|
||||
"""Decode JOSE Base-64 field.
|
||||
|
||||
:param unicode data:
|
||||
:param int size: Required length (after decoding).
|
||||
:param bool minimum: If ``True``, then `size` will be treated as
|
||||
minimum required length, as opposed to exact equality.
|
||||
|
||||
:rtype: bytes
|
||||
|
||||
"""
|
||||
error_cls = TypeError if six.PY2 else binascii.Error
|
||||
try:
|
||||
decoded = b64.b64decode(data.encode())
|
||||
except error_cls as error:
|
||||
raise errors.DeserializationError(error)
|
||||
|
||||
if size is not None and ((not minimum and len(decoded) != size)
|
||||
or (minimum and len(decoded) < size)):
|
||||
raise errors.DeserializationError()
|
||||
|
||||
return decoded
|
||||
|
||||
def encode_hex16(value):
|
||||
"""Hexlify.
|
||||
|
||||
:param bytes value:
|
||||
:rtype: unicode
|
||||
|
||||
"""
|
||||
return binascii.hexlify(value).decode()
|
||||
|
||||
def decode_hex16(value, size=None, minimum=False):
|
||||
"""Decode hexlified field.
|
||||
|
||||
:param unicode value:
|
||||
:param int size: Required length (after decoding).
|
||||
:param bool minimum: If ``True``, then `size` will be treated as
|
||||
minimum required length, as opposed to exact equality.
|
||||
|
||||
:rtype: bytes
|
||||
|
||||
"""
|
||||
value = value.encode()
|
||||
if size is not None and ((not minimum and len(value) != size * 2)
|
||||
or (minimum and len(value) < size * 2)):
|
||||
raise errors.DeserializationError()
|
||||
error_cls = TypeError if six.PY2 else binascii.Error
|
||||
try:
|
||||
return binascii.unhexlify(value)
|
||||
except error_cls as error:
|
||||
raise errors.DeserializationError(error)
|
||||
|
||||
def encode_cert(cert):
|
||||
"""Encode certificate as JOSE Base-64 DER.
|
||||
|
||||
:type cert: `OpenSSL.crypto.X509` wrapped in `.ComparableX509`
|
||||
:rtype: unicode
|
||||
|
||||
"""
|
||||
return encode_b64jose(OpenSSL.crypto.dump_certificate(
|
||||
OpenSSL.crypto.FILETYPE_ASN1, cert))
|
||||
|
||||
def decode_cert(b64der):
|
||||
"""Decode JOSE Base-64 DER-encoded certificate.
|
||||
|
||||
:param unicode b64der:
|
||||
:rtype: `OpenSSL.crypto.X509` wrapped in `.ComparableX509`
|
||||
|
||||
"""
|
||||
try:
|
||||
return util.ComparableX509(OpenSSL.crypto.load_certificate(
|
||||
OpenSSL.crypto.FILETYPE_ASN1, decode_b64jose(b64der)))
|
||||
except OpenSSL.crypto.Error as error:
|
||||
raise errors.DeserializationError(error)
|
||||
|
||||
def encode_csr(csr):
|
||||
"""Encode CSR as JOSE Base-64 DER.
|
||||
|
||||
:type csr: `OpenSSL.crypto.X509Req` wrapped in `.ComparableX509`
|
||||
:rtype: unicode
|
||||
|
||||
"""
|
||||
return encode_b64jose(OpenSSL.crypto.dump_certificate_request(
|
||||
OpenSSL.crypto.FILETYPE_ASN1, csr))
|
||||
|
||||
def decode_csr(b64der):
|
||||
"""Decode JOSE Base-64 DER-encoded CSR.
|
||||
|
||||
:param unicode b64der:
|
||||
:rtype: `OpenSSL.crypto.X509Req` wrapped in `.ComparableX509`
|
||||
|
||||
"""
|
||||
try:
|
||||
return util.ComparableX509(OpenSSL.crypto.load_certificate_request(
|
||||
OpenSSL.crypto.FILETYPE_ASN1, decode_b64jose(b64der)))
|
||||
except OpenSSL.crypto.Error as error:
|
||||
raise errors.DeserializationError(error)
|
||||
|
||||
|
||||
class TypedJSONObjectWithFields(JSONObjectWithFields):
|
||||
"""JSON object with type."""
|
||||
|
||||
typ = NotImplemented
|
||||
"""Type of the object. Subclasses must override."""
|
||||
|
||||
type_field_name = "type"
|
||||
"""Field name used to distinguish different object types.
|
||||
|
||||
Subclasses will probably have to override this.
|
||||
|
||||
"""
|
||||
|
||||
TYPES = NotImplemented
|
||||
"""Types registered for JSON deserialization"""
|
||||
|
||||
@classmethod
|
||||
def register(cls, type_cls, typ=None):
|
||||
"""Register class for JSON deserialization."""
|
||||
typ = type_cls.typ if typ is None else typ
|
||||
cls.TYPES[typ] = type_cls
|
||||
return type_cls
|
||||
|
||||
@classmethod
|
||||
def get_type_cls(cls, jobj):
|
||||
"""Get the registered class for ``jobj``."""
|
||||
if cls in six.itervalues(cls.TYPES):
|
||||
assert jobj[cls.type_field_name]
|
||||
# cls is already registered type_cls, force to use it
|
||||
# so that, e.g Revocation.from_json(jobj) fails if
|
||||
# jobj["type"] != "revocation".
|
||||
return cls
|
||||
|
||||
if not isinstance(jobj, dict):
|
||||
raise errors.DeserializationError(
|
||||
"{0} is not a dictionary object".format(jobj))
|
||||
try:
|
||||
typ = jobj[cls.type_field_name]
|
||||
except KeyError:
|
||||
raise errors.DeserializationError("missing type field")
|
||||
|
||||
try:
|
||||
return cls.TYPES[typ]
|
||||
except KeyError:
|
||||
raise errors.UnrecognizedTypeError(typ, jobj)
|
||||
|
||||
def to_partial_json(self):
|
||||
"""Get JSON serializable object.
|
||||
|
||||
:returns: Serializable JSON object representing ACME typed object.
|
||||
:meth:`validate` will almost certainly not work, due to reasons
|
||||
explained in :class:`acme.interfaces.IJSONSerializable`.
|
||||
:rtype: dict
|
||||
|
||||
"""
|
||||
jobj = self.fields_to_partial_json()
|
||||
jobj[self.type_field_name] = self.typ
|
||||
return jobj
|
||||
|
||||
@classmethod
|
||||
def from_json(cls, jobj):
|
||||
"""Deserialize ACME object from valid JSON object.
|
||||
|
||||
:raises acme.errors.UnrecognizedTypeError: if type
|
||||
of the ACME object has not been registered.
|
||||
|
||||
"""
|
||||
# make sure subclasses don't cause infinite recursive from_json calls
|
||||
type_cls = cls.get_type_cls(jobj)
|
||||
return type_cls(**type_cls.fields_from_json(jobj))
|
||||
364
acme/acme/jose/json_util_test.py
Normal file
364
acme/acme/jose/json_util_test.py
Normal file
|
|
@ -0,0 +1,364 @@
|
|||
"""Tests for acme.jose.json_util."""
|
||||
import itertools
|
||||
import unittest
|
||||
|
||||
import mock
|
||||
import six
|
||||
|
||||
from acme import test_util
|
||||
|
||||
from acme.jose import errors
|
||||
from acme.jose import interfaces
|
||||
from acme.jose import util
|
||||
|
||||
|
||||
CERT = test_util.load_cert('cert.pem')
|
||||
CSR = test_util.load_csr('csr.pem')
|
||||
|
||||
|
||||
class FieldTest(unittest.TestCase):
|
||||
"""Tests for acme.jose.json_util.Field."""
|
||||
|
||||
def test_no_omit_boolean(self):
|
||||
from acme.jose.json_util import Field
|
||||
for default, omitempty, value in itertools.product(
|
||||
[True, False], [True, False], [True, False]):
|
||||
self.assertFalse(
|
||||
Field("foo", default=default, omitempty=omitempty).omit(value))
|
||||
|
||||
def test_descriptors(self):
|
||||
mock_value = mock.MagicMock()
|
||||
|
||||
# pylint: disable=missing-docstring
|
||||
|
||||
def decoder(unused_value):
|
||||
return 'd'
|
||||
|
||||
def encoder(unused_value):
|
||||
return 'e'
|
||||
|
||||
from acme.jose.json_util import Field
|
||||
field = Field('foo')
|
||||
|
||||
field = field.encoder(encoder)
|
||||
self.assertEqual('e', field.encode(mock_value))
|
||||
|
||||
field = field.decoder(decoder)
|
||||
self.assertEqual('e', field.encode(mock_value))
|
||||
self.assertEqual('d', field.decode(mock_value))
|
||||
|
||||
def test_default_encoder_is_partial(self):
|
||||
class MockField(interfaces.JSONDeSerializable):
|
||||
# pylint: disable=missing-docstring
|
||||
def to_partial_json(self):
|
||||
return 'foo' # pragma: no cover
|
||||
@classmethod
|
||||
def from_json(cls, jobj):
|
||||
pass # pragma: no cover
|
||||
mock_field = MockField()
|
||||
|
||||
from acme.jose.json_util import Field
|
||||
self.assertTrue(Field.default_encoder(mock_field) is mock_field)
|
||||
# in particular...
|
||||
self.assertNotEqual('foo', Field.default_encoder(mock_field))
|
||||
|
||||
def test_default_encoder_passthrough(self):
|
||||
mock_value = mock.MagicMock()
|
||||
from acme.jose.json_util import Field
|
||||
self.assertTrue(Field.default_encoder(mock_value) is mock_value)
|
||||
|
||||
def test_default_decoder_list_to_tuple(self):
|
||||
from acme.jose.json_util import Field
|
||||
self.assertEqual((1, 2, 3), Field.default_decoder([1, 2, 3]))
|
||||
|
||||
def test_default_decoder_dict_to_frozendict(self):
|
||||
from acme.jose.json_util import Field
|
||||
obj = Field.default_decoder({'x': 2})
|
||||
self.assertTrue(isinstance(obj, util.frozendict))
|
||||
self.assertEqual(obj, util.frozendict(x=2))
|
||||
|
||||
def test_default_decoder_passthrough(self):
|
||||
mock_value = mock.MagicMock()
|
||||
from acme.jose.json_util import Field
|
||||
self.assertTrue(Field.default_decoder(mock_value) is mock_value)
|
||||
|
||||
|
||||
class JSONObjectWithFieldsMetaTest(unittest.TestCase):
|
||||
"""Tests for acme.jose.json_util.JSONObjectWithFieldsMeta."""
|
||||
|
||||
def setUp(self):
|
||||
from acme.jose.json_util import Field
|
||||
from acme.jose.json_util import JSONObjectWithFieldsMeta
|
||||
self.field = Field('Baz')
|
||||
self.field2 = Field('Baz2')
|
||||
# pylint: disable=invalid-name,missing-docstring,too-few-public-methods
|
||||
# pylint: disable=blacklisted-name
|
||||
@six.add_metaclass(JSONObjectWithFieldsMeta)
|
||||
class A(object):
|
||||
__slots__ = ('bar',)
|
||||
baz = self.field
|
||||
class B(A):
|
||||
pass
|
||||
class C(A):
|
||||
baz = self.field2
|
||||
self.a_cls = A
|
||||
self.b_cls = B
|
||||
self.c_cls = C
|
||||
|
||||
def test_fields(self):
|
||||
# pylint: disable=protected-access,no-member
|
||||
self.assertEqual({'baz': self.field}, self.a_cls._fields)
|
||||
self.assertEqual({'baz': self.field}, self.b_cls._fields)
|
||||
|
||||
def test_fields_inheritance(self):
|
||||
# pylint: disable=protected-access,no-member
|
||||
self.assertEqual({'baz': self.field2}, self.c_cls._fields)
|
||||
|
||||
def test_slots(self):
|
||||
self.assertEqual(('bar', 'baz'), self.a_cls.__slots__)
|
||||
self.assertEqual(('baz',), self.b_cls.__slots__)
|
||||
|
||||
def test_orig_slots(self):
|
||||
# pylint: disable=protected-access,no-member
|
||||
self.assertEqual(('bar',), self.a_cls._orig_slots)
|
||||
self.assertEqual((), self.b_cls._orig_slots)
|
||||
|
||||
|
||||
class JSONObjectWithFieldsTest(unittest.TestCase):
|
||||
"""Tests for acme.jose.json_util.JSONObjectWithFields."""
|
||||
# pylint: disable=protected-access
|
||||
|
||||
def setUp(self):
|
||||
from acme.jose.json_util import JSONObjectWithFields
|
||||
from acme.jose.json_util import Field
|
||||
|
||||
class MockJSONObjectWithFields(JSONObjectWithFields):
|
||||
# pylint: disable=invalid-name,missing-docstring,no-self-argument
|
||||
# pylint: disable=too-few-public-methods
|
||||
x = Field('x', omitempty=True,
|
||||
encoder=(lambda x: x * 2),
|
||||
decoder=(lambda x: x / 2))
|
||||
y = Field('y')
|
||||
z = Field('Z') # on purpose uppercase
|
||||
|
||||
@y.encoder
|
||||
def y(value):
|
||||
if value == 500:
|
||||
raise errors.SerializationError()
|
||||
return value
|
||||
|
||||
@y.decoder
|
||||
def y(value):
|
||||
if value == 500:
|
||||
raise errors.DeserializationError()
|
||||
return value
|
||||
|
||||
# pylint: disable=invalid-name
|
||||
self.MockJSONObjectWithFields = MockJSONObjectWithFields
|
||||
self.mock = MockJSONObjectWithFields(x=None, y=2, z=3)
|
||||
|
||||
def test_init_defaults(self):
|
||||
self.assertEqual(self.mock, self.MockJSONObjectWithFields(y=2, z=3))
|
||||
|
||||
def test_fields_to_partial_json_omits_empty(self):
|
||||
self.assertEqual(self.mock.fields_to_partial_json(), {'y': 2, 'Z': 3})
|
||||
|
||||
def test_fields_from_json_fills_default_for_empty(self):
|
||||
self.assertEqual(
|
||||
{'x': None, 'y': 2, 'z': 3},
|
||||
self.MockJSONObjectWithFields.fields_from_json({'y': 2, 'Z': 3}))
|
||||
|
||||
def test_fields_from_json_fails_on_missing(self):
|
||||
self.assertRaises(
|
||||
errors.DeserializationError,
|
||||
self.MockJSONObjectWithFields.fields_from_json, {'y': 0})
|
||||
self.assertRaises(
|
||||
errors.DeserializationError,
|
||||
self.MockJSONObjectWithFields.fields_from_json, {'Z': 0})
|
||||
self.assertRaises(
|
||||
errors.DeserializationError,
|
||||
self.MockJSONObjectWithFields.fields_from_json, {'x': 0, 'y': 0})
|
||||
self.assertRaises(
|
||||
errors.DeserializationError,
|
||||
self.MockJSONObjectWithFields.fields_from_json, {'x': 0, 'Z': 0})
|
||||
|
||||
def test_fields_to_partial_json_encoder(self):
|
||||
self.assertEqual(
|
||||
self.MockJSONObjectWithFields(x=1, y=2, z=3).to_partial_json(),
|
||||
{'x': 2, 'y': 2, 'Z': 3})
|
||||
|
||||
def test_fields_from_json_decoder(self):
|
||||
self.assertEqual(
|
||||
{'x': 2, 'y': 2, 'z': 3},
|
||||
self.MockJSONObjectWithFields.fields_from_json(
|
||||
{'x': 4, 'y': 2, 'Z': 3}))
|
||||
|
||||
def test_fields_to_partial_json_error_passthrough(self):
|
||||
self.assertRaises(
|
||||
errors.SerializationError, self.MockJSONObjectWithFields(
|
||||
x=1, y=500, z=3).to_partial_json)
|
||||
|
||||
def test_fields_from_json_error_passthrough(self):
|
||||
self.assertRaises(
|
||||
errors.DeserializationError,
|
||||
self.MockJSONObjectWithFields.from_json,
|
||||
{'x': 4, 'y': 500, 'Z': 3})
|
||||
|
||||
|
||||
class DeEncodersTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.b64_cert = (
|
||||
u'MIIB3jCCAYigAwIBAgICBTkwDQYJKoZIhvcNAQELBQAwdzELMAkGA1UEBhM'
|
||||
u'CVVMxETAPBgNVBAgMCE1pY2hpZ2FuMRIwEAYDVQQHDAlBbm4gQXJib3IxKz'
|
||||
u'ApBgNVBAoMIlVuaXZlcnNpdHkgb2YgTWljaGlnYW4gYW5kIHRoZSBFRkYxF'
|
||||
u'DASBgNVBAMMC2V4YW1wbGUuY29tMB4XDTE0MTIxMTIyMzQ0NVoXDTE0MTIx'
|
||||
u'ODIyMzQ0NVowdzELMAkGA1UEBhMCVVMxETAPBgNVBAgMCE1pY2hpZ2FuMRI'
|
||||
u'wEAYDVQQHDAlBbm4gQXJib3IxKzApBgNVBAoMIlVuaXZlcnNpdHkgb2YgTW'
|
||||
u'ljaGlnYW4gYW5kIHRoZSBFRkYxFDASBgNVBAMMC2V4YW1wbGUuY29tMFwwD'
|
||||
u'QYJKoZIhvcNAQEBBQADSwAwSAJBAKx1c7RR7R_drnBSQ_zfx1vQLHUbFLh1'
|
||||
u'AQQQ5R8DZUXd36efNK79vukFhN9HFoHZiUvOjm0c-pVE6K-EdE_twuUCAwE'
|
||||
u'AATANBgkqhkiG9w0BAQsFAANBAC24z0IdwIVKSlntksllvr6zJepBH5fMnd'
|
||||
u'fk3XJp10jT6VE-14KNtjh02a56GoraAvJAT5_H67E8GvJ_ocNnB_o'
|
||||
)
|
||||
self.b64_csr = (
|
||||
u'MIIBXTCCAQcCAQAweTELMAkGA1UEBhMCVVMxETAPBgNVBAgMCE1pY2hpZ2F'
|
||||
u'uMRIwEAYDVQQHDAlBbm4gQXJib3IxDDAKBgNVBAoMA0VGRjEfMB0GA1UECw'
|
||||
u'wWVW5pdmVyc2l0eSBvZiBNaWNoaWdhbjEUMBIGA1UEAwwLZXhhbXBsZS5jb'
|
||||
u'20wXDANBgkqhkiG9w0BAQEFAANLADBIAkEArHVztFHtH92ucFJD_N_HW9As'
|
||||
u'dRsUuHUBBBDlHwNlRd3fp580rv2-6QWE30cWgdmJS86ObRz6lUTor4R0T-3'
|
||||
u'C5QIDAQABoCkwJwYJKoZIhvcNAQkOMRowGDAWBgNVHREEDzANggtleGFtcG'
|
||||
u'xlLmNvbTANBgkqhkiG9w0BAQsFAANBAHJH_O6BtC9aGzEVCMGOZ7z9iIRHW'
|
||||
u'Szr9x_bOzn7hLwsbXPAgO1QxEwL-X-4g20Gn9XBE1N9W6HCIEut2d8wACg'
|
||||
)
|
||||
|
||||
def test_encode_b64jose(self):
|
||||
from acme.jose.json_util import encode_b64jose
|
||||
encoded = encode_b64jose(b'x')
|
||||
self.assertTrue(isinstance(encoded, six.string_types))
|
||||
self.assertEqual(u'eA', encoded)
|
||||
|
||||
def test_decode_b64jose(self):
|
||||
from acme.jose.json_util import decode_b64jose
|
||||
decoded = decode_b64jose(u'eA')
|
||||
self.assertTrue(isinstance(decoded, six.binary_type))
|
||||
self.assertEqual(b'x', decoded)
|
||||
|
||||
def test_decode_b64jose_padding_error(self):
|
||||
from acme.jose.json_util import decode_b64jose
|
||||
self.assertRaises(errors.DeserializationError, decode_b64jose, u'x')
|
||||
|
||||
def test_decode_b64jose_size(self):
|
||||
from acme.jose.json_util import decode_b64jose
|
||||
self.assertEqual(b'foo', decode_b64jose(u'Zm9v', size=3))
|
||||
self.assertRaises(
|
||||
errors.DeserializationError, decode_b64jose, u'Zm9v', size=2)
|
||||
self.assertRaises(
|
||||
errors.DeserializationError, decode_b64jose, u'Zm9v', size=4)
|
||||
|
||||
def test_decode_b64jose_minimum_size(self):
|
||||
from acme.jose.json_util import decode_b64jose
|
||||
self.assertEqual(b'foo', decode_b64jose(u'Zm9v', size=3, minimum=True))
|
||||
self.assertEqual(b'foo', decode_b64jose(u'Zm9v', size=2, minimum=True))
|
||||
self.assertRaises(errors.DeserializationError, decode_b64jose,
|
||||
u'Zm9v', size=4, minimum=True)
|
||||
|
||||
def test_encode_hex16(self):
|
||||
from acme.jose.json_util import encode_hex16
|
||||
encoded = encode_hex16(b'foo')
|
||||
self.assertEqual(u'666f6f', encoded)
|
||||
self.assertTrue(isinstance(encoded, six.string_types))
|
||||
|
||||
def test_decode_hex16(self):
|
||||
from acme.jose.json_util import decode_hex16
|
||||
decoded = decode_hex16(u'666f6f')
|
||||
self.assertEqual(b'foo', decoded)
|
||||
self.assertTrue(isinstance(decoded, six.binary_type))
|
||||
|
||||
def test_decode_hex16_minimum_size(self):
|
||||
from acme.jose.json_util import decode_hex16
|
||||
self.assertEqual(b'foo', decode_hex16(u'666f6f', size=3, minimum=True))
|
||||
self.assertEqual(b'foo', decode_hex16(u'666f6f', size=2, minimum=True))
|
||||
self.assertRaises(errors.DeserializationError, decode_hex16,
|
||||
u'666f6f', size=4, minimum=True)
|
||||
|
||||
def test_decode_hex16_odd_length(self):
|
||||
from acme.jose.json_util import decode_hex16
|
||||
self.assertRaises(errors.DeserializationError, decode_hex16, u'x')
|
||||
|
||||
def test_encode_cert(self):
|
||||
from acme.jose.json_util import encode_cert
|
||||
self.assertEqual(self.b64_cert, encode_cert(CERT))
|
||||
|
||||
def test_decode_cert(self):
|
||||
from acme.jose.json_util import decode_cert
|
||||
cert = decode_cert(self.b64_cert)
|
||||
self.assertTrue(isinstance(cert, util.ComparableX509))
|
||||
self.assertEqual(cert, CERT)
|
||||
self.assertRaises(errors.DeserializationError, decode_cert, u'')
|
||||
|
||||
def test_encode_csr(self):
|
||||
from acme.jose.json_util import encode_csr
|
||||
self.assertEqual(self.b64_csr, encode_csr(CSR))
|
||||
|
||||
def test_decode_csr(self):
|
||||
from acme.jose.json_util import decode_csr
|
||||
csr = decode_csr(self.b64_csr)
|
||||
self.assertTrue(isinstance(csr, util.ComparableX509))
|
||||
self.assertEqual(csr, CSR)
|
||||
self.assertRaises(errors.DeserializationError, decode_csr, u'')
|
||||
|
||||
|
||||
class TypedJSONObjectWithFieldsTest(unittest.TestCase):
|
||||
|
||||
def setUp(self):
|
||||
from acme.jose.json_util import TypedJSONObjectWithFields
|
||||
|
||||
# pylint: disable=missing-docstring,abstract-method
|
||||
# pylint: disable=too-few-public-methods
|
||||
|
||||
class MockParentTypedJSONObjectWithFields(TypedJSONObjectWithFields):
|
||||
TYPES = {}
|
||||
type_field_name = 'type'
|
||||
|
||||
@MockParentTypedJSONObjectWithFields.register
|
||||
class MockTypedJSONObjectWithFields(
|
||||
MockParentTypedJSONObjectWithFields):
|
||||
typ = 'test'
|
||||
__slots__ = ('foo',)
|
||||
|
||||
@classmethod
|
||||
def fields_from_json(cls, jobj):
|
||||
return {'foo': jobj['foo']}
|
||||
|
||||
def fields_to_partial_json(self):
|
||||
return {'foo': self.foo}
|
||||
|
||||
self.parent_cls = MockParentTypedJSONObjectWithFields
|
||||
self.msg = MockTypedJSONObjectWithFields(foo='bar')
|
||||
|
||||
def test_to_partial_json(self):
|
||||
self.assertEqual(self.msg.to_partial_json(), {
|
||||
'type': 'test',
|
||||
'foo': 'bar',
|
||||
})
|
||||
|
||||
def test_from_json_non_dict_fails(self):
|
||||
for value in [[], (), 5, "asd"]: # all possible input types
|
||||
self.assertRaises(
|
||||
errors.DeserializationError, self.parent_cls.from_json, value)
|
||||
|
||||
def test_from_json_dict_no_type_fails(self):
|
||||
self.assertRaises(
|
||||
errors.DeserializationError, self.parent_cls.from_json, {})
|
||||
|
||||
def test_from_json_unknown_type_fails(self):
|
||||
self.assertRaises(errors.UnrecognizedTypeError,
|
||||
self.parent_cls.from_json, {'type': 'bar'})
|
||||
|
||||
def test_from_json_returns_obj(self):
|
||||
self.assertEqual({'foo': 'bar'}, self.parent_cls.from_json(
|
||||
{'type': 'test', 'foo': 'bar'}))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main() # pragma: no cover
|
||||
180
acme/acme/jose/jwa.py
Normal file
180
acme/acme/jose/jwa.py
Normal file
|
|
@ -0,0 +1,180 @@
|
|||
"""JSON Web Algorithm.
|
||||
|
||||
https://tools.ietf.org/html/draft-ietf-jose-json-web-algorithms-40
|
||||
|
||||
"""
|
||||
import abc
|
||||
import collections
|
||||
import logging
|
||||
|
||||
import cryptography.exceptions
|
||||
from cryptography.hazmat.backends import default_backend
|
||||
from cryptography.hazmat.primitives import hashes
|
||||
from cryptography.hazmat.primitives import hmac
|
||||
from cryptography.hazmat.primitives.asymmetric import padding
|
||||
|
||||
from acme.jose import errors
|
||||
from acme.jose import interfaces
|
||||
from acme.jose import jwk
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class JWA(interfaces.JSONDeSerializable): # pylint: disable=abstract-method
|
||||
# pylint: disable=too-few-public-methods
|
||||
# for some reason disable=abstract-method has to be on the line
|
||||
# above...
|
||||
"""JSON Web Algorithm."""
|
||||
|
||||
|
||||
class JWASignature(JWA, collections.Hashable):
|
||||
"""JSON Web Signature Algorithm."""
|
||||
SIGNATURES = {}
|
||||
|
||||
def __init__(self, name):
|
||||
self.name = name
|
||||
|
||||
def __eq__(self, other):
|
||||
if not isinstance(other, JWASignature):
|
||||
return NotImplemented
|
||||
return self.name == other.name
|
||||
|
||||
def __hash__(self):
|
||||
return hash((self.__class__, self.name))
|
||||
|
||||
def __ne__(self, other):
|
||||
return not self == other
|
||||
|
||||
@classmethod
|
||||
def register(cls, signature_cls):
|
||||
"""Register class for JSON deserialization."""
|
||||
cls.SIGNATURES[signature_cls.name] = signature_cls
|
||||
return signature_cls
|
||||
|
||||
def to_partial_json(self):
|
||||
return self.name
|
||||
|
||||
@classmethod
|
||||
def from_json(cls, jobj):
|
||||
return cls.SIGNATURES[jobj]
|
||||
|
||||
@abc.abstractmethod
|
||||
def sign(self, key, msg): # pragma: no cover
|
||||
"""Sign the ``msg`` using ``key``."""
|
||||
raise NotImplementedError()
|
||||
|
||||
@abc.abstractmethod
|
||||
def verify(self, key, msg, sig): # pragma: no cover
|
||||
"""Verify the ``msg` and ``sig`` using ``key``."""
|
||||
raise NotImplementedError()
|
||||
|
||||
def __repr__(self):
|
||||
return self.name
|
||||
|
||||
|
||||
class _JWAHS(JWASignature):
|
||||
|
||||
kty = jwk.JWKOct
|
||||
|
||||
def __init__(self, name, hash_):
|
||||
super(_JWAHS, self).__init__(name)
|
||||
self.hash = hash_()
|
||||
|
||||
def sign(self, key, msg):
|
||||
signer = hmac.HMAC(key, self.hash, backend=default_backend())
|
||||
signer.update(msg)
|
||||
return signer.finalize()
|
||||
|
||||
def verify(self, key, msg, sig):
|
||||
verifier = hmac.HMAC(key, self.hash, backend=default_backend())
|
||||
verifier.update(msg)
|
||||
try:
|
||||
verifier.verify(sig)
|
||||
except cryptography.exceptions.InvalidSignature as error:
|
||||
logger.debug(error, exc_info=True)
|
||||
return False
|
||||
else:
|
||||
return True
|
||||
|
||||
|
||||
class _JWARSA(object):
|
||||
|
||||
kty = jwk.JWKRSA
|
||||
padding = NotImplemented
|
||||
hash = NotImplemented
|
||||
|
||||
def sign(self, key, msg):
|
||||
"""Sign the ``msg`` using ``key``."""
|
||||
try:
|
||||
signer = key.signer(self.padding, self.hash)
|
||||
except AttributeError as error:
|
||||
logger.debug(error, exc_info=True)
|
||||
raise errors.Error("Public key cannot be used for signing")
|
||||
except ValueError as error: # digest too large
|
||||
logger.debug(error, exc_info=True)
|
||||
raise errors.Error(str(error))
|
||||
signer.update(msg)
|
||||
try:
|
||||
return signer.finalize()
|
||||
except ValueError as error:
|
||||
logger.debug(error, exc_info=True)
|
||||
raise errors.Error(str(error))
|
||||
|
||||
def verify(self, key, msg, sig):
|
||||
"""Verify the ``msg` and ``sig`` using ``key``."""
|
||||
verifier = key.verifier(sig, self.padding, self.hash)
|
||||
verifier.update(msg)
|
||||
try:
|
||||
verifier.verify()
|
||||
except cryptography.exceptions.InvalidSignature as error:
|
||||
logger.debug(error, exc_info=True)
|
||||
return False
|
||||
else:
|
||||
return True
|
||||
|
||||
|
||||
class _JWARS(_JWARSA, JWASignature):
|
||||
|
||||
def __init__(self, name, hash_):
|
||||
super(_JWARS, self).__init__(name)
|
||||
self.padding = padding.PKCS1v15()
|
||||
self.hash = hash_()
|
||||
|
||||
|
||||
class _JWAPS(_JWARSA, JWASignature):
|
||||
|
||||
def __init__(self, name, hash_):
|
||||
super(_JWAPS, self).__init__(name)
|
||||
self.padding = padding.PSS(
|
||||
mgf=padding.MGF1(hash_()),
|
||||
salt_length=padding.PSS.MAX_LENGTH)
|
||||
self.hash = hash_()
|
||||
|
||||
|
||||
class _JWAES(JWASignature): # pylint: disable=abstract-class-not-used
|
||||
|
||||
# TODO: implement ES signatures
|
||||
|
||||
def sign(self, key, msg): # pragma: no cover
|
||||
raise NotImplementedError()
|
||||
|
||||
def verify(self, key, msg, sig): # pragma: no cover
|
||||
raise NotImplementedError()
|
||||
|
||||
|
||||
HS256 = JWASignature.register(_JWAHS('HS256', hashes.SHA256))
|
||||
HS384 = JWASignature.register(_JWAHS('HS384', hashes.SHA384))
|
||||
HS512 = JWASignature.register(_JWAHS('HS512', hashes.SHA512))
|
||||
|
||||
RS256 = JWASignature.register(_JWARS('RS256', hashes.SHA256))
|
||||
RS384 = JWASignature.register(_JWARS('RS384', hashes.SHA384))
|
||||
RS512 = JWASignature.register(_JWARS('RS512', hashes.SHA512))
|
||||
|
||||
PS256 = JWASignature.register(_JWAPS('PS256', hashes.SHA256))
|
||||
PS384 = JWASignature.register(_JWAPS('PS384', hashes.SHA384))
|
||||
PS512 = JWASignature.register(_JWAPS('PS512', hashes.SHA512))
|
||||
|
||||
ES256 = JWASignature.register(_JWAES('ES256'))
|
||||
ES256 = JWASignature.register(_JWAES('ES384'))
|
||||
ES256 = JWASignature.register(_JWAES('ES512'))
|
||||
104
acme/acme/jose/jwa_test.py
Normal file
104
acme/acme/jose/jwa_test.py
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
"""Tests for acme.jose.jwa."""
|
||||
import unittest
|
||||
|
||||
from acme import test_util
|
||||
|
||||
from acme.jose import errors
|
||||
|
||||
|
||||
RSA256_KEY = test_util.load_rsa_private_key('rsa256_key.pem')
|
||||
RSA512_KEY = test_util.load_rsa_private_key('rsa512_key.pem')
|
||||
RSA1024_KEY = test_util.load_rsa_private_key('rsa1024_key.pem')
|
||||
|
||||
|
||||
class JWASignatureTest(unittest.TestCase):
|
||||
"""Tests for acme.jose.jwa.JWASignature."""
|
||||
|
||||
def setUp(self):
|
||||
from acme.jose.jwa import JWASignature
|
||||
|
||||
class MockSig(JWASignature):
|
||||
# pylint: disable=missing-docstring,too-few-public-methods
|
||||
# pylint: disable=abstract-class-not-used
|
||||
def sign(self, key, msg):
|
||||
raise NotImplementedError() # pragma: no cover
|
||||
|
||||
def verify(self, key, msg, sig):
|
||||
raise NotImplementedError() # pragma: no cover
|
||||
|
||||
# pylint: disable=invalid-name
|
||||
self.Sig1 = MockSig('Sig1')
|
||||
self.Sig2 = MockSig('Sig2')
|
||||
|
||||
def test_eq(self):
|
||||
self.assertEqual(self.Sig1, self.Sig1)
|
||||
|
||||
def test_ne(self):
|
||||
self.assertNotEqual(self.Sig1, self.Sig2)
|
||||
|
||||
def test_ne_other_type(self):
|
||||
self.assertNotEqual(self.Sig1, 5)
|
||||
|
||||
def test_repr(self):
|
||||
self.assertEqual('Sig1', repr(self.Sig1))
|
||||
self.assertEqual('Sig2', repr(self.Sig2))
|
||||
|
||||
def test_to_partial_json(self):
|
||||
self.assertEqual(self.Sig1.to_partial_json(), 'Sig1')
|
||||
self.assertEqual(self.Sig2.to_partial_json(), 'Sig2')
|
||||
|
||||
def test_from_json(self):
|
||||
from acme.jose.jwa import JWASignature
|
||||
from acme.jose.jwa import RS256
|
||||
self.assertTrue(JWASignature.from_json('RS256') is RS256)
|
||||
|
||||
|
||||
class JWAHSTest(unittest.TestCase): # pylint: disable=too-few-public-methods
|
||||
|
||||
def test_it(self):
|
||||
from acme.jose.jwa import HS256
|
||||
sig = (
|
||||
b"\xceR\xea\xcd\x94\xab\xcf\xfb\xe0\xacA.:\x1a'\x08i\xe2\xc4"
|
||||
b"\r\x85+\x0e\x85\xaeUZ\xd4\xb3\x97zO"
|
||||
)
|
||||
self.assertEqual(HS256.sign(b'some key', b'foo'), sig)
|
||||
self.assertTrue(HS256.verify(b'some key', b'foo', sig) is True)
|
||||
self.assertTrue(HS256.verify(b'some key', b'foo', sig + b'!') is False)
|
||||
|
||||
|
||||
class JWARSTest(unittest.TestCase):
|
||||
|
||||
def test_sign_no_private_part(self):
|
||||
from acme.jose.jwa import RS256
|
||||
self.assertRaises(
|
||||
errors.Error, RS256.sign, RSA512_KEY.public_key(), b'foo')
|
||||
|
||||
def test_sign_key_too_small(self):
|
||||
from acme.jose.jwa import RS256
|
||||
from acme.jose.jwa import PS256
|
||||
self.assertRaises(errors.Error, RS256.sign, RSA256_KEY, b'foo')
|
||||
self.assertRaises(errors.Error, PS256.sign, RSA256_KEY, b'foo')
|
||||
|
||||
def test_rs(self):
|
||||
from acme.jose.jwa import RS256
|
||||
sig = (
|
||||
b'|\xc6\xb2\xa4\xab(\x87\x99\xfa*:\xea\xf8\xa0N&}\x9f\x0f\xc0O'
|
||||
b'\xc6t\xa3\xe6\xfa\xbb"\x15Y\x80Y\xe0\x81\xb8\x88)\xba\x0c\x9c'
|
||||
b'\xa4\x99\x1e\x19&\xd8\xc7\x99S\x97\xfc\x85\x0cOV\xe6\x07\x99'
|
||||
b'\xd2\xb9.>}\xfd'
|
||||
)
|
||||
self.assertEqual(RS256.sign(RSA512_KEY, b'foo'), sig)
|
||||
self.assertTrue(RS256.verify(RSA512_KEY.public_key(), b'foo', sig))
|
||||
self.assertFalse(RS256.verify(
|
||||
RSA512_KEY.public_key(), b'foo', sig + b'!'))
|
||||
|
||||
def test_ps(self):
|
||||
from acme.jose.jwa import PS256
|
||||
sig = PS256.sign(RSA1024_KEY, b'foo')
|
||||
self.assertTrue(PS256.verify(RSA1024_KEY.public_key(), b'foo', sig))
|
||||
self.assertFalse(PS256.verify(
|
||||
RSA1024_KEY.public_key(), b'foo', sig + b'!'))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main() # pragma: no cover
|
||||
248
acme/acme/jose/jwk.py
Normal file
248
acme/acme/jose/jwk.py
Normal file
|
|
@ -0,0 +1,248 @@
|
|||
"""JSON Web Key."""
|
||||
import abc
|
||||
import binascii
|
||||
import logging
|
||||
|
||||
import cryptography.exceptions
|
||||
from cryptography.hazmat.backends import default_backend
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
from cryptography.hazmat.primitives.asymmetric import ec
|
||||
from cryptography.hazmat.primitives.asymmetric import rsa
|
||||
|
||||
import six
|
||||
|
||||
from acme.jose import errors
|
||||
from acme.jose import json_util
|
||||
from acme.jose import util
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class JWK(json_util.TypedJSONObjectWithFields):
|
||||
# pylint: disable=too-few-public-methods
|
||||
"""JSON Web Key."""
|
||||
type_field_name = 'kty'
|
||||
TYPES = {}
|
||||
cryptography_key_types = ()
|
||||
"""Subclasses should override."""
|
||||
|
||||
@abc.abstractmethod
|
||||
def public_key(self): # pragma: no cover
|
||||
"""Generate JWK with public key.
|
||||
|
||||
For symmetric cryptosystems, this would return ``self``.
|
||||
|
||||
"""
|
||||
raise NotImplementedError()
|
||||
|
||||
@classmethod
|
||||
def _load_cryptography_key(cls, data, password=None, backend=None):
|
||||
backend = default_backend() if backend is None else backend
|
||||
exceptions = {}
|
||||
|
||||
# private key?
|
||||
for loader in (serialization.load_pem_private_key,
|
||||
serialization.load_der_private_key):
|
||||
try:
|
||||
return loader(data, password, backend)
|
||||
except (ValueError, TypeError,
|
||||
cryptography.exceptions.UnsupportedAlgorithm) as error:
|
||||
exceptions[loader] = error
|
||||
|
||||
# public key?
|
||||
for loader in (serialization.load_pem_public_key,
|
||||
serialization.load_der_public_key):
|
||||
try:
|
||||
return loader(data, backend)
|
||||
except (ValueError,
|
||||
cryptography.exceptions.UnsupportedAlgorithm) as error:
|
||||
exceptions[loader] = error
|
||||
|
||||
# no luck
|
||||
raise errors.Error("Unable to deserialize key: {0}".format(exceptions))
|
||||
|
||||
@classmethod
|
||||
def load(cls, data, password=None, backend=None):
|
||||
"""Load serialized key as JWK.
|
||||
|
||||
:param str data: Public or private key serialized as PEM or DER.
|
||||
:param str password: Optional password.
|
||||
:param backend: A `.PEMSerializationBackend` and
|
||||
`.DERSerializationBackend` provider.
|
||||
|
||||
:raises errors.Error: if unable to deserialize, or unsupported
|
||||
JWK algorithm
|
||||
|
||||
:returns: JWK of an appropriate type.
|
||||
:rtype: `JWK`
|
||||
|
||||
"""
|
||||
try:
|
||||
key = cls._load_cryptography_key(data, password, backend)
|
||||
except errors.Error as error:
|
||||
logger.debug("Loading symmetric key, assymentric failed: %s", error)
|
||||
return JWKOct(key=data)
|
||||
|
||||
if cls.typ is not NotImplemented and not isinstance(
|
||||
key, cls.cryptography_key_types):
|
||||
raise errors.Error("Unable to deserialize {0} into {1}".format(
|
||||
key.__class__, cls.__class__))
|
||||
for jwk_cls in six.itervalues(cls.TYPES):
|
||||
if isinstance(key, jwk_cls.cryptography_key_types):
|
||||
return jwk_cls(key=key)
|
||||
raise errors.Error("Unsupported algorithm: {0}".format(key.__class__))
|
||||
|
||||
|
||||
@JWK.register
|
||||
class JWKES(JWK): # pragma: no cover
|
||||
# pylint: disable=abstract-class-not-used
|
||||
"""ES JWK.
|
||||
|
||||
.. warning:: This is not yet implemented!
|
||||
|
||||
"""
|
||||
typ = 'ES'
|
||||
cryptography_key_types = (
|
||||
ec.EllipticCurvePublicKey, ec.EllipticCurvePrivateKey)
|
||||
|
||||
def fields_to_partial_json(self):
|
||||
raise NotImplementedError()
|
||||
|
||||
@classmethod
|
||||
def fields_from_json(cls, jobj):
|
||||
raise NotImplementedError()
|
||||
|
||||
def public_key(self):
|
||||
raise NotImplementedError()
|
||||
|
||||
|
||||
@JWK.register
|
||||
class JWKOct(JWK):
|
||||
"""Symmetric JWK."""
|
||||
typ = 'oct'
|
||||
__slots__ = ('key',)
|
||||
|
||||
def fields_to_partial_json(self):
|
||||
# TODO: An "alg" member SHOULD also be present to identify the
|
||||
# algorithm intended to be used with the key, unless the
|
||||
# application uses another means or convention to determine
|
||||
# the algorithm used.
|
||||
return {'k': json_util.encode_b64jose(self.key)}
|
||||
|
||||
@classmethod
|
||||
def fields_from_json(cls, jobj):
|
||||
return cls(key=json_util.decode_b64jose(jobj['k']))
|
||||
|
||||
def public_key(self):
|
||||
return self
|
||||
|
||||
|
||||
@JWK.register
|
||||
class JWKRSA(JWK):
|
||||
"""RSA JWK.
|
||||
|
||||
:ivar key: `cryptography.hazmat.primitives.rsa.RSAPrivateKey`
|
||||
or `cryptography.hazmat.primitives.rsa.RSAPublicKey` wrapped
|
||||
in `.ComparableRSAKey`
|
||||
|
||||
"""
|
||||
typ = 'RSA'
|
||||
cryptography_key_types = (rsa.RSAPublicKey, rsa.RSAPrivateKey)
|
||||
__slots__ = ('key',)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
if 'key' in kwargs and not isinstance(
|
||||
kwargs['key'], util.ComparableRSAKey):
|
||||
kwargs['key'] = util.ComparableRSAKey(kwargs['key'])
|
||||
super(JWKRSA, self).__init__(*args, **kwargs)
|
||||
|
||||
@classmethod
|
||||
def _encode_param(cls, data):
|
||||
"""Encode Base64urlUInt.
|
||||
|
||||
:type data: long
|
||||
:rtype: unicode
|
||||
|
||||
"""
|
||||
def _leading_zeros(arg):
|
||||
if len(arg) % 2:
|
||||
return '0' + arg
|
||||
return arg
|
||||
|
||||
return json_util.encode_b64jose(binascii.unhexlify(
|
||||
_leading_zeros(hex(data)[2:].rstrip('L'))))
|
||||
|
||||
@classmethod
|
||||
def _decode_param(cls, data):
|
||||
"""Decode Base64urlUInt."""
|
||||
try:
|
||||
return int(binascii.hexlify(json_util.decode_b64jose(data)), 16)
|
||||
except ValueError: # invalid literal for long() with base 16
|
||||
raise errors.DeserializationError()
|
||||
|
||||
def public_key(self):
|
||||
return type(self)(key=self.key.public_key())
|
||||
|
||||
@classmethod
|
||||
def fields_from_json(cls, jobj):
|
||||
# pylint: disable=invalid-name
|
||||
n, e = (cls._decode_param(jobj[x]) for x in ('n', 'e'))
|
||||
public_numbers = rsa.RSAPublicNumbers(e=e, n=n)
|
||||
if 'd' not in jobj: # public key
|
||||
key = public_numbers.public_key(default_backend())
|
||||
else: # private key
|
||||
d = cls._decode_param(jobj['d'])
|
||||
if ('p' in jobj or 'q' in jobj or 'dp' in jobj or
|
||||
'dq' in jobj or 'qi' in jobj or 'oth' in jobj):
|
||||
# "If the producer includes any of the other private
|
||||
# key parameters, then all of the others MUST be
|
||||
# present, with the exception of "oth", which MUST
|
||||
# only be present when more than two prime factors
|
||||
# were used."
|
||||
p, q, dp, dq, qi, = all_params = tuple(
|
||||
jobj.get(x) for x in ('p', 'q', 'dp', 'dq', 'qi'))
|
||||
if tuple(param for param in all_params if param is None):
|
||||
raise errors.Error(
|
||||
"Some private parameters are missing: {0}".format(
|
||||
all_params))
|
||||
p, q, dp, dq, qi = tuple(
|
||||
cls._decode_param(x) for x in all_params)
|
||||
|
||||
# TODO: check for oth
|
||||
else:
|
||||
# cryptography>=0.8
|
||||
p, q = rsa.rsa_recover_prime_factors(n, e, d)
|
||||
dp = rsa.rsa_crt_dmp1(d, p)
|
||||
dq = rsa.rsa_crt_dmq1(d, q)
|
||||
qi = rsa.rsa_crt_iqmp(p, q)
|
||||
|
||||
key = rsa.RSAPrivateNumbers(
|
||||
p, q, d, dp, dq, qi, public_numbers).private_key(
|
||||
default_backend())
|
||||
|
||||
return cls(key=key)
|
||||
|
||||
def fields_to_partial_json(self):
|
||||
# pylint: disable=protected-access
|
||||
if isinstance(self.key._wrapped, rsa.RSAPublicKey):
|
||||
numbers = self.key.public_numbers()
|
||||
params = {
|
||||
'n': numbers.n,
|
||||
'e': numbers.e,
|
||||
}
|
||||
else: # rsa.RSAPrivateKey
|
||||
private = self.key.private_numbers()
|
||||
public = self.key.public_key().public_numbers()
|
||||
params = {
|
||||
'n': public.n,
|
||||
'e': public.e,
|
||||
'd': private.d,
|
||||
'p': private.p,
|
||||
'q': private.q,
|
||||
'dp': private.dmp1,
|
||||
'dq': private.dmq1,
|
||||
'qi': private.iqmp,
|
||||
}
|
||||
return dict((key, self._encode_param(value))
|
||||
for key, value in six.iteritems(params))
|
||||
154
acme/acme/jose/jwk_test.py
Normal file
154
acme/acme/jose/jwk_test.py
Normal file
|
|
@ -0,0 +1,154 @@
|
|||
"""Tests for acme.jose.jwk."""
|
||||
import unittest
|
||||
|
||||
from acme import test_util
|
||||
|
||||
from acme.jose import errors
|
||||
from acme.jose import json_util
|
||||
from acme.jose import util
|
||||
|
||||
|
||||
DSA_PEM = test_util.load_vector('dsa512_key.pem')
|
||||
RSA256_KEY = test_util.load_rsa_private_key('rsa256_key.pem')
|
||||
RSA512_KEY = test_util.load_rsa_private_key('rsa512_key.pem')
|
||||
|
||||
|
||||
class JWKTest(unittest.TestCase):
|
||||
"""Tests for acme.jose.jwk.JWK."""
|
||||
|
||||
def test_load(self):
|
||||
from acme.jose.jwk import JWK
|
||||
self.assertRaises(errors.Error, JWK.load, DSA_PEM)
|
||||
|
||||
def test_load_subclass_wrong_type(self):
|
||||
from acme.jose.jwk import JWKRSA
|
||||
self.assertRaises(errors.Error, JWKRSA.load, DSA_PEM)
|
||||
|
||||
|
||||
class JWKOctTest(unittest.TestCase):
|
||||
"""Tests for acme.jose.jwk.JWKOct."""
|
||||
|
||||
def setUp(self):
|
||||
from acme.jose.jwk import JWKOct
|
||||
self.jwk = JWKOct(key=b'foo')
|
||||
self.jobj = {'kty': 'oct', 'k': json_util.encode_b64jose(b'foo')}
|
||||
|
||||
def test_to_partial_json(self):
|
||||
self.assertEqual(self.jwk.to_partial_json(), self.jobj)
|
||||
|
||||
def test_from_json(self):
|
||||
from acme.jose.jwk import JWKOct
|
||||
self.assertEqual(self.jwk, JWKOct.from_json(self.jobj))
|
||||
|
||||
def test_from_json_hashable(self):
|
||||
from acme.jose.jwk import JWKOct
|
||||
hash(JWKOct.from_json(self.jobj))
|
||||
|
||||
def test_load(self):
|
||||
from acme.jose.jwk import JWKOct
|
||||
self.assertEqual(self.jwk, JWKOct.load(b'foo'))
|
||||
|
||||
def test_public_key(self):
|
||||
self.assertTrue(self.jwk.public_key() is self.jwk)
|
||||
|
||||
|
||||
class JWKRSATest(unittest.TestCase):
|
||||
"""Tests for acme.jose.jwk.JWKRSA."""
|
||||
# pylint: disable=too-many-instance-attributes
|
||||
|
||||
def setUp(self):
|
||||
from acme.jose.jwk import JWKRSA
|
||||
self.jwk256 = JWKRSA(key=RSA256_KEY.public_key())
|
||||
self.jwk256json = {
|
||||
'kty': 'RSA',
|
||||
'e': 'AQAB',
|
||||
'n': 'm2Fylv-Uz7trgTW8EBHP3FQSMeZs2GNQ6VRo1sIVJEk',
|
||||
}
|
||||
# pylint: disable=protected-access
|
||||
self.jwk256_not_comparable = JWKRSA(
|
||||
key=RSA256_KEY.public_key()._wrapped)
|
||||
self.jwk512 = JWKRSA(key=RSA512_KEY.public_key())
|
||||
self.jwk512json = {
|
||||
'kty': 'RSA',
|
||||
'e': 'AQAB',
|
||||
'n': 'rHVztFHtH92ucFJD_N_HW9AsdRsUuHUBBBDlHwNlRd3fp5'
|
||||
'80rv2-6QWE30cWgdmJS86ObRz6lUTor4R0T-3C5Q',
|
||||
}
|
||||
self.private = JWKRSA(key=RSA256_KEY)
|
||||
self.private_json_small = self.jwk256json.copy()
|
||||
self.private_json_small['d'] = (
|
||||
'lPQED_EPTV0UIBfNI3KP2d9Jlrc2mrMllmf946bu-CE')
|
||||
self.private_json = self.jwk256json.copy()
|
||||
self.private_json.update({
|
||||
'd': 'lPQED_EPTV0UIBfNI3KP2d9Jlrc2mrMllmf946bu-CE',
|
||||
'p': 'zUVNZn4lLLBD1R6NE8TKNQ',
|
||||
'q': 'wcfKfc7kl5jfqXArCRSURQ',
|
||||
'dp': 'CWJFq43QvT5Bm5iN8n1okQ',
|
||||
'dq': 'bHh2u7etM8LKKCF2pY2UdQ',
|
||||
'qi': 'oi45cEkbVoJjAbnQpFY87Q',
|
||||
})
|
||||
|
||||
def test_init_auto_comparable(self):
|
||||
self.assertTrue(isinstance(
|
||||
self.jwk256_not_comparable.key, util.ComparableRSAKey))
|
||||
self.assertEqual(self.jwk256, self.jwk256_not_comparable)
|
||||
|
||||
def test_encode_param_zero(self):
|
||||
from acme.jose.jwk import JWKRSA
|
||||
# pylint: disable=protected-access
|
||||
# TODO: move encode/decode _param to separate class
|
||||
self.assertEqual('AA', JWKRSA._encode_param(0))
|
||||
|
||||
def test_equals(self):
|
||||
self.assertEqual(self.jwk256, self.jwk256)
|
||||
self.assertEqual(self.jwk512, self.jwk512)
|
||||
|
||||
def test_not_equals(self):
|
||||
self.assertNotEqual(self.jwk256, self.jwk512)
|
||||
self.assertNotEqual(self.jwk512, self.jwk256)
|
||||
|
||||
def test_load(self):
|
||||
from acme.jose.jwk import JWKRSA
|
||||
self.assertEqual(self.private, JWKRSA.load(
|
||||
test_util.load_vector('rsa256_key.pem')))
|
||||
|
||||
def test_public_key(self):
|
||||
self.assertEqual(self.jwk256, self.private.public_key())
|
||||
|
||||
def test_to_partial_json(self):
|
||||
self.assertEqual(self.jwk256.to_partial_json(), self.jwk256json)
|
||||
self.assertEqual(self.jwk512.to_partial_json(), self.jwk512json)
|
||||
self.assertEqual(self.private.to_partial_json(), self.private_json)
|
||||
|
||||
def test_from_json(self):
|
||||
from acme.jose.jwk import JWK
|
||||
self.assertEqual(
|
||||
self.jwk256, JWK.from_json(self.jwk256json))
|
||||
self.assertEqual(
|
||||
self.jwk512, JWK.from_json(self.jwk512json))
|
||||
self.assertEqual(self.private, JWK.from_json(self.private_json))
|
||||
|
||||
def test_from_json_private_small(self):
|
||||
from acme.jose.jwk import JWK
|
||||
self.assertEqual(self.private, JWK.from_json(self.private_json_small))
|
||||
|
||||
def test_from_json_missing_one_additional(self):
|
||||
from acme.jose.jwk import JWK
|
||||
del self.private_json['q']
|
||||
self.assertRaises(errors.Error, JWK.from_json, self.private_json)
|
||||
|
||||
def test_from_json_hashable(self):
|
||||
from acme.jose.jwk import JWK
|
||||
hash(JWK.from_json(self.jwk256json))
|
||||
|
||||
def test_from_json_non_schema_errors(self):
|
||||
# valid against schema, but still failing
|
||||
from acme.jose.jwk import JWK
|
||||
self.assertRaises(errors.DeserializationError, JWK.from_json,
|
||||
{'kty': 'RSA', 'e': 'AQAB', 'n': ''})
|
||||
self.assertRaises(errors.DeserializationError, JWK.from_json,
|
||||
{'kty': 'RSA', 'e': 'AQAB', 'n': '1'})
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main() # pragma: no cover
|
||||
433
acme/acme/jose/jws.py
Normal file
433
acme/acme/jose/jws.py
Normal file
|
|
@ -0,0 +1,433 @@
|
|||
"""JOSE Web Signature."""
|
||||
import argparse
|
||||
import base64
|
||||
import sys
|
||||
|
||||
import OpenSSL
|
||||
import six
|
||||
|
||||
from acme.jose import b64
|
||||
from acme.jose import errors
|
||||
from acme.jose import json_util
|
||||
from acme.jose import jwa
|
||||
from acme.jose import jwk
|
||||
from acme.jose import util
|
||||
|
||||
|
||||
class MediaType(object):
|
||||
"""MediaType field encoder/decoder."""
|
||||
|
||||
PREFIX = 'application/'
|
||||
"""MIME Media Type and Content Type prefix."""
|
||||
|
||||
@classmethod
|
||||
def decode(cls, value):
|
||||
"""Decoder."""
|
||||
# 4.1.10
|
||||
if '/' not in value:
|
||||
if ';' in value:
|
||||
raise errors.DeserializationError('Unexpected semi-colon')
|
||||
return cls.PREFIX + value
|
||||
return value
|
||||
|
||||
@classmethod
|
||||
def encode(cls, value):
|
||||
"""Encoder."""
|
||||
# 4.1.10
|
||||
if ';' not in value:
|
||||
assert value.startswith(cls.PREFIX)
|
||||
return value[len(cls.PREFIX):]
|
||||
return value
|
||||
|
||||
|
||||
class Header(json_util.JSONObjectWithFields):
|
||||
"""JOSE Header.
|
||||
|
||||
.. warning:: This class supports **only** Registered Header
|
||||
Parameter Names (as defined in section 4.1 of the
|
||||
protocol). If you need Public Header Parameter Names (4.2)
|
||||
or Private Header Parameter Names (4.3), you must subclass
|
||||
and override :meth:`from_json` and :meth:`to_partial_json`
|
||||
appropriately.
|
||||
|
||||
.. warning:: This class does not support any extensions through
|
||||
the "crit" (Critical) Header Parameter (4.1.11) and as a
|
||||
conforming implementation, :meth:`from_json` treats its
|
||||
occurence as an error. Please subclass if you seek for
|
||||
a diferent behaviour.
|
||||
|
||||
:ivar x5tS256: "x5t#S256"
|
||||
:ivar str typ: MIME Media Type, inc. :const:`MediaType.PREFIX`.
|
||||
:ivar str cty: Content-Type, inc. :const:`MediaType.PREFIX`.
|
||||
|
||||
"""
|
||||
alg = json_util.Field(
|
||||
'alg', decoder=jwa.JWASignature.from_json, omitempty=True)
|
||||
jku = json_util.Field('jku', omitempty=True)
|
||||
jwk = json_util.Field('jwk', decoder=jwk.JWK.from_json, omitempty=True)
|
||||
kid = json_util.Field('kid', omitempty=True)
|
||||
x5u = json_util.Field('x5u', omitempty=True)
|
||||
x5c = json_util.Field('x5c', omitempty=True, default=())
|
||||
x5t = json_util.Field(
|
||||
'x5t', decoder=json_util.decode_b64jose, omitempty=True)
|
||||
x5tS256 = json_util.Field(
|
||||
'x5t#S256', decoder=json_util.decode_b64jose, omitempty=True)
|
||||
typ = json_util.Field('typ', encoder=MediaType.encode,
|
||||
decoder=MediaType.decode, omitempty=True)
|
||||
cty = json_util.Field('cty', encoder=MediaType.encode,
|
||||
decoder=MediaType.decode, omitempty=True)
|
||||
crit = json_util.Field('crit', omitempty=True, default=())
|
||||
|
||||
def not_omitted(self):
|
||||
"""Fields that would not be omitted in the JSON object."""
|
||||
return dict((name, getattr(self, name))
|
||||
for name, field in six.iteritems(self._fields)
|
||||
if not field.omit(getattr(self, name)))
|
||||
|
||||
def __add__(self, other):
|
||||
if not isinstance(other, type(self)):
|
||||
raise TypeError('Header cannot be added to: {0}'.format(
|
||||
type(other)))
|
||||
|
||||
not_omitted_self = self.not_omitted()
|
||||
not_omitted_other = other.not_omitted()
|
||||
|
||||
if set(not_omitted_self).intersection(not_omitted_other):
|
||||
raise TypeError('Addition of overlapping headers not defined')
|
||||
|
||||
not_omitted_self.update(not_omitted_other)
|
||||
return type(self)(**not_omitted_self) # pylint: disable=star-args
|
||||
|
||||
def find_key(self):
|
||||
"""Find key based on header.
|
||||
|
||||
.. todo:: Supports only "jwk" header parameter lookup.
|
||||
|
||||
:returns: (Public) key found in the header.
|
||||
:rtype: :class:`acme.jose.jwk.JWK`
|
||||
|
||||
:raises acme.jose.errors.Error: if key could not be found
|
||||
|
||||
"""
|
||||
if self.jwk is None:
|
||||
raise errors.Error('No key found')
|
||||
return self.jwk
|
||||
|
||||
@crit.decoder
|
||||
def crit(unused_value):
|
||||
# pylint: disable=missing-docstring,no-self-argument,no-self-use
|
||||
raise errors.DeserializationError(
|
||||
'"crit" is not supported, please subclass')
|
||||
|
||||
# x5c does NOT use JOSE Base64 (4.1.6)
|
||||
|
||||
@x5c.encoder
|
||||
def x5c(value): # pylint: disable=missing-docstring,no-self-argument
|
||||
return [base64.b64encode(OpenSSL.crypto.dump_certificate(
|
||||
OpenSSL.crypto.FILETYPE_ASN1, cert)) for cert in value]
|
||||
|
||||
@x5c.decoder
|
||||
def x5c(value): # pylint: disable=missing-docstring,no-self-argument
|
||||
try:
|
||||
return tuple(util.ComparableX509(OpenSSL.crypto.load_certificate(
|
||||
OpenSSL.crypto.FILETYPE_ASN1,
|
||||
base64.b64decode(cert))) for cert in value)
|
||||
except OpenSSL.crypto.Error as error:
|
||||
raise errors.DeserializationError(error)
|
||||
|
||||
|
||||
class Signature(json_util.JSONObjectWithFields):
|
||||
"""JWS Signature.
|
||||
|
||||
:ivar combined: Combined Header (protected and unprotected,
|
||||
:class:`Header`).
|
||||
:ivar unicode protected: JWS protected header (Jose Base-64 decoded).
|
||||
:ivar header: JWS Unprotected Header (:class:`Header`).
|
||||
:ivar str signature: The signature.
|
||||
|
||||
"""
|
||||
header_cls = Header
|
||||
|
||||
__slots__ = ('combined',)
|
||||
protected = json_util.Field('protected', omitempty=True, default='')
|
||||
header = json_util.Field(
|
||||
'header', omitempty=True, default=header_cls(),
|
||||
decoder=header_cls.from_json)
|
||||
signature = json_util.Field(
|
||||
'signature', decoder=json_util.decode_b64jose,
|
||||
encoder=json_util.encode_b64jose)
|
||||
|
||||
@protected.encoder
|
||||
def protected(value): # pylint: disable=missing-docstring,no-self-argument
|
||||
# wrong type guess (Signature, not bytes) | pylint: disable=no-member
|
||||
return json_util.encode_b64jose(value.encode('utf-8'))
|
||||
|
||||
@protected.decoder
|
||||
def protected(value): # pylint: disable=missing-docstring,no-self-argument
|
||||
return json_util.decode_b64jose(value).decode('utf-8')
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
if 'combined' not in kwargs:
|
||||
kwargs = self._with_combined(kwargs)
|
||||
super(Signature, self).__init__(**kwargs)
|
||||
assert self.combined.alg is not None
|
||||
|
||||
@classmethod
|
||||
def _with_combined(cls, kwargs):
|
||||
assert 'combined' not in kwargs
|
||||
header = kwargs.get('header', cls._fields['header'].default)
|
||||
protected = kwargs.get('protected', cls._fields['protected'].default)
|
||||
|
||||
if protected:
|
||||
combined = header + cls.header_cls.json_loads(protected)
|
||||
else:
|
||||
combined = header
|
||||
|
||||
kwargs['combined'] = combined
|
||||
return kwargs
|
||||
|
||||
@classmethod
|
||||
def _msg(cls, protected, payload):
|
||||
return (b64.b64encode(protected.encode('utf-8')) + b'.' +
|
||||
b64.b64encode(payload))
|
||||
|
||||
def verify(self, payload, key=None):
|
||||
"""Verify.
|
||||
|
||||
:param key: Key used for verification.
|
||||
:type key: :class:`acme.jose.jwk.JWK`
|
||||
|
||||
"""
|
||||
key = self.combined.find_key() if key is None else key
|
||||
return self.combined.alg.verify(
|
||||
key=key.key, sig=self.signature,
|
||||
msg=self._msg(self.protected, payload))
|
||||
|
||||
@classmethod
|
||||
def sign(cls, payload, key, alg, include_jwk=True,
|
||||
protect=frozenset(), **kwargs):
|
||||
"""Sign.
|
||||
|
||||
:param key: Key for signature.
|
||||
:type key: :class:`acme.jose.jwk.JWK`
|
||||
|
||||
"""
|
||||
assert isinstance(key, alg.kty)
|
||||
|
||||
header_params = kwargs
|
||||
header_params['alg'] = alg
|
||||
if include_jwk:
|
||||
header_params['jwk'] = key.public_key()
|
||||
|
||||
assert set(header_params).issubset(cls.header_cls._fields)
|
||||
assert protect.issubset(cls.header_cls._fields)
|
||||
|
||||
protected_params = {}
|
||||
for header in protect:
|
||||
protected_params[header] = header_params.pop(header)
|
||||
if protected_params:
|
||||
# pylint: disable=star-args
|
||||
protected = cls.header_cls(**protected_params).json_dumps()
|
||||
else:
|
||||
protected = ''
|
||||
|
||||
header = cls.header_cls(**header_params) # pylint: disable=star-args
|
||||
signature = alg.sign(key.key, cls._msg(protected, payload))
|
||||
|
||||
return cls(protected=protected, header=header, signature=signature)
|
||||
|
||||
def fields_to_partial_json(self):
|
||||
fields = super(Signature, self).fields_to_partial_json()
|
||||
if not fields['header'].not_omitted():
|
||||
del fields['header']
|
||||
return fields
|
||||
|
||||
@classmethod
|
||||
def fields_from_json(cls, jobj):
|
||||
fields = super(Signature, cls).fields_from_json(jobj)
|
||||
fields_with_combined = cls._with_combined(fields)
|
||||
if 'alg' not in fields_with_combined['combined'].not_omitted():
|
||||
raise errors.DeserializationError('alg not present')
|
||||
return fields_with_combined
|
||||
|
||||
|
||||
class JWS(json_util.JSONObjectWithFields):
|
||||
"""JSON Web Signature.
|
||||
|
||||
:ivar str payload: JWS Payload.
|
||||
:ivar str signature: JWS Signatures.
|
||||
|
||||
"""
|
||||
__slots__ = ('payload', 'signatures')
|
||||
|
||||
signature_cls = Signature
|
||||
|
||||
def verify(self, key=None):
|
||||
"""Verify."""
|
||||
return all(sig.verify(self.payload, key) for sig in self.signatures)
|
||||
|
||||
@classmethod
|
||||
def sign(cls, payload, **kwargs):
|
||||
"""Sign."""
|
||||
return cls(payload=payload, signatures=(
|
||||
cls.signature_cls.sign(payload=payload, **kwargs),))
|
||||
|
||||
@property
|
||||
def signature(self):
|
||||
"""Get a singleton signature.
|
||||
|
||||
:rtype: `signature_cls`
|
||||
|
||||
"""
|
||||
assert len(self.signatures) == 1
|
||||
return self.signatures[0]
|
||||
|
||||
def to_compact(self):
|
||||
"""Compact serialization.
|
||||
|
||||
:rtype: bytes
|
||||
|
||||
"""
|
||||
assert len(self.signatures) == 1
|
||||
|
||||
assert 'alg' not in self.signature.header.not_omitted()
|
||||
# ... it must be in protected
|
||||
|
||||
return (
|
||||
b64.b64encode(self.signature.protected.encode('utf-8'))
|
||||
+ b'.' +
|
||||
b64.b64encode(self.payload)
|
||||
+ b'.' +
|
||||
b64.b64encode(self.signature.signature))
|
||||
|
||||
@classmethod
|
||||
def from_compact(cls, compact):
|
||||
"""Compact deserialization.
|
||||
|
||||
:param bytes compact:
|
||||
|
||||
"""
|
||||
try:
|
||||
protected, payload, signature = compact.split(b'.')
|
||||
except ValueError:
|
||||
raise errors.DeserializationError(
|
||||
'Compact JWS serialization should comprise of exactly'
|
||||
' 3 dot-separated components')
|
||||
|
||||
sig = cls.signature_cls(
|
||||
protected=b64.b64decode(protected).decode('utf-8'),
|
||||
signature=b64.b64decode(signature))
|
||||
return cls(payload=b64.b64decode(payload), signatures=(sig,))
|
||||
|
||||
def to_partial_json(self, flat=True): # pylint: disable=arguments-differ
|
||||
assert self.signatures
|
||||
payload = json_util.encode_b64jose(self.payload)
|
||||
|
||||
if flat and len(self.signatures) == 1:
|
||||
ret = self.signatures[0].to_partial_json()
|
||||
ret['payload'] = payload
|
||||
return ret
|
||||
else:
|
||||
return {
|
||||
'payload': payload,
|
||||
'signatures': self.signatures,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_json(cls, jobj):
|
||||
if 'signature' in jobj and 'signatures' in jobj:
|
||||
raise errors.DeserializationError('Flat mixed with non-flat')
|
||||
elif 'signature' in jobj: # flat
|
||||
return cls(payload=json_util.decode_b64jose(jobj.pop('payload')),
|
||||
signatures=(cls.signature_cls.from_json(jobj),))
|
||||
else:
|
||||
return cls(payload=json_util.decode_b64jose(jobj['payload']),
|
||||
signatures=tuple(cls.signature_cls.from_json(sig)
|
||||
for sig in jobj['signatures']))
|
||||
|
||||
class CLI(object):
|
||||
"""JWS CLI."""
|
||||
|
||||
@classmethod
|
||||
def sign(cls, args):
|
||||
"""Sign."""
|
||||
key = args.alg.kty.load(args.key.read())
|
||||
args.key.close()
|
||||
if args.protect is None:
|
||||
args.protect = []
|
||||
if args.compact:
|
||||
args.protect.append('alg')
|
||||
|
||||
sig = JWS.sign(payload=sys.stdin.read().encode(), key=key, alg=args.alg,
|
||||
protect=set(args.protect))
|
||||
|
||||
if args.compact:
|
||||
six.print_(sig.to_compact().decode('utf-8'))
|
||||
else: # JSON
|
||||
six.print_(sig.json_dumps_pretty())
|
||||
|
||||
@classmethod
|
||||
def verify(cls, args):
|
||||
"""Verify."""
|
||||
if args.compact:
|
||||
sig = JWS.from_compact(sys.stdin.read().encode())
|
||||
else: # JSON
|
||||
try:
|
||||
sig = JWS.json_loads(sys.stdin.read())
|
||||
except errors.Error as error:
|
||||
six.print_(error)
|
||||
return -1
|
||||
|
||||
if args.key is not None:
|
||||
assert args.kty is not None
|
||||
key = args.kty.load(args.key.read()).public_key()
|
||||
args.key.close()
|
||||
else:
|
||||
key = None
|
||||
|
||||
sys.stdout.write(sig.payload)
|
||||
return not sig.verify(key=key)
|
||||
|
||||
@classmethod
|
||||
def _alg_type(cls, arg):
|
||||
return jwa.JWASignature.from_json(arg)
|
||||
|
||||
@classmethod
|
||||
def _header_type(cls, arg):
|
||||
assert arg in Signature.header_cls._fields
|
||||
return arg
|
||||
|
||||
@classmethod
|
||||
def _kty_type(cls, arg):
|
||||
assert arg in jwk.JWK.TYPES
|
||||
return jwk.JWK.TYPES[arg]
|
||||
|
||||
@classmethod
|
||||
def run(cls, args=sys.argv[1:]):
|
||||
"""Parse arguments and sign/verify."""
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument('--compact', action='store_true')
|
||||
|
||||
subparsers = parser.add_subparsers()
|
||||
parser_sign = subparsers.add_parser('sign')
|
||||
parser_sign.set_defaults(func=cls.sign)
|
||||
parser_sign.add_argument(
|
||||
'-k', '--key', type=argparse.FileType('rb'), required=True)
|
||||
parser_sign.add_argument(
|
||||
'-a', '--alg', type=cls._alg_type, default=jwa.RS256)
|
||||
parser_sign.add_argument(
|
||||
'-p', '--protect', action='append', type=cls._header_type)
|
||||
|
||||
parser_verify = subparsers.add_parser('verify')
|
||||
parser_verify.set_defaults(func=cls.verify)
|
||||
parser_verify.add_argument(
|
||||
'-k', '--key', type=argparse.FileType('rb'), required=False)
|
||||
parser_verify.add_argument(
|
||||
'--kty', type=cls._kty_type, required=False)
|
||||
|
||||
parsed = parser.parse_args(args)
|
||||
return parsed.func(parsed)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
exit(CLI.run()) # pragma: no cover
|
||||
240
acme/acme/jose/jws_test.py
Normal file
240
acme/acme/jose/jws_test.py
Normal file
|
|
@ -0,0 +1,240 @@
|
|||
"""Tests for acme.jose.jws."""
|
||||
import base64
|
||||
import unittest
|
||||
|
||||
import mock
|
||||
import OpenSSL
|
||||
|
||||
from acme import test_util
|
||||
|
||||
from acme.jose import errors
|
||||
from acme.jose import json_util
|
||||
from acme.jose import jwa
|
||||
from acme.jose import jwk
|
||||
|
||||
|
||||
CERT = test_util.load_cert('cert.pem')
|
||||
KEY = jwk.JWKRSA.load(test_util.load_vector('rsa512_key.pem'))
|
||||
|
||||
|
||||
class MediaTypeTest(unittest.TestCase):
|
||||
"""Tests for acme.jose.jws.MediaType."""
|
||||
|
||||
def test_decode(self):
|
||||
from acme.jose.jws import MediaType
|
||||
self.assertEqual('application/app', MediaType.decode('application/app'))
|
||||
self.assertEqual('application/app', MediaType.decode('app'))
|
||||
self.assertRaises(
|
||||
errors.DeserializationError, MediaType.decode, 'app;foo')
|
||||
|
||||
def test_encode(self):
|
||||
from acme.jose.jws import MediaType
|
||||
self.assertEqual('app', MediaType.encode('application/app'))
|
||||
self.assertEqual('application/app;foo',
|
||||
MediaType.encode('application/app;foo'))
|
||||
|
||||
|
||||
class HeaderTest(unittest.TestCase):
|
||||
"""Tests for acme.jose.jws.Header."""
|
||||
|
||||
def setUp(self):
|
||||
from acme.jose.jws import Header
|
||||
self.header1 = Header(jwk='foo')
|
||||
self.header2 = Header(jwk='bar')
|
||||
self.crit = Header(crit=('a', 'b'))
|
||||
self.empty = Header()
|
||||
|
||||
def test_add_non_empty(self):
|
||||
from acme.jose.jws import Header
|
||||
self.assertEqual(Header(jwk='foo', crit=('a', 'b')),
|
||||
self.header1 + self.crit)
|
||||
|
||||
def test_add_empty(self):
|
||||
self.assertEqual(self.header1, self.header1 + self.empty)
|
||||
self.assertEqual(self.header1, self.empty + self.header1)
|
||||
|
||||
def test_add_overlapping_error(self):
|
||||
self.assertRaises(TypeError, self.header1.__add__, self.header2)
|
||||
|
||||
def test_add_wrong_type_error(self):
|
||||
self.assertRaises(TypeError, self.header1.__add__, 'xxx')
|
||||
|
||||
def test_crit_decode_always_errors(self):
|
||||
from acme.jose.jws import Header
|
||||
self.assertRaises(errors.DeserializationError, Header.from_json,
|
||||
{'crit': ['a', 'b']})
|
||||
|
||||
def test_x5c_decoding(self):
|
||||
from acme.jose.jws import Header
|
||||
header = Header(x5c=(CERT, CERT))
|
||||
jobj = header.to_partial_json()
|
||||
cert_b64 = base64.b64encode(OpenSSL.crypto.dump_certificate(
|
||||
OpenSSL.crypto.FILETYPE_ASN1, CERT))
|
||||
self.assertEqual(jobj, {'x5c': [cert_b64, cert_b64]})
|
||||
self.assertEqual(header, Header.from_json(jobj))
|
||||
jobj['x5c'][0] = base64.b64encode(
|
||||
b'xxx' + OpenSSL.crypto.dump_certificate(
|
||||
OpenSSL.crypto.FILETYPE_ASN1, CERT))
|
||||
self.assertRaises(errors.DeserializationError, Header.from_json, jobj)
|
||||
|
||||
def test_find_key(self):
|
||||
self.assertEqual('foo', self.header1.find_key())
|
||||
self.assertEqual('bar', self.header2.find_key())
|
||||
self.assertRaises(errors.Error, self.crit.find_key)
|
||||
|
||||
|
||||
class SignatureTest(unittest.TestCase):
|
||||
"""Tests for acme.jose.jws.Signature."""
|
||||
|
||||
def test_from_json(self):
|
||||
from acme.jose.jws import Header
|
||||
from acme.jose.jws import Signature
|
||||
self.assertEqual(
|
||||
Signature(signature=b'foo', header=Header(alg=jwa.RS256)),
|
||||
Signature.from_json(
|
||||
{'signature': 'Zm9v', 'header': {'alg': 'RS256'}}))
|
||||
|
||||
def test_from_json_no_alg_error(self):
|
||||
from acme.jose.jws import Signature
|
||||
self.assertRaises(errors.DeserializationError,
|
||||
Signature.from_json, {'signature': 'foo'})
|
||||
|
||||
|
||||
class JWSTest(unittest.TestCase):
|
||||
"""Tests for acme.jose.jws.JWS."""
|
||||
|
||||
def setUp(self):
|
||||
self.privkey = KEY
|
||||
self.pubkey = self.privkey.public_key()
|
||||
|
||||
from acme.jose.jws import JWS
|
||||
self.unprotected = JWS.sign(
|
||||
payload=b'foo', key=self.privkey, alg=jwa.RS256)
|
||||
self.protected = JWS.sign(
|
||||
payload=b'foo', key=self.privkey, alg=jwa.RS256,
|
||||
protect=frozenset(['jwk', 'alg']))
|
||||
self.mixed = JWS.sign(
|
||||
payload=b'foo', key=self.privkey, alg=jwa.RS256,
|
||||
protect=frozenset(['alg']))
|
||||
|
||||
def test_pubkey_jwk(self):
|
||||
self.assertEqual(self.unprotected.signature.combined.jwk, self.pubkey)
|
||||
self.assertEqual(self.protected.signature.combined.jwk, self.pubkey)
|
||||
self.assertEqual(self.mixed.signature.combined.jwk, self.pubkey)
|
||||
|
||||
def test_sign_unprotected(self):
|
||||
self.assertTrue(self.unprotected.verify())
|
||||
|
||||
def test_sign_protected(self):
|
||||
self.assertTrue(self.protected.verify())
|
||||
|
||||
def test_sign_mixed(self):
|
||||
self.assertTrue(self.mixed.verify())
|
||||
|
||||
def test_compact_lost_unprotected(self):
|
||||
compact = self.mixed.to_compact()
|
||||
self.assertEqual(
|
||||
b'eyJhbGciOiAiUlMyNTYifQ.Zm9v.OHdxFVj73l5LpxbFp1AmYX4yJM0Pyb'
|
||||
b'_893n1zQjpim_eLS5J1F61lkvrCrCDErTEJnBGOGesJ72M7b6Ve1cAJA',
|
||||
compact)
|
||||
|
||||
from acme.jose.jws import JWS
|
||||
mixed = JWS.from_compact(compact)
|
||||
|
||||
self.assertNotEqual(self.mixed, mixed)
|
||||
self.assertEqual(
|
||||
set(['alg']), set(mixed.signature.combined.not_omitted()))
|
||||
|
||||
def test_from_compact_missing_components(self):
|
||||
from acme.jose.jws import JWS
|
||||
self.assertRaises(errors.DeserializationError, JWS.from_compact, b'.')
|
||||
|
||||
def test_json_omitempty(self):
|
||||
protected_jobj = self.protected.to_partial_json(flat=True)
|
||||
unprotected_jobj = self.unprotected.to_partial_json(flat=True)
|
||||
|
||||
self.assertTrue('protected' not in unprotected_jobj)
|
||||
self.assertTrue('header' not in protected_jobj)
|
||||
|
||||
unprotected_jobj['header'] = unprotected_jobj['header'].to_json()
|
||||
|
||||
from acme.jose.jws import JWS
|
||||
self.assertEqual(JWS.from_json(protected_jobj), self.protected)
|
||||
self.assertEqual(JWS.from_json(unprotected_jobj), self.unprotected)
|
||||
|
||||
def test_json_flat(self):
|
||||
jobj_to = {
|
||||
'signature': json_util.encode_b64jose(
|
||||
self.mixed.signature.signature),
|
||||
'payload': json_util.encode_b64jose(b'foo'),
|
||||
'header': self.mixed.signature.header,
|
||||
'protected': json_util.encode_b64jose(
|
||||
self.mixed.signature.protected.encode('utf-8')),
|
||||
}
|
||||
jobj_from = jobj_to.copy()
|
||||
jobj_from['header'] = jobj_from['header'].to_json()
|
||||
|
||||
self.assertEqual(self.mixed.to_partial_json(flat=True), jobj_to)
|
||||
from acme.jose.jws import JWS
|
||||
self.assertEqual(self.mixed, JWS.from_json(jobj_from))
|
||||
|
||||
def test_json_not_flat(self):
|
||||
jobj_to = {
|
||||
'signatures': (self.mixed.signature,),
|
||||
'payload': json_util.encode_b64jose(b'foo'),
|
||||
}
|
||||
jobj_from = jobj_to.copy()
|
||||
jobj_from['signatures'] = [jobj_to['signatures'][0].to_json()]
|
||||
|
||||
self.assertEqual(self.mixed.to_partial_json(flat=False), jobj_to)
|
||||
from acme.jose.jws import JWS
|
||||
self.assertEqual(self.mixed, JWS.from_json(jobj_from))
|
||||
|
||||
def test_from_json_mixed_flat(self):
|
||||
from acme.jose.jws import JWS
|
||||
self.assertRaises(errors.DeserializationError, JWS.from_json,
|
||||
{'signatures': (), 'signature': 'foo'})
|
||||
|
||||
def test_from_json_hashable(self):
|
||||
from acme.jose.jws import JWS
|
||||
hash(JWS.from_json(self.mixed.to_json()))
|
||||
|
||||
|
||||
class CLITest(unittest.TestCase):
|
||||
|
||||
def setUp(self):
|
||||
self.key_path = test_util.vector_path('rsa512_key.pem')
|
||||
|
||||
def test_unverified(self):
|
||||
from acme.jose.jws import CLI
|
||||
with mock.patch('sys.stdin') as sin:
|
||||
sin.read.return_value = '{"payload": "foo", "signature": "xxx"}'
|
||||
with mock.patch('sys.stdout'):
|
||||
self.assertEqual(-1, CLI.run(['verify']))
|
||||
|
||||
def test_json(self):
|
||||
from acme.jose.jws import CLI
|
||||
|
||||
with mock.patch('sys.stdin') as sin:
|
||||
sin.read.return_value = 'foo'
|
||||
with mock.patch('sys.stdout') as sout:
|
||||
CLI.run(['sign', '-k', self.key_path, '-a', 'RS256',
|
||||
'-p', 'jwk'])
|
||||
sin.read.return_value = sout.write.mock_calls[0][1][0]
|
||||
self.assertEqual(0, CLI.run(['verify']))
|
||||
|
||||
def test_compact(self):
|
||||
from acme.jose.jws import CLI
|
||||
|
||||
with mock.patch('sys.stdin') as sin:
|
||||
sin.read.return_value = 'foo'
|
||||
with mock.patch('sys.stdout') as sout:
|
||||
CLI.run(['--compact', 'sign', '-k', self.key_path])
|
||||
sin.read.return_value = sout.write.mock_calls[0][1][0]
|
||||
self.assertEqual(0, CLI.run([
|
||||
'--compact', 'verify', '--kty', 'RSA',
|
||||
'-k', self.key_path]))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main() # pragma: no cover
|
||||
217
acme/acme/jose/util.py
Normal file
217
acme/acme/jose/util.py
Normal file
|
|
@ -0,0 +1,217 @@
|
|||
"""JOSE utilities."""
|
||||
import collections
|
||||
|
||||
from cryptography.hazmat.primitives.asymmetric import rsa
|
||||
import OpenSSL
|
||||
import six
|
||||
|
||||
|
||||
class abstractclassmethod(classmethod):
|
||||
# pylint: disable=invalid-name,too-few-public-methods
|
||||
"""Descriptor for an abstract classmethod.
|
||||
|
||||
It augments the :mod:`abc` framework with an abstract
|
||||
classmethod. This is implemented as :class:`abc.abstractclassmethod`
|
||||
in the standard Python library starting with version 3.2.
|
||||
|
||||
This particular implementation, allegedly based on Python 3.3 source
|
||||
code, is stolen from
|
||||
http://stackoverflow.com/questions/11217878/python-2-7-combine-abc-abstractmethod-and-classmethod.
|
||||
|
||||
"""
|
||||
__isabstractmethod__ = True
|
||||
|
||||
def __init__(self, target):
|
||||
target.__isabstractmethod__ = True
|
||||
super(abstractclassmethod, self).__init__(target)
|
||||
|
||||
|
||||
class ComparableX509(object): # pylint: disable=too-few-public-methods
|
||||
"""Wrapper for OpenSSL.crypto.X509** objects that supports __eq__.
|
||||
|
||||
Wraps around:
|
||||
|
||||
- :class:`OpenSSL.crypto.X509`
|
||||
- :class:`OpenSSL.crypto.X509Req`
|
||||
|
||||
"""
|
||||
def __init__(self, wrapped):
|
||||
assert isinstance(wrapped, OpenSSL.crypto.X509) or isinstance(
|
||||
wrapped, OpenSSL.crypto.X509Req)
|
||||
self._wrapped = wrapped
|
||||
|
||||
def __getattr__(self, name):
|
||||
return getattr(self._wrapped, name)
|
||||
|
||||
def _dump(self, filetype=OpenSSL.crypto.FILETYPE_ASN1):
|
||||
# pylint: disable=missing-docstring,protected-access
|
||||
if isinstance(self._wrapped, OpenSSL.crypto.X509):
|
||||
func = OpenSSL.crypto.dump_certificate
|
||||
else: # assert in __init__ makes sure this is X509Req
|
||||
func = OpenSSL.crypto.dump_certificate_request
|
||||
return func(filetype, self._wrapped)
|
||||
|
||||
def __eq__(self, other):
|
||||
if not isinstance(other, self.__class__):
|
||||
return NotImplemented
|
||||
return self._dump() == other._dump() # pylint: disable=protected-access
|
||||
|
||||
def __hash__(self):
|
||||
return hash((self.__class__, self._dump()))
|
||||
|
||||
def __ne__(self, other):
|
||||
return not self == other
|
||||
|
||||
def __repr__(self):
|
||||
return '<{0}({1!r})>'.format(self.__class__.__name__, self._wrapped)
|
||||
|
||||
|
||||
class ComparableKey(object): # pylint: disable=too-few-public-methods
|
||||
"""Comparable wrapper for `cryptography` keys.
|
||||
|
||||
See https://github.com/pyca/cryptography/issues/2122.
|
||||
|
||||
"""
|
||||
__hash__ = NotImplemented
|
||||
|
||||
def __init__(self, wrapped):
|
||||
self._wrapped = wrapped
|
||||
|
||||
def __getattr__(self, name):
|
||||
return getattr(self._wrapped, name)
|
||||
|
||||
def __eq__(self, other):
|
||||
# pylint: disable=protected-access
|
||||
if (not isinstance(other, self.__class__) or
|
||||
self._wrapped.__class__ is not other._wrapped.__class__):
|
||||
return NotImplemented
|
||||
elif hasattr(self._wrapped, 'private_numbers'):
|
||||
return self.private_numbers() == other.private_numbers()
|
||||
elif hasattr(self._wrapped, 'public_numbers'):
|
||||
return self.public_numbers() == other.public_numbers()
|
||||
else:
|
||||
return NotImplemented
|
||||
|
||||
def __ne__(self, other):
|
||||
return not self == other
|
||||
|
||||
def __repr__(self):
|
||||
return '<{0}({1!r})>'.format(self.__class__.__name__, self._wrapped)
|
||||
|
||||
def public_key(self):
|
||||
"""Get wrapped public key."""
|
||||
return self.__class__(self._wrapped.public_key())
|
||||
|
||||
|
||||
class ComparableRSAKey(ComparableKey): # pylint: disable=too-few-public-methods
|
||||
"""Wrapper for `cryptography` RSA keys.
|
||||
|
||||
Wraps around:
|
||||
- `cryptography.hazmat.primitives.assymetric.RSAPrivateKey`
|
||||
- `cryptography.hazmat.primitives.assymetric.RSAPublicKey`
|
||||
|
||||
"""
|
||||
|
||||
def __hash__(self):
|
||||
# public_numbers() hasn't got stable hash!
|
||||
# https://github.com/pyca/cryptography/issues/2143
|
||||
if isinstance(self._wrapped, rsa.RSAPrivateKeyWithSerialization):
|
||||
priv = self.private_numbers()
|
||||
pub = priv.public_numbers
|
||||
return hash((self.__class__, priv.p, priv.q, priv.dmp1,
|
||||
priv.dmq1, priv.iqmp, pub.n, pub.e))
|
||||
elif isinstance(self._wrapped, rsa.RSAPublicKeyWithSerialization):
|
||||
pub = self.public_numbers()
|
||||
return hash((self.__class__, pub.n, pub.e))
|
||||
|
||||
|
||||
class ImmutableMap(collections.Mapping, collections.Hashable):
|
||||
# pylint: disable=too-few-public-methods
|
||||
"""Immutable key to value mapping with attribute access."""
|
||||
|
||||
__slots__ = ()
|
||||
"""Must be overriden in subclasses."""
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
if set(kwargs) != set(self.__slots__):
|
||||
raise TypeError(
|
||||
'__init__() takes exactly the following arguments: {0} '
|
||||
'({1} given)'.format(', '.join(self.__slots__),
|
||||
', '.join(kwargs) if kwargs else 'none'))
|
||||
for slot in self.__slots__:
|
||||
object.__setattr__(self, slot, kwargs.pop(slot))
|
||||
|
||||
def update(self, **kwargs):
|
||||
"""Return updated map."""
|
||||
items = dict(self)
|
||||
items.update(kwargs)
|
||||
return type(self)(**items) # pylint: disable=star-args
|
||||
|
||||
def __getitem__(self, key):
|
||||
try:
|
||||
return getattr(self, key)
|
||||
except AttributeError:
|
||||
raise KeyError(key)
|
||||
|
||||
def __iter__(self):
|
||||
return iter(self.__slots__)
|
||||
|
||||
def __len__(self):
|
||||
return len(self.__slots__)
|
||||
|
||||
def __hash__(self):
|
||||
return hash(tuple(getattr(self, slot) for slot in self.__slots__))
|
||||
|
||||
def __setattr__(self, name, value):
|
||||
raise AttributeError("can't set attribute")
|
||||
|
||||
def __repr__(self):
|
||||
return '{0}({1})'.format(self.__class__.__name__, ', '.join(
|
||||
'{0}={1!r}'.format(key, value)
|
||||
for key, value in six.iteritems(self)))
|
||||
|
||||
|
||||
class frozendict(collections.Mapping, collections.Hashable):
|
||||
# pylint: disable=invalid-name,too-few-public-methods
|
||||
"""Frozen dictionary."""
|
||||
__slots__ = ('_items', '_keys')
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
if kwargs and not args:
|
||||
items = dict(kwargs)
|
||||
elif len(args) == 1 and isinstance(args[0], collections.Mapping):
|
||||
items = args[0]
|
||||
else:
|
||||
raise TypeError()
|
||||
# TODO: support generators/iterators
|
||||
|
||||
object.__setattr__(self, '_items', items)
|
||||
object.__setattr__(self, '_keys', tuple(sorted(six.iterkeys(items))))
|
||||
|
||||
def __getitem__(self, key):
|
||||
return self._items[key]
|
||||
|
||||
def __iter__(self):
|
||||
return iter(self._keys)
|
||||
|
||||
def __len__(self):
|
||||
return len(self._items)
|
||||
|
||||
def _sorted_items(self):
|
||||
return tuple((key, self[key]) for key in self._keys)
|
||||
|
||||
def __hash__(self):
|
||||
return hash(self._sorted_items())
|
||||
|
||||
def __getattr__(self, name):
|
||||
try:
|
||||
return self._items[name]
|
||||
except KeyError:
|
||||
raise AttributeError(name)
|
||||
|
||||
def __setattr__(self, name, value):
|
||||
raise AttributeError("can't set attribute")
|
||||
|
||||
def __repr__(self):
|
||||
return 'frozendict({0})'.format(', '.join('{0}={1!r}'.format(
|
||||
key, value) for key, value in self._sorted_items()))
|
||||
196
acme/acme/jose/util_test.py
Normal file
196
acme/acme/jose/util_test.py
Normal file
|
|
@ -0,0 +1,196 @@
|
|||
"""Tests for acme.jose.util."""
|
||||
import functools
|
||||
import unittest
|
||||
|
||||
import six
|
||||
|
||||
from acme import test_util
|
||||
|
||||
|
||||
class ComparableX509Test(unittest.TestCase):
|
||||
"""Tests for acme.jose.util.ComparableX509."""
|
||||
|
||||
def setUp(self):
|
||||
# test_util.load_{csr,cert} return ComparableX509
|
||||
self.req1 = test_util.load_csr('csr.pem')
|
||||
self.req2 = test_util.load_csr('csr.pem')
|
||||
self.req_other = test_util.load_csr('csr-san.pem')
|
||||
|
||||
self.cert1 = test_util.load_cert('cert.pem')
|
||||
self.cert2 = test_util.load_cert('cert.pem')
|
||||
self.cert_other = test_util.load_cert('cert-san.pem')
|
||||
|
||||
def test_eq(self):
|
||||
self.assertEqual(self.req1, self.req2)
|
||||
self.assertEqual(self.cert1, self.cert2)
|
||||
|
||||
def test_ne(self):
|
||||
self.assertNotEqual(self.req1, self.req_other)
|
||||
self.assertNotEqual(self.cert1, self.cert_other)
|
||||
|
||||
def test_ne_wrong_types(self):
|
||||
self.assertNotEqual(self.req1, 5)
|
||||
self.assertNotEqual(self.cert1, 5)
|
||||
|
||||
def test_hash(self):
|
||||
self.assertEqual(hash(self.req1), hash(self.req2))
|
||||
self.assertNotEqual(hash(self.req1), hash(self.req_other))
|
||||
|
||||
self.assertEqual(hash(self.cert1), hash(self.cert2))
|
||||
self.assertNotEqual(hash(self.cert1), hash(self.cert_other))
|
||||
|
||||
def test_repr(self):
|
||||
for x509 in self.req1, self.cert1:
|
||||
self.assertTrue(repr(x509).startswith(
|
||||
'<ComparableX509(<OpenSSL.crypto.X509'))
|
||||
|
||||
|
||||
class ComparableRSAKeyTest(unittest.TestCase):
|
||||
"""Tests for acme.jose.util.ComparableRSAKey."""
|
||||
|
||||
def setUp(self):
|
||||
# test_utl.load_rsa_private_key return ComparableRSAKey
|
||||
self.key = test_util.load_rsa_private_key('rsa256_key.pem')
|
||||
self.key_same = test_util.load_rsa_private_key('rsa256_key.pem')
|
||||
self.key2 = test_util.load_rsa_private_key('rsa512_key.pem')
|
||||
|
||||
def test_getattr_proxy(self):
|
||||
self.assertEqual(256, self.key.key_size)
|
||||
|
||||
def test_eq(self):
|
||||
self.assertEqual(self.key, self.key_same)
|
||||
|
||||
def test_ne(self):
|
||||
self.assertNotEqual(self.key, self.key2)
|
||||
|
||||
def test_ne_different_types(self):
|
||||
self.assertNotEqual(self.key, 5)
|
||||
|
||||
def test_ne_not_wrapped(self):
|
||||
# pylint: disable=protected-access
|
||||
self.assertNotEqual(self.key, self.key_same._wrapped)
|
||||
|
||||
def test_ne_no_serialization(self):
|
||||
from acme.jose.util import ComparableRSAKey
|
||||
self.assertNotEqual(ComparableRSAKey(5), ComparableRSAKey(5))
|
||||
|
||||
def test_hash(self):
|
||||
self.assertTrue(isinstance(hash(self.key), int))
|
||||
self.assertEqual(hash(self.key), hash(self.key_same))
|
||||
self.assertNotEqual(hash(self.key), hash(self.key2))
|
||||
|
||||
def test_repr(self):
|
||||
self.assertTrue(repr(self.key).startswith(
|
||||
'<ComparableRSAKey(<cryptography.hazmat.'))
|
||||
|
||||
def test_public_key(self):
|
||||
from acme.jose.util import ComparableRSAKey
|
||||
self.assertTrue(isinstance(self.key.public_key(), ComparableRSAKey))
|
||||
|
||||
|
||||
class ImmutableMapTest(unittest.TestCase):
|
||||
"""Tests for acme.jose.util.ImmutableMap."""
|
||||
|
||||
def setUp(self):
|
||||
# pylint: disable=invalid-name,too-few-public-methods
|
||||
# pylint: disable=missing-docstring
|
||||
from acme.jose.util import ImmutableMap
|
||||
|
||||
class A(ImmutableMap):
|
||||
__slots__ = ('x', 'y')
|
||||
|
||||
class B(ImmutableMap):
|
||||
__slots__ = ('x', 'y')
|
||||
|
||||
self.A = A
|
||||
self.B = B
|
||||
|
||||
self.a1 = self.A(x=1, y=2)
|
||||
self.a1_swap = self.A(y=2, x=1)
|
||||
self.a2 = self.A(x=3, y=4)
|
||||
self.b = self.B(x=1, y=2)
|
||||
|
||||
def test_update(self):
|
||||
self.assertEqual(self.A(x=2, y=2), self.a1.update(x=2))
|
||||
self.assertEqual(self.a2, self.a1.update(x=3, y=4))
|
||||
|
||||
def test_get_missing_item_raises_key_error(self):
|
||||
self.assertRaises(KeyError, self.a1.__getitem__, 'z')
|
||||
|
||||
def test_order_of_args_does_not_matter(self):
|
||||
self.assertEqual(self.a1, self.a1_swap)
|
||||
|
||||
def test_type_error_on_missing(self):
|
||||
self.assertRaises(TypeError, self.A, x=1)
|
||||
self.assertRaises(TypeError, self.A, y=2)
|
||||
|
||||
def test_type_error_on_unrecognized(self):
|
||||
self.assertRaises(TypeError, self.A, x=1, z=2)
|
||||
self.assertRaises(TypeError, self.A, x=1, y=2, z=3)
|
||||
|
||||
def test_get_attr(self):
|
||||
self.assertEqual(1, self.a1.x)
|
||||
self.assertEqual(2, self.a1.y)
|
||||
self.assertEqual(1, self.a1_swap.x)
|
||||
self.assertEqual(2, self.a1_swap.y)
|
||||
|
||||
def test_set_attr_raises_attribute_error(self):
|
||||
self.assertRaises(
|
||||
AttributeError, functools.partial(self.a1.__setattr__, 'x'), 10)
|
||||
|
||||
def test_equal(self):
|
||||
self.assertEqual(self.a1, self.a1)
|
||||
self.assertEqual(self.a2, self.a2)
|
||||
self.assertNotEqual(self.a1, self.a2)
|
||||
|
||||
def test_hash(self):
|
||||
self.assertEqual(hash((1, 2)), hash(self.a1))
|
||||
|
||||
def test_unhashable(self):
|
||||
self.assertRaises(TypeError, self.A(x=1, y={}).__hash__)
|
||||
|
||||
def test_repr(self):
|
||||
self.assertEqual('A(x=1, y=2)', repr(self.a1))
|
||||
self.assertEqual('A(x=1, y=2)', repr(self.a1_swap))
|
||||
self.assertEqual('B(x=1, y=2)', repr(self.b))
|
||||
self.assertEqual("B(x='foo', y='bar')", repr(self.B(x='foo', y='bar')))
|
||||
|
||||
|
||||
class frozendictTest(unittest.TestCase): # pylint: disable=invalid-name
|
||||
"""Tests for acme.jose.util.frozendict."""
|
||||
|
||||
def setUp(self):
|
||||
from acme.jose.util import frozendict
|
||||
self.fdict = frozendict(x=1, y='2')
|
||||
|
||||
def test_init_dict(self):
|
||||
from acme.jose.util import frozendict
|
||||
self.assertEqual(self.fdict, frozendict({'x': 1, 'y': '2'}))
|
||||
|
||||
def test_init_other_raises_type_error(self):
|
||||
from acme.jose.util import frozendict
|
||||
# specifically fail for generators...
|
||||
self.assertRaises(TypeError, frozendict, six.iteritems({'a': 'b'}))
|
||||
|
||||
def test_len(self):
|
||||
self.assertEqual(2, len(self.fdict))
|
||||
|
||||
def test_hash(self):
|
||||
self.assertTrue(isinstance(hash(self.fdict), int))
|
||||
|
||||
def test_getattr_proxy(self):
|
||||
self.assertEqual(1, self.fdict.x)
|
||||
self.assertEqual('2', self.fdict.y)
|
||||
|
||||
def test_getattr_raises_attribute_error(self):
|
||||
self.assertRaises(AttributeError, self.fdict.__getattr__, 'z')
|
||||
|
||||
def test_setattr_immutable(self):
|
||||
self.assertRaises(AttributeError, self.fdict.__setattr__, 'z', 3)
|
||||
|
||||
def test_repr(self):
|
||||
self.assertEqual("frozendict(x=1, y='2')", repr(self.fdict))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main() # pragma: no cover
|
||||
44
acme/acme/jws.py
Normal file
44
acme/acme/jws.py
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
"""ACME JOSE JWS."""
|
||||
from acme import jose
|
||||
|
||||
|
||||
class Header(jose.Header):
|
||||
"""ACME JOSE Header.
|
||||
|
||||
.. todo:: Implement ``acmePath``.
|
||||
|
||||
"""
|
||||
nonce = jose.Field('nonce', omitempty=True, encoder=jose.encode_b64jose)
|
||||
|
||||
@nonce.decoder
|
||||
def nonce(value): # pylint: disable=missing-docstring,no-self-argument
|
||||
try:
|
||||
return jose.decode_b64jose(value)
|
||||
except jose.DeserializationError as error:
|
||||
# TODO: custom error
|
||||
raise jose.DeserializationError("Invalid nonce: {0}".format(error))
|
||||
|
||||
|
||||
class Signature(jose.Signature):
|
||||
"""ACME Signature."""
|
||||
__slots__ = jose.Signature._orig_slots # pylint: disable=no-member
|
||||
|
||||
# TODO: decoder/encoder should accept cls? Otherwise, subclassing
|
||||
# JSONObjectWithFields is tricky...
|
||||
header_cls = Header
|
||||
header = jose.Field(
|
||||
'header', omitempty=True, default=header_cls(),
|
||||
decoder=header_cls.from_json)
|
||||
|
||||
# TODO: decoder should check that nonce is in the protected header
|
||||
|
||||
|
||||
class JWS(jose.JWS):
|
||||
"""ACME JWS."""
|
||||
signature_cls = Signature
|
||||
__slots__ = jose.JWS._orig_slots # pylint: disable=no-member
|
||||
|
||||
@classmethod
|
||||
def sign(cls, payload, key, alg, nonce): # pylint: disable=arguments-differ
|
||||
return super(JWS, cls).sign(payload, key=key, alg=alg,
|
||||
protect=frozenset(['nonce']), nonce=nonce)
|
||||
52
acme/acme/jws_test.py
Normal file
52
acme/acme/jws_test.py
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
"""Tests for acme.jws."""
|
||||
import unittest
|
||||
|
||||
from acme import jose
|
||||
from acme import test_util
|
||||
|
||||
|
||||
KEY = jose.JWKRSA.load(test_util.load_vector('rsa512_key.pem'))
|
||||
|
||||
|
||||
class HeaderTest(unittest.TestCase):
|
||||
"""Tests for acme.jws.Header."""
|
||||
|
||||
good_nonce = jose.encode_b64jose(b'foo')
|
||||
wrong_nonce = u'F'
|
||||
# Following just makes sure wrong_nonce is wrong
|
||||
try:
|
||||
jose.b64decode(wrong_nonce)
|
||||
except (ValueError, TypeError):
|
||||
assert True
|
||||
else:
|
||||
assert False # pragma: no cover
|
||||
|
||||
def test_nonce_decoder(self):
|
||||
from acme.jws import Header
|
||||
nonce_field = Header._fields['nonce']
|
||||
|
||||
self.assertRaises(
|
||||
jose.DeserializationError, nonce_field.decode, self.wrong_nonce)
|
||||
self.assertEqual(b'foo', nonce_field.decode(self.good_nonce))
|
||||
|
||||
|
||||
class JWSTest(unittest.TestCase):
|
||||
"""Tests for acme.jws.JWS."""
|
||||
|
||||
def setUp(self):
|
||||
self.privkey = KEY
|
||||
self.pubkey = self.privkey.public_key()
|
||||
self.nonce = jose.b64encode(b'Nonce')
|
||||
|
||||
def test_it(self):
|
||||
from acme.jws import JWS
|
||||
jws = JWS.sign(payload=b'foo', key=self.privkey,
|
||||
alg=jose.RS256, nonce=self.nonce)
|
||||
self.assertEqual(jws.signature.combined.nonce, self.nonce)
|
||||
# TODO: check that nonce is in protected header
|
||||
|
||||
self.assertEqual(jws, JWS.from_json(jws.to_json()))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main() # pragma: no cover
|
||||
377
acme/acme/messages.py
Normal file
377
acme/acme/messages.py
Normal file
|
|
@ -0,0 +1,377 @@
|
|||
"""ACME protocol messages."""
|
||||
import collections
|
||||
|
||||
from six.moves.urllib import parse as urllib_parse # pylint: disable=import-error
|
||||
|
||||
from acme import challenges
|
||||
from acme import fields
|
||||
from acme import jose
|
||||
|
||||
|
||||
class Error(jose.JSONObjectWithFields, Exception):
|
||||
"""ACME error.
|
||||
|
||||
https://tools.ietf.org/html/draft-ietf-appsawg-http-problem-00
|
||||
|
||||
:ivar unicode typ:
|
||||
:ivar unicode title:
|
||||
:ivar unicode detail:
|
||||
|
||||
"""
|
||||
ERROR_TYPE_NAMESPACE = 'urn:acme:error:'
|
||||
ERROR_TYPE_DESCRIPTIONS = {
|
||||
'badCSR': 'The CSR is unacceptable (e.g., due to a short key)',
|
||||
'badNonce': 'The client sent an unacceptable anti-replay nonce',
|
||||
'connection': 'The server could not connect to the client for DV',
|
||||
'dnssec': 'The server could not validate a DNSSEC signed domain',
|
||||
'malformed': 'The request message was malformed',
|
||||
'serverInternal': 'The server experienced an internal error',
|
||||
'tls': 'The server experienced a TLS error during DV',
|
||||
'unauthorized': 'The client lacks sufficient authorization',
|
||||
'unknownHost': 'The server could not resolve a domain name',
|
||||
}
|
||||
|
||||
typ = jose.Field('type')
|
||||
title = jose.Field('title', omitempty=True)
|
||||
detail = jose.Field('detail')
|
||||
|
||||
@typ.encoder
|
||||
def typ(value): # pylint: disable=missing-docstring,no-self-argument
|
||||
return Error.ERROR_TYPE_NAMESPACE + value
|
||||
|
||||
@typ.decoder
|
||||
def typ(value): # pylint: disable=missing-docstring,no-self-argument
|
||||
# pylint thinks isinstance(value, Error), so startswith is not found
|
||||
# pylint: disable=no-member
|
||||
if not value.startswith(Error.ERROR_TYPE_NAMESPACE):
|
||||
raise jose.DeserializationError('Missing error type prefix')
|
||||
|
||||
without_prefix = value[len(Error.ERROR_TYPE_NAMESPACE):]
|
||||
if without_prefix not in Error.ERROR_TYPE_DESCRIPTIONS:
|
||||
raise jose.DeserializationError('Error type not recognized')
|
||||
|
||||
return without_prefix
|
||||
|
||||
@property
|
||||
def description(self):
|
||||
"""Hardcoded error description based on its type.
|
||||
|
||||
:rtype: unicode
|
||||
|
||||
"""
|
||||
return self.ERROR_TYPE_DESCRIPTIONS[self.typ]
|
||||
|
||||
def __str__(self):
|
||||
if self.typ is not None:
|
||||
return ' :: '.join([self.typ, self.description, self.detail])
|
||||
else:
|
||||
return str(self.detail)
|
||||
|
||||
|
||||
class _Constant(jose.JSONDeSerializable, collections.Hashable):
|
||||
"""ACME constant."""
|
||||
__slots__ = ('name',)
|
||||
POSSIBLE_NAMES = NotImplemented
|
||||
|
||||
def __init__(self, name):
|
||||
self.POSSIBLE_NAMES[name] = self
|
||||
self.name = name
|
||||
|
||||
def to_partial_json(self):
|
||||
return self.name
|
||||
|
||||
@classmethod
|
||||
def from_json(cls, value):
|
||||
if value not in cls.POSSIBLE_NAMES:
|
||||
raise jose.DeserializationError(
|
||||
'{0} not recognized'.format(cls.__name__))
|
||||
return cls.POSSIBLE_NAMES[value]
|
||||
|
||||
def __repr__(self):
|
||||
return '{0}({1})'.format(self.__class__.__name__, self.name)
|
||||
|
||||
def __eq__(self, other):
|
||||
return isinstance(other, type(self)) and other.name == self.name
|
||||
|
||||
def __hash__(self):
|
||||
return hash((self.__class__, self.name))
|
||||
|
||||
def __ne__(self, other):
|
||||
return not self == other
|
||||
|
||||
|
||||
class Status(_Constant):
|
||||
"""ACME "status" field."""
|
||||
POSSIBLE_NAMES = {}
|
||||
STATUS_UNKNOWN = Status('unknown')
|
||||
STATUS_PENDING = Status('pending')
|
||||
STATUS_PROCESSING = Status('processing')
|
||||
STATUS_VALID = Status('valid')
|
||||
STATUS_INVALID = Status('invalid')
|
||||
STATUS_REVOKED = Status('revoked')
|
||||
|
||||
|
||||
class IdentifierType(_Constant):
|
||||
"""ACME identifier type."""
|
||||
POSSIBLE_NAMES = {}
|
||||
IDENTIFIER_FQDN = IdentifierType('dns') # IdentifierDNS in Boulder
|
||||
|
||||
|
||||
class Identifier(jose.JSONObjectWithFields):
|
||||
"""ACME identifier.
|
||||
|
||||
:ivar IdentifierType typ:
|
||||
:ivar unicode value:
|
||||
|
||||
"""
|
||||
typ = jose.Field('type', decoder=IdentifierType.from_json)
|
||||
value = jose.Field('value')
|
||||
|
||||
|
||||
class Resource(jose.JSONObjectWithFields):
|
||||
"""ACME Resource.
|
||||
|
||||
:ivar acme.messages.ResourceBody body: Resource body.
|
||||
|
||||
"""
|
||||
body = jose.Field('body')
|
||||
|
||||
|
||||
class ResourceWithURI(Resource):
|
||||
"""ACME Resource with URI.
|
||||
|
||||
:ivar unicode uri: Location of the resource.
|
||||
|
||||
"""
|
||||
uri = jose.Field('uri') # no ChallengeResource.uri
|
||||
|
||||
|
||||
class ResourceBody(jose.JSONObjectWithFields):
|
||||
"""ACME Resource Body."""
|
||||
|
||||
|
||||
class Registration(ResourceBody):
|
||||
"""Registration Resource Body.
|
||||
|
||||
:ivar acme.jose.jwk.JWK key: Public key.
|
||||
:ivar tuple contact: Contact information following ACME spec,
|
||||
`tuple` of `unicode`.
|
||||
:ivar unicode recovery_token:
|
||||
:ivar unicode agreement:
|
||||
|
||||
"""
|
||||
# on new-reg key server ignores 'key' and populates it based on
|
||||
# JWS.signature.combined.jwk
|
||||
key = jose.Field('key', omitempty=True, decoder=jose.JWK.from_json)
|
||||
contact = jose.Field('contact', omitempty=True, default=())
|
||||
recovery_token = jose.Field('recoveryToken', omitempty=True)
|
||||
agreement = jose.Field('agreement', omitempty=True)
|
||||
|
||||
phone_prefix = 'tel:'
|
||||
email_prefix = 'mailto:'
|
||||
|
||||
@classmethod
|
||||
def from_data(cls, phone=None, email=None, **kwargs):
|
||||
"""Create registration resource from contact details."""
|
||||
details = list(kwargs.pop('contact', ()))
|
||||
if phone is not None:
|
||||
details.append(cls.phone_prefix + phone)
|
||||
if email is not None:
|
||||
details.append(cls.email_prefix + email)
|
||||
kwargs['contact'] = tuple(details)
|
||||
return cls(**kwargs)
|
||||
|
||||
def _filter_contact(self, prefix):
|
||||
return tuple(
|
||||
detail[len(prefix):] for detail in self.contact
|
||||
if detail.startswith(prefix))
|
||||
|
||||
@property
|
||||
def phones(self):
|
||||
"""All phones found in the ``contact`` field."""
|
||||
return self._filter_contact(self.phone_prefix)
|
||||
|
||||
@property
|
||||
def emails(self):
|
||||
"""All emails found in the ``contact`` field."""
|
||||
return self._filter_contact(self.email_prefix)
|
||||
|
||||
class NewRegistration(Registration):
|
||||
"""New registration."""
|
||||
resource_type = 'new-reg'
|
||||
resource = fields.Resource(resource_type)
|
||||
|
||||
class UpdateRegistration(Registration):
|
||||
"""Update registration."""
|
||||
resource_type = 'reg'
|
||||
resource = fields.Resource(resource_type)
|
||||
|
||||
class RegistrationResource(ResourceWithURI):
|
||||
"""Registration Resource.
|
||||
|
||||
:ivar acme.messages.Registration body:
|
||||
:ivar unicode new_authzr_uri: URI found in the 'next' ``Link`` header
|
||||
:ivar unicode terms_of_service: URL for the CA TOS.
|
||||
|
||||
"""
|
||||
body = jose.Field('body', decoder=Registration.from_json)
|
||||
new_authzr_uri = jose.Field('new_authzr_uri')
|
||||
terms_of_service = jose.Field('terms_of_service', omitempty=True)
|
||||
|
||||
|
||||
class ChallengeBody(ResourceBody):
|
||||
"""Challenge Resource Body.
|
||||
|
||||
.. todo::
|
||||
Confusingly, this has a similar name to `.challenges.Challenge`,
|
||||
as well as `.achallenges.AnnotatedChallenge`. Please use names
|
||||
such as ``challb`` to distinguish instances of this class from
|
||||
``achall``.
|
||||
|
||||
:ivar acme.challenges.Challenge: Wrapped challenge.
|
||||
Conveniently, all challenge fields are proxied, i.e. you can
|
||||
call ``challb.x`` to get ``challb.chall.x`` contents.
|
||||
:ivar acme.messages.Status status:
|
||||
:ivar datetime.datetime validated:
|
||||
:ivar Error error:
|
||||
|
||||
"""
|
||||
__slots__ = ('chall',)
|
||||
uri = jose.Field('uri')
|
||||
status = jose.Field('status', decoder=Status.from_json,
|
||||
omitempty=True, default=STATUS_PENDING)
|
||||
validated = fields.RFC3339Field('validated', omitempty=True)
|
||||
error = jose.Field('error', decoder=Error.from_json,
|
||||
omitempty=True, default=None)
|
||||
|
||||
def to_partial_json(self):
|
||||
jobj = super(ChallengeBody, self).to_partial_json()
|
||||
jobj.update(self.chall.to_partial_json())
|
||||
return jobj
|
||||
|
||||
@classmethod
|
||||
def fields_from_json(cls, jobj):
|
||||
jobj_fields = super(ChallengeBody, cls).fields_from_json(jobj)
|
||||
jobj_fields['chall'] = challenges.Challenge.from_json(jobj)
|
||||
return jobj_fields
|
||||
|
||||
def __getattr__(self, name):
|
||||
return getattr(self.chall, name)
|
||||
|
||||
|
||||
class ChallengeResource(Resource):
|
||||
"""Challenge Resource.
|
||||
|
||||
:ivar acme.messages.ChallengeBody body:
|
||||
:ivar unicode authzr_uri: URI found in the 'up' ``Link`` header.
|
||||
|
||||
"""
|
||||
body = jose.Field('body', decoder=ChallengeBody.from_json)
|
||||
authzr_uri = jose.Field('authzr_uri')
|
||||
|
||||
@property
|
||||
def uri(self): # pylint: disable=missing-docstring,no-self-argument
|
||||
# bug? 'method already defined line None'
|
||||
# pylint: disable=function-redefined
|
||||
return self.body.uri # pylint: disable=no-member
|
||||
|
||||
|
||||
class Authorization(ResourceBody):
|
||||
"""Authorization Resource Body.
|
||||
|
||||
:ivar acme.messages.Identifier identifier:
|
||||
:ivar list challenges: `list` of `.ChallengeBody`
|
||||
:ivar tuple combinations: Challenge combinations (`tuple` of `tuple`
|
||||
of `int`, as opposed to `list` of `list` from the spec).
|
||||
:ivar acme.messages.Status status:
|
||||
:ivar datetime.datetime expires:
|
||||
|
||||
"""
|
||||
identifier = jose.Field('identifier', decoder=Identifier.from_json)
|
||||
challenges = jose.Field('challenges', omitempty=True)
|
||||
combinations = jose.Field('combinations', omitempty=True)
|
||||
|
||||
status = jose.Field('status', omitempty=True, decoder=Status.from_json)
|
||||
# TODO: 'expires' is allowed for Authorization Resources in
|
||||
# general, but for Key Authorization '[t]he "expires" field MUST
|
||||
# be absent'... then acme-spec gives example with 'expires'
|
||||
# present... That's confusing!
|
||||
expires = fields.RFC3339Field('expires', omitempty=True)
|
||||
|
||||
@challenges.decoder
|
||||
def challenges(value): # pylint: disable=missing-docstring,no-self-argument
|
||||
return tuple(ChallengeBody.from_json(chall) for chall in value)
|
||||
|
||||
@property
|
||||
def resolved_combinations(self):
|
||||
"""Combinations with challenges instead of indices."""
|
||||
return tuple(tuple(self.challenges[idx] for idx in combo)
|
||||
for combo in self.combinations)
|
||||
|
||||
class NewAuthorization(Authorization):
|
||||
"""New authorization."""
|
||||
resource_type = 'new-authz'
|
||||
resource = fields.Resource(resource_type)
|
||||
|
||||
class AuthorizationResource(ResourceWithURI):
|
||||
"""Authorization Resource.
|
||||
|
||||
:ivar acme.messages.Authorization body:
|
||||
:ivar unicode new_cert_uri: URI found in the 'next' ``Link`` header
|
||||
|
||||
"""
|
||||
body = jose.Field('body', decoder=Authorization.from_json)
|
||||
new_cert_uri = jose.Field('new_cert_uri')
|
||||
|
||||
|
||||
class CertificateRequest(jose.JSONObjectWithFields):
|
||||
"""ACME new-cert request.
|
||||
|
||||
:ivar acme.jose.util.ComparableX509 csr:
|
||||
`OpenSSL.crypto.X509Req` wrapped in `.ComparableX509`
|
||||
:ivar tuple authorizations: `tuple` of URIs (`str`)
|
||||
|
||||
"""
|
||||
resource_type = 'new-cert'
|
||||
resource = fields.Resource(resource_type)
|
||||
csr = jose.Field('csr', decoder=jose.decode_csr, encoder=jose.encode_csr)
|
||||
authorizations = jose.Field('authorizations', decoder=tuple)
|
||||
|
||||
|
||||
class CertificateResource(ResourceWithURI):
|
||||
"""Certificate Resource.
|
||||
|
||||
:ivar acme.jose.util.ComparableX509 body:
|
||||
`OpenSSL.crypto.X509` wrapped in `.ComparableX509`
|
||||
:ivar unicode cert_chain_uri: URI found in the 'up' ``Link`` header
|
||||
:ivar tuple authzrs: `tuple` of `AuthorizationResource`.
|
||||
|
||||
"""
|
||||
cert_chain_uri = jose.Field('cert_chain_uri')
|
||||
authzrs = jose.Field('authzrs')
|
||||
|
||||
|
||||
class Revocation(jose.JSONObjectWithFields):
|
||||
"""Revocation message.
|
||||
|
||||
:ivar .ComparableX509 certificate: `OpenSSL.crypto.X509` wrapped in
|
||||
`.ComparableX509`
|
||||
|
||||
"""
|
||||
resource_type = 'revoke-cert'
|
||||
resource = fields.Resource(resource_type)
|
||||
certificate = jose.Field(
|
||||
'certificate', decoder=jose.decode_cert, encoder=jose.encode_cert)
|
||||
|
||||
# TODO: acme-spec#138, this allows only one ACME server instance per domain
|
||||
PATH = '/acme/revoke-cert'
|
||||
"""Path to revocation URL, see `url`"""
|
||||
|
||||
@classmethod
|
||||
def url(cls, base):
|
||||
"""Get revocation URL.
|
||||
|
||||
:param str base: New Registration Resource or server (root) URL.
|
||||
|
||||
"""
|
||||
return urllib_parse.urljoin(base, cls.PATH)
|
||||
331
acme/acme/messages_test.py
Normal file
331
acme/acme/messages_test.py
Normal file
|
|
@ -0,0 +1,331 @@
|
|||
"""Tests for acme.messages."""
|
||||
import unittest
|
||||
|
||||
import mock
|
||||
|
||||
from acme import challenges
|
||||
from acme import jose
|
||||
from acme import test_util
|
||||
|
||||
|
||||
CERT = test_util.load_cert('cert.der')
|
||||
CSR = test_util.load_csr('csr.der')
|
||||
KEY = test_util.load_rsa_private_key('rsa512_key.pem')
|
||||
|
||||
|
||||
class ErrorTest(unittest.TestCase):
|
||||
"""Tests for acme.messages.Error."""
|
||||
|
||||
def setUp(self):
|
||||
from acme.messages import Error
|
||||
self.error = Error(detail='foo', typ='malformed', title='title')
|
||||
self.jobj = {'detail': 'foo', 'title': 'some title'}
|
||||
|
||||
def test_typ_prefix(self):
|
||||
self.assertEqual('malformed', self.error.typ)
|
||||
self.assertEqual(
|
||||
'urn:acme:error:malformed', self.error.to_partial_json()['type'])
|
||||
self.assertEqual(
|
||||
'malformed', self.error.from_json(self.error.to_partial_json()).typ)
|
||||
|
||||
def test_typ_decoder_missing_prefix(self):
|
||||
from acme.messages import Error
|
||||
self.jobj['type'] = 'malformed'
|
||||
self.assertRaises(jose.DeserializationError, Error.from_json, self.jobj)
|
||||
self.jobj['type'] = 'not valid bare type'
|
||||
self.assertRaises(jose.DeserializationError, Error.from_json, self.jobj)
|
||||
|
||||
def test_typ_decoder_not_recognized(self):
|
||||
from acme.messages import Error
|
||||
self.jobj['type'] = 'urn:acme:error:baz'
|
||||
self.assertRaises(jose.DeserializationError, Error.from_json, self.jobj)
|
||||
|
||||
def test_description(self):
|
||||
self.assertEqual(
|
||||
'The request message was malformed', self.error.description)
|
||||
|
||||
def test_from_json_hashable(self):
|
||||
from acme.messages import Error
|
||||
hash(Error.from_json(self.error.to_json()))
|
||||
|
||||
def test_str(self):
|
||||
self.assertEqual(
|
||||
'malformed :: The request message was malformed :: foo',
|
||||
str(self.error))
|
||||
self.assertEqual('foo', str(self.error.update(typ=None)))
|
||||
|
||||
|
||||
class ConstantTest(unittest.TestCase):
|
||||
"""Tests for acme.messages._Constant."""
|
||||
|
||||
def setUp(self):
|
||||
from acme.messages import _Constant
|
||||
class MockConstant(_Constant): # pylint: disable=missing-docstring
|
||||
POSSIBLE_NAMES = {}
|
||||
|
||||
self.MockConstant = MockConstant # pylint: disable=invalid-name
|
||||
self.const_a = MockConstant('a')
|
||||
self.const_b = MockConstant('b')
|
||||
|
||||
def test_to_partial_json(self):
|
||||
self.assertEqual('a', self.const_a.to_partial_json())
|
||||
self.assertEqual('b', self.const_b.to_partial_json())
|
||||
|
||||
def test_from_json(self):
|
||||
self.assertEqual(self.const_a, self.MockConstant.from_json('a'))
|
||||
self.assertRaises(
|
||||
jose.DeserializationError, self.MockConstant.from_json, 'c')
|
||||
|
||||
def test_from_json_hashable(self):
|
||||
hash(self.MockConstant.from_json('a'))
|
||||
|
||||
def test_repr(self):
|
||||
self.assertEqual('MockConstant(a)', repr(self.const_a))
|
||||
self.assertEqual('MockConstant(b)', repr(self.const_b))
|
||||
|
||||
def test_equality(self):
|
||||
const_a_prime = self.MockConstant('a')
|
||||
self.assertFalse(self.const_a == self.const_b)
|
||||
self.assertTrue(self.const_a == const_a_prime)
|
||||
|
||||
self.assertTrue(self.const_a != self.const_b)
|
||||
self.assertFalse(self.const_a != const_a_prime)
|
||||
|
||||
|
||||
class RegistrationTest(unittest.TestCase):
|
||||
"""Tests for acme.messages.Registration."""
|
||||
|
||||
def setUp(self):
|
||||
key = jose.jwk.JWKRSA(key=KEY.public_key())
|
||||
contact = (
|
||||
'mailto:admin@foo.com',
|
||||
'tel:1234',
|
||||
)
|
||||
recovery_token = 'XYZ'
|
||||
agreement = 'https://letsencrypt.org/terms'
|
||||
|
||||
from acme.messages import Registration
|
||||
self.reg = Registration(
|
||||
key=key, contact=contact, recovery_token=recovery_token,
|
||||
agreement=agreement)
|
||||
self.reg_none = Registration()
|
||||
|
||||
self.jobj_to = {
|
||||
'contact': contact,
|
||||
'recoveryToken': recovery_token,
|
||||
'agreement': agreement,
|
||||
'key': key,
|
||||
}
|
||||
self.jobj_from = self.jobj_to.copy()
|
||||
self.jobj_from['key'] = key.to_json()
|
||||
|
||||
def test_from_data(self):
|
||||
from acme.messages import Registration
|
||||
reg = Registration.from_data(phone='1234', email='admin@foo.com')
|
||||
self.assertEqual(reg.contact, (
|
||||
'tel:1234',
|
||||
'mailto:admin@foo.com',
|
||||
))
|
||||
|
||||
def test_phones(self):
|
||||
self.assertEqual(('1234',), self.reg.phones)
|
||||
|
||||
def test_emails(self):
|
||||
self.assertEqual(('admin@foo.com',), self.reg.emails)
|
||||
|
||||
def test_to_partial_json(self):
|
||||
self.assertEqual(self.jobj_to, self.reg.to_partial_json())
|
||||
|
||||
def test_from_json(self):
|
||||
from acme.messages import Registration
|
||||
self.assertEqual(self.reg, Registration.from_json(self.jobj_from))
|
||||
|
||||
def test_from_json_hashable(self):
|
||||
from acme.messages import Registration
|
||||
hash(Registration.from_json(self.jobj_from))
|
||||
|
||||
|
||||
class RegistrationResourceTest(unittest.TestCase):
|
||||
"""Tests for acme.messages.RegistrationResource."""
|
||||
|
||||
def setUp(self):
|
||||
from acme.messages import RegistrationResource
|
||||
self.regr = RegistrationResource(
|
||||
body=mock.sentinel.body, uri=mock.sentinel.uri,
|
||||
new_authzr_uri=mock.sentinel.new_authzr_uri,
|
||||
terms_of_service=mock.sentinel.terms_of_service)
|
||||
|
||||
def test_to_partial_json(self):
|
||||
self.assertEqual(self.regr.to_json(), {
|
||||
'body': mock.sentinel.body,
|
||||
'uri': mock.sentinel.uri,
|
||||
'new_authzr_uri': mock.sentinel.new_authzr_uri,
|
||||
'terms_of_service': mock.sentinel.terms_of_service,
|
||||
})
|
||||
|
||||
|
||||
class ChallengeResourceTest(unittest.TestCase):
|
||||
"""Tests for acme.messages.ChallengeResource."""
|
||||
|
||||
def test_uri(self):
|
||||
from acme.messages import ChallengeResource
|
||||
self.assertEqual('http://challb', ChallengeResource(body=mock.MagicMock(
|
||||
uri='http://challb'), authzr_uri='http://authz').uri)
|
||||
|
||||
|
||||
class ChallengeBodyTest(unittest.TestCase):
|
||||
"""Tests for acme.messages.ChallengeBody."""
|
||||
|
||||
def setUp(self):
|
||||
self.chall = challenges.DNS(token='foo')
|
||||
|
||||
from acme.messages import ChallengeBody
|
||||
from acme.messages import Error
|
||||
from acme.messages import STATUS_INVALID
|
||||
self.status = STATUS_INVALID
|
||||
error = Error(typ='serverInternal',
|
||||
detail='Unable to communicate with DNS server')
|
||||
self.challb = ChallengeBody(
|
||||
uri='http://challb', chall=self.chall, status=self.status,
|
||||
error=error)
|
||||
|
||||
self.jobj_to = {
|
||||
'uri': 'http://challb',
|
||||
'status': self.status,
|
||||
'type': 'dns',
|
||||
'token': 'foo',
|
||||
'error': error,
|
||||
}
|
||||
self.jobj_from = self.jobj_to.copy()
|
||||
self.jobj_from['status'] = 'invalid'
|
||||
self.jobj_from['error'] = {
|
||||
'type': 'urn:acme:error:serverInternal',
|
||||
'detail': 'Unable to communicate with DNS server',
|
||||
}
|
||||
|
||||
|
||||
def test_to_partial_json(self):
|
||||
self.assertEqual(self.jobj_to, self.challb.to_partial_json())
|
||||
|
||||
def test_from_json(self):
|
||||
from acme.messages import ChallengeBody
|
||||
self.assertEqual(self.challb, ChallengeBody.from_json(self.jobj_from))
|
||||
|
||||
def test_from_json_hashable(self):
|
||||
from acme.messages import ChallengeBody
|
||||
hash(ChallengeBody.from_json(self.jobj_from))
|
||||
|
||||
def test_proxy(self):
|
||||
self.assertEqual('foo', self.challb.token)
|
||||
|
||||
|
||||
class AuthorizationTest(unittest.TestCase):
|
||||
"""Tests for acme.messages.Authorization."""
|
||||
|
||||
def setUp(self):
|
||||
from acme.messages import ChallengeBody
|
||||
from acme.messages import STATUS_VALID
|
||||
self.challbs = (
|
||||
ChallengeBody(
|
||||
uri='http://challb1', status=STATUS_VALID,
|
||||
chall=challenges.SimpleHTTP(token='IlirfxKKXAsHtmzK29Pj8A')),
|
||||
ChallengeBody(uri='http://challb2', status=STATUS_VALID,
|
||||
chall=challenges.DNS(token='DGyRejmCefe7v4NfDGDKfA')),
|
||||
ChallengeBody(uri='http://challb3', status=STATUS_VALID,
|
||||
chall=challenges.RecoveryToken()),
|
||||
)
|
||||
combinations = ((0, 2), (1, 2))
|
||||
|
||||
from acme.messages import Authorization
|
||||
from acme.messages import Identifier
|
||||
from acme.messages import IDENTIFIER_FQDN
|
||||
identifier = Identifier(typ=IDENTIFIER_FQDN, value='example.com')
|
||||
self.authz = Authorization(
|
||||
identifier=identifier, combinations=combinations,
|
||||
challenges=self.challbs)
|
||||
|
||||
self.jobj_from = {
|
||||
'identifier': identifier.to_json(),
|
||||
'challenges': [challb.to_json() for challb in self.challbs],
|
||||
'combinations': combinations,
|
||||
}
|
||||
|
||||
def test_from_json(self):
|
||||
from acme.messages import Authorization
|
||||
Authorization.from_json(self.jobj_from)
|
||||
|
||||
def test_from_json_hashable(self):
|
||||
from acme.messages import Authorization
|
||||
hash(Authorization.from_json(self.jobj_from))
|
||||
|
||||
def test_resolved_combinations(self):
|
||||
self.assertEqual(self.authz.resolved_combinations, (
|
||||
(self.challbs[0], self.challbs[2]),
|
||||
(self.challbs[1], self.challbs[2]),
|
||||
))
|
||||
|
||||
|
||||
class AuthorizationResourceTest(unittest.TestCase):
|
||||
"""Tests for acme.messages.AuthorizationResource."""
|
||||
|
||||
def test_json_de_serializable(self):
|
||||
from acme.messages import AuthorizationResource
|
||||
authzr = AuthorizationResource(
|
||||
uri=mock.sentinel.uri,
|
||||
body=mock.sentinel.body,
|
||||
new_cert_uri=mock.sentinel.new_cert_uri,
|
||||
)
|
||||
self.assertTrue(isinstance(authzr, jose.JSONDeSerializable))
|
||||
|
||||
|
||||
class CertificateRequestTest(unittest.TestCase):
|
||||
"""Tests for acme.messages.CertificateRequest."""
|
||||
|
||||
def setUp(self):
|
||||
from acme.messages import CertificateRequest
|
||||
self.req = CertificateRequest(csr=CSR, authorizations=('foo',))
|
||||
|
||||
def test_json_de_serializable(self):
|
||||
self.assertTrue(isinstance(self.req, jose.JSONDeSerializable))
|
||||
from acme.messages import CertificateRequest
|
||||
self.assertEqual(
|
||||
self.req, CertificateRequest.from_json(self.req.to_json()))
|
||||
|
||||
|
||||
class CertificateResourceTest(unittest.TestCase):
|
||||
"""Tests for acme.messages.CertificateResourceTest."""
|
||||
|
||||
def setUp(self):
|
||||
from acme.messages import CertificateResource
|
||||
self.certr = CertificateResource(
|
||||
body=CERT, uri=mock.sentinel.uri, authzrs=(),
|
||||
cert_chain_uri=mock.sentinel.cert_chain_uri)
|
||||
|
||||
def test_json_de_serializable(self):
|
||||
self.assertTrue(isinstance(self.certr, jose.JSONDeSerializable))
|
||||
from acme.messages import CertificateResource
|
||||
self.assertEqual(
|
||||
self.certr, CertificateResource.from_json(self.certr.to_json()))
|
||||
|
||||
|
||||
class RevocationTest(unittest.TestCase):
|
||||
"""Tests for acme.messages.RevocationTest."""
|
||||
|
||||
def test_url(self):
|
||||
from acme.messages import Revocation
|
||||
url = 'https://letsencrypt-demo.org/acme/revoke-cert'
|
||||
self.assertEqual(url, Revocation.url('https://letsencrypt-demo.org'))
|
||||
self.assertEqual(
|
||||
url, Revocation.url('https://letsencrypt-demo.org/acme/new-reg'))
|
||||
|
||||
def setUp(self):
|
||||
from acme.messages import Revocation
|
||||
self.rev = Revocation(certificate=CERT)
|
||||
|
||||
def test_from_json_hashable(self):
|
||||
from acme.messages import Revocation
|
||||
hash(Revocation.from_json(self.rev.to_json()))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main() # pragma: no cover
|
||||
67
acme/acme/other.py
Normal file
67
acme/acme/other.py
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
"""Other ACME objects."""
|
||||
import functools
|
||||
import logging
|
||||
import os
|
||||
|
||||
from acme import jose
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Signature(jose.JSONObjectWithFields):
|
||||
"""ACME signature.
|
||||
|
||||
:ivar .JWASignature alg: Signature algorithm.
|
||||
:ivar bytes sig: Signature.
|
||||
:ivar bytes nonce: Nonce.
|
||||
:ivar .JWK jwk: JWK.
|
||||
|
||||
"""
|
||||
NONCE_SIZE = 16
|
||||
"""Minimum size of nonce in bytes."""
|
||||
|
||||
alg = jose.Field('alg', decoder=jose.JWASignature.from_json)
|
||||
sig = jose.Field('sig', encoder=jose.encode_b64jose,
|
||||
decoder=jose.decode_b64jose)
|
||||
nonce = jose.Field(
|
||||
'nonce', encoder=jose.encode_b64jose, decoder=functools.partial(
|
||||
jose.decode_b64jose, size=NONCE_SIZE, minimum=True))
|
||||
jwk = jose.Field('jwk', decoder=jose.JWK.from_json)
|
||||
|
||||
@classmethod
|
||||
def from_msg(cls, msg, key, nonce=None, nonce_size=None, alg=jose.RS256):
|
||||
"""Create signature with nonce prepended to the message.
|
||||
|
||||
:param bytes msg: Message to be signed.
|
||||
|
||||
:param key: Key used for signing.
|
||||
:type key: `cryptography.hazmat.primitives.assymetric.rsa.RSAPrivateKey`
|
||||
(optionally wrapped in `.ComparableRSAKey`).
|
||||
|
||||
:param bytes nonce: Nonce to be used. If None, nonce of
|
||||
``nonce_size`` will be randomly generated.
|
||||
:param int nonce_size: Size of the automatically generated nonce.
|
||||
Defaults to :const:`NONCE_SIZE`.
|
||||
|
||||
:param .JWASignature alg:
|
||||
|
||||
"""
|
||||
nonce_size = cls.NONCE_SIZE if nonce_size is None else nonce_size
|
||||
nonce = os.urandom(nonce_size) if nonce is None else nonce
|
||||
|
||||
msg_with_nonce = nonce + msg
|
||||
sig = alg.sign(key, nonce + msg)
|
||||
logger.debug('%r signed as %r', msg_with_nonce, sig)
|
||||
|
||||
return cls(alg=alg, sig=sig, nonce=nonce,
|
||||
jwk=alg.kty(key=key.public_key()))
|
||||
|
||||
def verify(self, msg):
|
||||
"""Verify the signature.
|
||||
|
||||
:param bytes msg: Message that was used in signing.
|
||||
|
||||
"""
|
||||
# self.alg is not Field, but JWA | pylint: disable=no-member
|
||||
return self.alg.verify(self.jwk.key, self.nonce + msg, self.sig)
|
||||
94
acme/acme/other_test.py
Normal file
94
acme/acme/other_test.py
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
"""Tests for acme.sig."""
|
||||
import unittest
|
||||
|
||||
from acme import jose
|
||||
from acme import test_util
|
||||
|
||||
|
||||
KEY = test_util.load_rsa_private_key('rsa512_key.pem')
|
||||
|
||||
|
||||
class SignatureTest(unittest.TestCase):
|
||||
# pylint: disable=too-many-instance-attributes
|
||||
"""Tests for acme.sig.Signature."""
|
||||
|
||||
def setUp(self):
|
||||
self.msg = b'message'
|
||||
self.sig = (b'IC\xd8*\xe7\x14\x9e\x19S\xb7\xcf\xec3\x12\xe2\x8a\x03'
|
||||
b'\x98u\xff\xf0\x94\xe2\xd7<\x8f\xa8\xed\xa4KN\xc3\xaa'
|
||||
b'\xb9X\xc3w\xaa\xc0_\xd0\x05$y>l#\x10<\x96\xd2\xcdr\xa3'
|
||||
b'\x1b\xa1\xf5!f\xef\xc64\xb6\x13')
|
||||
self.nonce = b'\xec\xd6\xf2oYH\xeb\x13\xd5#q\xe0\xdd\xa2\x92\xa9'
|
||||
|
||||
self.alg = jose.RS256
|
||||
self.jwk = jose.JWKRSA(key=KEY.public_key())
|
||||
|
||||
b64sig = ('SUPYKucUnhlTt8_sMxLiigOYdf_wlOLXPI-o7aRLTsOquVjDd6r'
|
||||
'AX9AFJHk-bCMQPJbSzXKjG6H1IWbvxjS2Ew')
|
||||
b64nonce = '7Nbyb1lI6xPVI3Hg3aKSqQ'
|
||||
self.jsig_to = {
|
||||
'nonce': b64nonce,
|
||||
'alg': self.alg,
|
||||
'jwk': self.jwk,
|
||||
'sig': b64sig,
|
||||
}
|
||||
|
||||
self.jsig_from = {
|
||||
'nonce': b64nonce,
|
||||
'alg': self.alg.to_partial_json(),
|
||||
'jwk': self.jwk.to_partial_json(),
|
||||
'sig': b64sig,
|
||||
}
|
||||
|
||||
from acme.other import Signature
|
||||
self.signature = Signature(
|
||||
alg=self.alg, sig=self.sig, nonce=self.nonce, jwk=self.jwk)
|
||||
|
||||
def test_attributes(self):
|
||||
self.assertEqual(self.signature.nonce, self.nonce)
|
||||
self.assertEqual(self.signature.alg, self.alg)
|
||||
self.assertEqual(self.signature.sig, self.sig)
|
||||
self.assertEqual(self.signature.jwk, self.jwk)
|
||||
|
||||
def test_verify_good_succeeds(self):
|
||||
self.assertTrue(self.signature.verify(self.msg))
|
||||
|
||||
def test_verify_bad_fails(self):
|
||||
self.assertFalse(self.signature.verify(self.msg + b'x'))
|
||||
|
||||
@classmethod
|
||||
def _from_msg(cls, *args, **kwargs):
|
||||
from acme.other import Signature
|
||||
return Signature.from_msg(*args, **kwargs)
|
||||
|
||||
def test_create_from_msg(self):
|
||||
signature = self._from_msg(self.msg, KEY, self.nonce)
|
||||
self.assertEqual(self.signature, signature)
|
||||
|
||||
def test_create_from_msg_random_nonce(self):
|
||||
signature = self._from_msg(self.msg, KEY)
|
||||
self.assertEqual(signature.alg, self.alg)
|
||||
self.assertEqual(signature.jwk, self.jwk)
|
||||
self.assertTrue(signature.verify(self.msg))
|
||||
|
||||
def test_to_partial_json(self):
|
||||
self.assertEqual(self.signature.to_partial_json(), self.jsig_to)
|
||||
|
||||
def test_from_json(self):
|
||||
from acme.other import Signature
|
||||
self.assertEqual(
|
||||
self.signature, Signature.from_json(self.jsig_from))
|
||||
|
||||
def test_from_json_non_schema_errors(self):
|
||||
from acme.other import Signature
|
||||
jwk = self.jwk.to_partial_json()
|
||||
self.assertRaises(
|
||||
jose.DeserializationError, Signature.from_json, {
|
||||
'alg': 'RS256', 'sig': 'x', 'nonce': '', 'jwk': jwk})
|
||||
self.assertRaises(
|
||||
jose.DeserializationError, Signature.from_json, {
|
||||
'alg': 'RS256', 'sig': '', 'nonce': 'x', 'jwk': jwk})
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main() # pragma: no cover
|
||||
63
acme/acme/test_util.py
Normal file
63
acme/acme/test_util.py
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
# Symlinked in letsencrypt/tests/test_util.py, casues duplicate-code
|
||||
# warning that cannot be disabled locally.
|
||||
"""Test utilities.
|
||||
|
||||
.. warning:: This module is not part of the public API.
|
||||
|
||||
"""
|
||||
import os
|
||||
import pkg_resources
|
||||
|
||||
from cryptography.hazmat.backends import default_backend
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
import OpenSSL
|
||||
|
||||
from acme import jose
|
||||
|
||||
|
||||
def vector_path(*names):
|
||||
"""Path to a test vector."""
|
||||
return pkg_resources.resource_filename(
|
||||
__name__, os.path.join('testdata', *names))
|
||||
|
||||
def load_vector(*names):
|
||||
"""Load contents of a test vector."""
|
||||
# luckily, resource_string opens file in binary mode
|
||||
return pkg_resources.resource_string(
|
||||
__name__, os.path.join('testdata', *names))
|
||||
|
||||
def _guess_loader(filename, loader_pem, loader_der):
|
||||
_, ext = os.path.splitext(filename)
|
||||
if ext.lower() == '.pem':
|
||||
return loader_pem
|
||||
elif ext.lower() == '.der':
|
||||
return loader_der
|
||||
else: # pragma: no cover
|
||||
raise ValueError("Loader could not be recognized based on extension")
|
||||
|
||||
def load_cert(*names):
|
||||
"""Load certificate."""
|
||||
loader = _guess_loader(
|
||||
names[-1], OpenSSL.crypto.FILETYPE_PEM, OpenSSL.crypto.FILETYPE_ASN1)
|
||||
return jose.ComparableX509(OpenSSL.crypto.load_certificate(
|
||||
loader, load_vector(*names)))
|
||||
|
||||
def load_csr(*names):
|
||||
"""Load certificate request."""
|
||||
loader = _guess_loader(
|
||||
names[-1], OpenSSL.crypto.FILETYPE_PEM, OpenSSL.crypto.FILETYPE_ASN1)
|
||||
return jose.ComparableX509(OpenSSL.crypto.load_certificate_request(
|
||||
loader, load_vector(*names)))
|
||||
|
||||
def load_rsa_private_key(*names):
|
||||
"""Load RSA private key."""
|
||||
loader = _guess_loader(names[-1], serialization.load_pem_private_key,
|
||||
serialization.load_der_private_key)
|
||||
return jose.ComparableRSAKey(loader(
|
||||
load_vector(*names), password=None, backend=default_backend()))
|
||||
|
||||
def load_pyopenssl_private_key(*names):
|
||||
"""Load pyOpenSSL private key."""
|
||||
loader = _guess_loader(
|
||||
names[-1], OpenSSL.crypto.FILETYPE_PEM, OpenSSL.crypto.FILETYPE_ASN1)
|
||||
return OpenSSL.crypto.load_privatekey(loader, load_vector(*names))
|
||||
15
acme/acme/testdata/README
vendored
Normal file
15
acme/acme/testdata/README
vendored
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
In order for acme.test_util._guess_loader to work properly, make sure
|
||||
to use appropriate extension for vector filenames: .pem for PEM and
|
||||
.der for DER.
|
||||
|
||||
The following command has been used to generate test keys:
|
||||
|
||||
for x in 256 512 1024; do openssl genrsa -out rsa${k}_key.pem $k; done
|
||||
|
||||
and for the CSR:
|
||||
|
||||
openssl req -key rsa512_key.pem -new -subj '/CN=example.com' -outform DER > csr.der
|
||||
|
||||
and for the certificate:
|
||||
|
||||
openssl req -key rsa512_key.pem -new -subj '/CN=example.com' -x509 -outform DER > cert.der
|
||||
14
acme/acme/testdata/cert-san.pem
vendored
Normal file
14
acme/acme/testdata/cert-san.pem
vendored
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
-----BEGIN CERTIFICATE-----
|
||||
MIICFjCCAcCgAwIBAgICBTkwDQYJKoZIhvcNAQELBQAwdzELMAkGA1UEBhMCVVMx
|
||||
ETAPBgNVBAgMCE1pY2hpZ2FuMRIwEAYDVQQHDAlBbm4gQXJib3IxKzApBgNVBAoM
|
||||
IlVuaXZlcnNpdHkgb2YgTWljaGlnYW4gYW5kIHRoZSBFRkYxFDASBgNVBAMMC2V4
|
||||
YW1wbGUuY29tMB4XDTE0MTIxMTIyMzQ0NVoXDTE0MTIxODIyMzQ0NVowdzELMAkG
|
||||
A1UEBhMCVVMxETAPBgNVBAgMCE1pY2hpZ2FuMRIwEAYDVQQHDAlBbm4gQXJib3Ix
|
||||
KzApBgNVBAoMIlVuaXZlcnNpdHkgb2YgTWljaGlnYW4gYW5kIHRoZSBFRkYxFDAS
|
||||
BgNVBAMMC2V4YW1wbGUuY29tMFwwDQYJKoZIhvcNAQEBBQADSwAwSAJBAKx1c7RR
|
||||
7R/drnBSQ/zfx1vQLHUbFLh1AQQQ5R8DZUXd36efNK79vukFhN9HFoHZiUvOjm0c
|
||||
+pVE6K+EdE/twuUCAwEAAaM2MDQwCQYDVR0TBAIwADAnBgNVHREEIDAeggtleGFt
|
||||
cGxlLmNvbYIPd3d3LmV4YW1wbGUuY29tMA0GCSqGSIb3DQEBCwUAA0EASuvNKFTF
|
||||
nTJsvnSXn52f4BMZJJ2id/kW7+r+FJRm+L20gKQ1aqq8d3e/lzRUrv5SMf1TAOe7
|
||||
RDjyGMKy5ZgM2w==
|
||||
-----END CERTIFICATE-----
|
||||
BIN
acme/acme/testdata/cert.der
vendored
Normal file
BIN
acme/acme/testdata/cert.der
vendored
Normal file
Binary file not shown.
13
acme/acme/testdata/cert.pem
vendored
Normal file
13
acme/acme/testdata/cert.pem
vendored
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
-----BEGIN CERTIFICATE-----
|
||||
MIIB3jCCAYigAwIBAgICBTkwDQYJKoZIhvcNAQELBQAwdzELMAkGA1UEBhMCVVMx
|
||||
ETAPBgNVBAgMCE1pY2hpZ2FuMRIwEAYDVQQHDAlBbm4gQXJib3IxKzApBgNVBAoM
|
||||
IlVuaXZlcnNpdHkgb2YgTWljaGlnYW4gYW5kIHRoZSBFRkYxFDASBgNVBAMMC2V4
|
||||
YW1wbGUuY29tMB4XDTE0MTIxMTIyMzQ0NVoXDTE0MTIxODIyMzQ0NVowdzELMAkG
|
||||
A1UEBhMCVVMxETAPBgNVBAgMCE1pY2hpZ2FuMRIwEAYDVQQHDAlBbm4gQXJib3Ix
|
||||
KzApBgNVBAoMIlVuaXZlcnNpdHkgb2YgTWljaGlnYW4gYW5kIHRoZSBFRkYxFDAS
|
||||
BgNVBAMMC2V4YW1wbGUuY29tMFwwDQYJKoZIhvcNAQEBBQADSwAwSAJBAKx1c7RR
|
||||
7R/drnBSQ/zfx1vQLHUbFLh1AQQQ5R8DZUXd36efNK79vukFhN9HFoHZiUvOjm0c
|
||||
+pVE6K+EdE/twuUCAwEAATANBgkqhkiG9w0BAQsFAANBAC24z0IdwIVKSlntksll
|
||||
vr6zJepBH5fMndfk3XJp10jT6VE+14KNtjh02a56GoraAvJAT5/H67E8GvJ/ocNn
|
||||
B/o=
|
||||
-----END CERTIFICATE-----
|
||||
12
acme/acme/testdata/csr-6sans.pem
vendored
Normal file
12
acme/acme/testdata/csr-6sans.pem
vendored
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
-----BEGIN CERTIFICATE REQUEST-----
|
||||
MIIBuzCCAWUCAQAweTELMAkGA1UEBhMCVVMxETAPBgNVBAgTCE1pY2hpZ2FuMRIw
|
||||
EAYDVQQHEwlBbm4gQXJib3IxDDAKBgNVBAoTA0VGRjEfMB0GA1UECxMWVW5pdmVy
|
||||
c2l0eSBvZiBNaWNoaWdhbjEUMBIGA1UEAxMLZXhhbXBsZS5jb20wXDANBgkqhkiG
|
||||
9w0BAQEFAANLADBIAkEA9LYRcVE3Nr+qleecEcX8JwVDnjeG1X7ucsCasuuZM0e0
|
||||
9cmYuUzxIkMjO/9x4AVcvXXRXPEV+LzWWkfkTlzRMwIDAQABoIGGMIGDBgkqhkiG
|
||||
9w0BCQ4xdjB0MHIGA1UdEQRrMGmCC2V4YW1wbGUuY29tggtleGFtcGxlLm9yZ4IL
|
||||
ZXhhbXBsZS5uZXSCDGV4YW1wbGUuaW5mb4IVc3ViZG9tYWluLmV4YW1wbGUuY29t
|
||||
ghtvdGhlci5zdWJkb21haW4uZXhhbXBsZS5jb20wDQYJKoZIhvcNAQELBQADQQBd
|
||||
k4BE5qvEvkYoZM/2++Xd9RrQ6wsdj0QiJQCozfsI4lQx6ZJnbtNc7HpDrX4W6XIv
|
||||
IvzVBz/nD11drfz/RNuX
|
||||
-----END CERTIFICATE REQUEST-----
|
||||
8
acme/acme/testdata/csr-nosans.pem
vendored
Normal file
8
acme/acme/testdata/csr-nosans.pem
vendored
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
-----BEGIN CERTIFICATE REQUEST-----
|
||||
MIIBFTCBwAIBADBbMQswCQYDVQQGEwJBVTETMBEGA1UECAwKU29tZS1TdGF0ZTEh
|
||||
MB8GA1UECgwYSW50ZXJuZXQgV2lkZ2l0cyBQdHkgTHRkMRQwEgYDVQQDDAtleGFt
|
||||
cGxlLm9yZzBcMA0GCSqGSIb3DQEBAQUAA0sAMEgCQQD0thFxUTc2v6qV55wRxfwn
|
||||
BUOeN4bVfu5ywJqy65kzR7T1yZi5TPEiQyM7/3HgBVy9ddFc8RX4vNZaR+ROXNEz
|
||||
AgMBAAGgADANBgkqhkiG9w0BAQsFAANBAMikGL8Ch7hQCStXH7chhDp6+pt2+VSo
|
||||
wgsrPQ2Bw4veDMlSemUrH+4e0TwbbntHfvXTDHWs9P3BiIDJLxFrjuA=
|
||||
-----END CERTIFICATE REQUEST-----
|
||||
10
acme/acme/testdata/csr-san.pem
vendored
Normal file
10
acme/acme/testdata/csr-san.pem
vendored
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
-----BEGIN CERTIFICATE REQUEST-----
|
||||
MIIBbjCCARgCAQAweTELMAkGA1UEBhMCVVMxETAPBgNVBAgMCE1pY2hpZ2FuMRIw
|
||||
EAYDVQQHDAlBbm4gQXJib3IxDDAKBgNVBAoMA0VGRjEfMB0GA1UECwwWVW5pdmVy
|
||||
c2l0eSBvZiBNaWNoaWdhbjEUMBIGA1UEAwwLZXhhbXBsZS5jb20wXDANBgkqhkiG
|
||||
9w0BAQEFAANLADBIAkEArHVztFHtH92ucFJD/N/HW9AsdRsUuHUBBBDlHwNlRd3f
|
||||
p580rv2+6QWE30cWgdmJS86ObRz6lUTor4R0T+3C5QIDAQABoDowOAYJKoZIhvcN
|
||||
AQkOMSswKTAnBgNVHREEIDAeggtleGFtcGxlLmNvbYIPd3d3LmV4YW1wbGUuY29t
|
||||
MA0GCSqGSIb3DQEBCwUAA0EAZGBM8J1rRs7onFgtc76mOeoT1c3v0ZsEmxQfb2Wy
|
||||
tmReY6X1N4cs38D9VSow+VMRu2LWkKvzS7RUFSaTaeQz1A==
|
||||
-----END CERTIFICATE REQUEST-----
|
||||
BIN
acme/acme/testdata/csr.der
vendored
Normal file
BIN
acme/acme/testdata/csr.der
vendored
Normal file
Binary file not shown.
10
acme/acme/testdata/csr.pem
vendored
Normal file
10
acme/acme/testdata/csr.pem
vendored
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
-----BEGIN CERTIFICATE REQUEST-----
|
||||
MIIBXTCCAQcCAQAweTELMAkGA1UEBhMCVVMxETAPBgNVBAgMCE1pY2hpZ2FuMRIw
|
||||
EAYDVQQHDAlBbm4gQXJib3IxDDAKBgNVBAoMA0VGRjEfMB0GA1UECwwWVW5pdmVy
|
||||
c2l0eSBvZiBNaWNoaWdhbjEUMBIGA1UEAwwLZXhhbXBsZS5jb20wXDANBgkqhkiG
|
||||
9w0BAQEFAANLADBIAkEArHVztFHtH92ucFJD/N/HW9AsdRsUuHUBBBDlHwNlRd3f
|
||||
p580rv2+6QWE30cWgdmJS86ObRz6lUTor4R0T+3C5QIDAQABoCkwJwYJKoZIhvcN
|
||||
AQkOMRowGDAWBgNVHREEDzANggtleGFtcGxlLmNvbTANBgkqhkiG9w0BAQsFAANB
|
||||
AHJH/O6BtC9aGzEVCMGOZ7z9iIRHWSzr9x/bOzn7hLwsbXPAgO1QxEwL+X+4g20G
|
||||
n9XBE1N9W6HCIEut2d8wACg=
|
||||
-----END CERTIFICATE REQUEST-----
|
||||
14
acme/acme/testdata/dsa512_key.pem
vendored
Normal file
14
acme/acme/testdata/dsa512_key.pem
vendored
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
-----BEGIN DSA PARAMETERS-----
|
||||
MIGdAkEAwebEoGBfokKQeALHHnAZMQwYU35ILEBdV8oUmzv7qpSVUoHihyqfn6GC
|
||||
OixAKSP8EJYcTilIqPbFbfFyOPlbLwIVANoFHEDiQgknAvKrG78pHzAJdQSPAkEA
|
||||
qfka5Bnl+CeEMpzVZGrOVqZE/LFdZK9eT6YtWjzqtIkf3hwXUVxJsTnBG4xmrfvl
|
||||
41pgNJpgu99YOYqPpS0g7A==
|
||||
-----END DSA PARAMETERS-----
|
||||
-----BEGIN DSA PRIVATE KEY-----
|
||||
MIH5AgEAAkEAwebEoGBfokKQeALHHnAZMQwYU35ILEBdV8oUmzv7qpSVUoHihyqf
|
||||
n6GCOixAKSP8EJYcTilIqPbFbfFyOPlbLwIVANoFHEDiQgknAvKrG78pHzAJdQSP
|
||||
AkEAqfka5Bnl+CeEMpzVZGrOVqZE/LFdZK9eT6YtWjzqtIkf3hwXUVxJsTnBG4xm
|
||||
rfvl41pgNJpgu99YOYqPpS0g7AJATQ2LUzjGQSM6UljcPY5I2OD9THkUR9kH2tth
|
||||
zZd70UoI9btrVaTizgqYShuok94glSQNK0H92JgUk3scJPaAkAIVAMDn61h6vrCE
|
||||
mNv063So6E+eYaIN
|
||||
-----END DSA PRIVATE KEY-----
|
||||
15
acme/acme/testdata/rsa1024_key.pem
vendored
Normal file
15
acme/acme/testdata/rsa1024_key.pem
vendored
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
-----BEGIN RSA PRIVATE KEY-----
|
||||
MIICXAIBAAKBgQCaifO0fGlcAcjjcYEAPYcIL0Hf0KiNa9VCJ14RBdlZxLWRrVFi
|
||||
4tdNCKSKqzKuKrrA8DWd4PHFD7UpLyRrPPXY6GozAyCT+5UFBClGJ2KyNKu+eU6/
|
||||
w4C1kpO4lpeXs8ptFc1lA9P8V1M/MkWzTE402nPNK0uUmZNo2tsFpGJUSQIDAQAB
|
||||
AoGAFjLWxQhSAhtnhfRZ+XTdHrnbFpFchOQGgDgzdPKIJDLzefeRh0jacIBbUmgB
|
||||
Ia+Vn/1hVkpnsEzvUvkonBbnoYWlYVQdpNTmrrew7SOztf8/1fYCsSkyDAvqGTXc
|
||||
TmHM0PaLS+junoWcKOvQRVb0N3k+43OnBkr2b393Sx30qGECQQDNO2IBWOsYs8cB
|
||||
CZQAZs8zBlbwBFZibqovqpLwXt9adBIsT9XzgagGbJMpzSuoHTUn3QqqJd9uHD8X
|
||||
UTmmoh4NAkEAwMRauo+PlNj8W1lusflko52KL17+E5cmeOERM2xvhZNpO7d3/1ak
|
||||
Co9dxVMicrYSh7jXbcXFNt3xNDTv6Dg8LQJAPuJwMDt/pc0IMCAwMkNOP7M0lkyt
|
||||
73E7QmnAplhblcq0+tDnnLpgsr84BHnyY4u3iuRm7SW3pXSQPGPOB2nrTQJANBXa
|
||||
HgakWSe4KEal7ljgpITwzZPxOwHgV1EZALgP+hu2l3gfaFLUyDWstKCd8jjYEOwU
|
||||
6YhCnWyiu+SB3lEzkQJBAJapJpfypFyY8kQNYlYILLBcPu5fmy3QUZKHJ4L3rIVJ
|
||||
c2UTLMeBBgGFHT04CtWntmjwzSv+V6lwiCxKXsIUySc=
|
||||
-----END RSA PRIVATE KEY-----
|
||||
6
acme/acme/testdata/rsa256_key.pem
vendored
Normal file
6
acme/acme/testdata/rsa256_key.pem
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
-----BEGIN RSA PRIVATE KEY-----
|
||||
MIGrAgEAAiEAm2Fylv+Uz7trgTW8EBHP3FQSMeZs2GNQ6VRo1sIVJEkCAwEAAQIh
|
||||
AJT0BA/xD01dFCAXzSNyj9nfSZa3NpqzJZZn/eOm7vghAhEAzUVNZn4lLLBD1R6N
|
||||
E8TKNQIRAMHHyn3O5JeY36lwKwkUlEUCEAliRauN0L0+QZuYjfJ9aJECEGx4dru3
|
||||
rTPCyighdqWNlHUCEQCiLjlwSRtWgmMBudCkVjzt
|
||||
-----END RSA PRIVATE KEY-----
|
||||
9
acme/acme/testdata/rsa512_key.pem
vendored
Normal file
9
acme/acme/testdata/rsa512_key.pem
vendored
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
-----BEGIN RSA PRIVATE KEY-----
|
||||
MIIBOgIBAAJBAKx1c7RR7R/drnBSQ/zfx1vQLHUbFLh1AQQQ5R8DZUXd36efNK79
|
||||
vukFhN9HFoHZiUvOjm0c+pVE6K+EdE/twuUCAwEAAQJAMbrEnJCrQe8YqAbw1/Bn
|
||||
elAzIamndfE3U8bTavf9sgFpS4HL83rhd6PDbvx81ucaJAT/5x048fM/nFl4fzAc
|
||||
mQIhAOF/a9o3EIsDKEmUl+Z1OaOiUxDF3kqWSmALEsmvDhwXAiEAw8ljV5RO/rUp
|
||||
Zu2YMDFq3MKpyyMgBIJ8CxmGRc6gCmMCIGRQzkcmhfqBrhOFwkmozrqIBRIKJIjj
|
||||
8TRm2LXWZZ2DAiAqVO7PztdNpynugUy4jtbGKKjBrTSNBRGA7OHlUgm0dQIhALQq
|
||||
6oGU29Vxlvt3k0vmiRKU4AVfLyNXIGtcWcNG46h/
|
||||
-----END RSA PRIVATE KEY-----
|
||||
48
acme/setup.py
Normal file
48
acme/setup.py
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
import sys
|
||||
|
||||
from setuptools import setup
|
||||
from setuptools import find_packages
|
||||
|
||||
|
||||
install_requires = [
|
||||
'argparse',
|
||||
# load_pem_private/public_key (>=0.6)
|
||||
# rsa_recover_prime_factors (>=0.8)
|
||||
'cryptography>=0.8',
|
||||
'mock<1.1.0', # py26
|
||||
'pyrfc3339',
|
||||
'ndg-httpsclient', # urllib3 InsecurePlatformWarning (#304)
|
||||
'pyasn1', # urllib3 InsecurePlatformWarning (#304)
|
||||
# Connection.set_tlsext_host_name (>=0.13), X509Req.get_extensions (>=0.15)
|
||||
'PyOpenSSL>=0.15',
|
||||
'pytz',
|
||||
'requests',
|
||||
'six',
|
||||
'werkzeug',
|
||||
]
|
||||
|
||||
# env markers in extras_require cause problems with older pip: #517
|
||||
if sys.version_info < (2, 7):
|
||||
# only some distros recognize stdlib argparse as already satisfying
|
||||
install_requires.append('argparse')
|
||||
|
||||
testing_extras = [
|
||||
'nose',
|
||||
'tox',
|
||||
]
|
||||
|
||||
|
||||
setup(
|
||||
name='acme',
|
||||
packages=find_packages(),
|
||||
install_requires=install_requires,
|
||||
extras_require={
|
||||
'testing': testing_extras,
|
||||
},
|
||||
entry_points={
|
||||
'console_scripts': [
|
||||
'jws = acme.jose.jws:CLI.run',
|
||||
],
|
||||
},
|
||||
test_suite='acme',
|
||||
)
|
||||
7
bootstrap/README
Normal file
7
bootstrap/README
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
This directory contains scripts that install necessary OS-specific
|
||||
prerequisite dependencies (see docs/using.rst).
|
||||
|
||||
General dependencies:
|
||||
- git-core: requirements.txt git+https://*
|
||||
- ca-certificates: communication with demo ACMO server at
|
||||
https://www.letsencrypt-demo.org, requirements.txt git+https://*
|
||||
57
bootstrap/_deb_common.sh
Executable file
57
bootstrap/_deb_common.sh
Executable file
|
|
@ -0,0 +1,57 @@
|
|||
#!/bin/sh
|
||||
|
||||
# Tested with:
|
||||
# - Ubuntu:
|
||||
# - 12.04 (x64, Travis)
|
||||
# - 14.04 (x64, Vagrant)
|
||||
# - 14.10 (x64)
|
||||
# - Debian:
|
||||
# - 6.0.10 "squeeze" (x64)
|
||||
# - 7.8 "wheezy" (x64)
|
||||
# - 8.0 "jessie" (x64)
|
||||
# - Raspbian:
|
||||
# - 7.8 (armhf)
|
||||
|
||||
|
||||
# virtualenv binary can be found in different packages depending on
|
||||
# distro version (#346)
|
||||
newer () {
|
||||
apt-get install -y lsb-release --no-install-recommends
|
||||
distro=$(lsb_release -si)
|
||||
# 6.0.10 => 60, 14.04 => 1404
|
||||
# TODO: in sid version==unstable
|
||||
version=$(lsb_release -sr | awk -F '.' '{print $1 $2}')
|
||||
if [ "$distro" = "Ubuntu" -a "$version" -ge 1410 ]
|
||||
then
|
||||
return 0;
|
||||
elif [ "$distro" = "Debian" -a "$version" -ge 80 ]
|
||||
then
|
||||
return 0;
|
||||
else
|
||||
return 1;
|
||||
fi
|
||||
}
|
||||
|
||||
apt-get update
|
||||
|
||||
# you can force newer if lsb_release is not available (e.g. Docker
|
||||
# debian:jessie base image)
|
||||
if [ "$1" = "newer" ] || newer
|
||||
then
|
||||
virtualenv="virtualenv"
|
||||
else
|
||||
virtualenv="python-virtualenv"
|
||||
fi
|
||||
|
||||
|
||||
apt-get install -y --no-install-recommends \
|
||||
git-core \
|
||||
python \
|
||||
python-dev \
|
||||
"$virtualenv" \
|
||||
gcc \
|
||||
dialog \
|
||||
libaugeas0 \
|
||||
libssl-dev \
|
||||
libffi-dev \
|
||||
ca-certificates \
|
||||
19
bootstrap/_rpm_common.sh
Executable file
19
bootstrap/_rpm_common.sh
Executable file
|
|
@ -0,0 +1,19 @@
|
|||
#!/bin/sh
|
||||
|
||||
# Tested with:
|
||||
# - Fedora 22 (x64)
|
||||
# - Centos 7 (x64: on AWS EC2 t2.micro, DigitalOcean droplet)
|
||||
|
||||
# "git-core" seems to be an alias for "git" in CentOS 7 (yum search fails)
|
||||
yum install -y \
|
||||
git-core \
|
||||
python \
|
||||
python-devel \
|
||||
python-virtualenv \
|
||||
python-devel \
|
||||
gcc \
|
||||
dialog \
|
||||
augeas-libs \
|
||||
openssl-devel \
|
||||
libffi-devel \
|
||||
ca-certificates \
|
||||
1
bootstrap/centos.sh
Symbolic link
1
bootstrap/centos.sh
Symbolic link
|
|
@ -0,0 +1 @@
|
|||
_rpm_common.sh
|
||||
1
bootstrap/debian.sh
Symbolic link
1
bootstrap/debian.sh
Symbolic link
|
|
@ -0,0 +1 @@
|
|||
_deb_common.sh
|
||||
1
bootstrap/fedora.sh
Symbolic link
1
bootstrap/fedora.sh
Symbolic link
|
|
@ -0,0 +1 @@
|
|||
_rpm_common.sh
|
||||
2
bootstrap/mac.sh
Executable file
2
bootstrap/mac.sh
Executable file
|
|
@ -0,0 +1,2 @@
|
|||
#!/bin/sh
|
||||
brew install augeas
|
||||
1
bootstrap/ubuntu.sh
Symbolic link
1
bootstrap/ubuntu.sh
Symbolic link
|
|
@ -0,0 +1 @@
|
|||
_deb_common.sh
|
||||
|
|
@ -1 +0,0 @@
|
|||
Protocol docs are currently authored at https://choc.pad.jhalderm.com/6, and exported here. This should probably change soon.
|
||||
|
|
@ -1,266 +0,0 @@
|
|||
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
|
||||
<html xmlns="http://www.w3.org/1999/xhtml" lang="en" xml:lang="en">
|
||||
<head>
|
||||
<meta http-equiv="Content-type" content="text/html; charset=utf-8" />
|
||||
<meta http-equiv="Content-Language" content="en-us" />
|
||||
<title>/26$6</title>
|
||||
</head>
|
||||
<body><b>ABSTRACT</b><br
|
||||
/><br
|
||||
/>This protocol exists to facilitate the activation of secure TLS encryption on all Internet servers to which it is applicable, in a manner compatible with existing deployed client bases, but without the need for the expert human configuration that has limited the use of TLS to date. The protocol provides transmission and validation of certification requests, and the issuance of digital certificates, with as little human intervention as possible.<br
|
||||
/><br
|
||||
/><br
|
||||
/><b>INTRODUCTION</b><br
|
||||
/><br
|
||||
/>The Trustify (aka Chococlate) protocol is used between a <b>client</b>, which is a software package on an Internet-connected host that is or will be used to operate a Web server or other TLS service, and a <b>server</b>, which is a certification service provided by a certificate authority.<br
|
||||
/><br
|
||||
/>The protocol is implemented as a series of Protocol Buffers messages which are serialized and transported as HTTP POST request and response bodies relative to an HTTPS URI published by the server. Currently, each client to server message is a new HTTP POST and each server reply is a new response body. Hence, the client and server alternate sending messages; asynchronous notification of changes in server state do not occur, but the client can be instructed to poll periodically while the server is working. Full specification of the Trustify message format is in the Messages section below.<br
|
||||
/><br
|
||||
/>The client must verify the server's own certificate when making each HTTPS connection. If the client cannot establish a secure connection to the server, the client SHOULD abort the session without any further communication.<br
|
||||
/><br
|
||||
/>Because avoiding misissuance of certificates is a fundamental priority, the server is able to abort sessions at any time for a wide variety of reasons, declining any further communication about an abandoned session.<br
|
||||
/><br
|
||||
/><b>IDENTIFYING AVAILABLE TRUSTIFY SERVERS</b><br
|
||||
/><br
|
||||
/>The first step for a Trustify client is to identify the currently operating and available Trustify servers that correspond to CAs issuing certificates through the protocol. Clients contain an HTTPS URL that always referrs to a fresh client list. For reliability purposes, if the URL cannot be fetched, clients may refer to a previously cached copy of the list.<br
|
||||
/><br
|
||||
/>The Trustify Server List will contain the following data:<br
|
||||
/><br
|
||||
/>[Server name string, server URL, <optional> [policy string1, policy string 2,..]]<br
|
||||
/><br
|
||||
/>The Server name string is a UTF-8 string containing an English readable name (possibly followed by a native name in another alphabet) of the CA. The server URL is an HTTPS endpoint that responds to the Trustify protocol as documented below. The policy strings are an optional list of parameters that may at some point express relevant comparison points between multiple CAs. The semantics of policy fields will be specified by the Trustify governance foundation.<br
|
||||
/><br
|
||||
/><b>SESSIONS</b><br
|
||||
/><br
|
||||
/>Trustify sessions are distinguished by a random session identifier which is issued by the server in its very first response to a new client connection. The session identifier must then be mentioned in all communications in either direction in order to associate requests and responses with a particular transaction. Omitting the session identifier is treated as a request to begin a new session, while messages containing an unrecognized or stale session identifier are rejected.<br
|
||||
/><br
|
||||
/>The session identifier is an ephemeral authenticator private to the client and server. An entity that knows the session identifier is able to make requests to the server related to the session, including erroneous requests that will cause the session to be terminated. Session identifiers from old, inactive sessions are explicitly marked invalid and no further use can be made of them.<br
|
||||
/><br
|
||||
/>The goal of a session is the issuance of a new digital certificate. The client must already possess a subject public key and a list of requested subject host names. The client begins the protocol with the goal of obtaining a certificate issued by the server, associating the public key with each subject name.<br
|
||||
/><br
|
||||
/><b>MESSAGE</b><br
|
||||
/><br
|
||||
/>The Trusitfy message object contains a version ID and a session ID. It also contains exactly one of the following specific message objects (except for challenge and completedchallenge):<br
|
||||
/><br
|
||||
/>request [type SigningRequest]: sent exactly once by the client at the beginning of a session in order to request a certificate. The contents indicate the details of the certificate that is being requested in the current session.<br
|
||||
/><br
|
||||
/>failure [type Failure]: sent by the client or the server to indicate a reason why the session cannot continue. All failure messages are fatal and result in the server marking the session as expired, so that no further activity may occur. (If a client sends a message related to an expired session, the server will respond with a failure message indicating that the session is expired.)<br
|
||||
/><br
|
||||
/>proceed [type Proceed]: sent by the server to request the client to poll again on the current session after a specified time delay. Normally used when the server believes that it is in the process of testing whether challenges have been satisfied or in the process of issuing the requested certificate.<br
|
||||
/>Note: Protocol also specifies the client can send this message after a polling period.<br
|
||||
/><br
|
||||
/>challenge [type Challenge]: sent by the server to announce challenges for the first time or to tell the client whether the server believes that the client has successfully completed a subset of the previously issued challenges.<br
|
||||
/><br
|
||||
/>completedchallenge [type Challenge]: sent by the client to announce that the client believes it has successfully completed a challenge.<br
|
||||
/><br
|
||||
/>success [type Success]: sent by the server to issue the requested certificate.<br
|
||||
/><br
|
||||
/><b>SEQUENCE OF COMMUNICATIONS</b><br
|
||||
/><br
|
||||
/><b><i>TODO</i></b>: Document difference between challenge types that call for proceed message and challenge types that call for completedchallenge message.<br
|
||||
/><br
|
||||
/>Initial request<br
|
||||
/>At the start of communications:<br
|
||||
/><ul><li>1. The client sends a <b>request</b> message, which includes a signing request.</li
|
||||
><li>2. The server issues a new session ID, which is included with all subsequent messages sent in either direction.</li
|
||||
><li>3. The server validates the request. If it is known to be invalid or unacceptable for any reason, the server sends a failure message and aborts the protocol.<br/><br
|
||||
/></li></ul
|
||||
>Server challenge or certificate issuance<br
|
||||
/>In response to the <b>request</b> message:<br
|
||||
/><ul><li>4. Otherwise, in accordance with its policy, the server sends a <b>challenge</b> or <b>success</b> message. (The normal case is a <b>challenge</b> message because the server typically requires proof from the client that the client is authorized to obtain the requested certificate.)</li
|
||||
><li>5. If the client receives a <b>challenge</b> message, it notes the challenges that it is expected to complete. The client then attempts to complete each specified challenge.</li
|
||||
><li>6. When the client believes that it has completed a challenge, it can send a <b>completedchallenge</b> message updating the server on its progress. If the client believes that it is unable to complete a challenge, it can send a failure message admitting failure, which will result in the termination of the session.<br/><br
|
||||
/></li></ul
|
||||
>Server response to challenge progress<br
|
||||
/>In response to the client-side <b>completedchallenge</b> or <b>proceed</b> message:<br
|
||||
/><ul><li>7. If the server observes or agrees that all challenges issued in the session have been completed, it may issue the certificate and send a <b>success</b> message. This terminates the session.</li
|
||||
><li>8. If the server believes that more time is required for certificate issuance or for challenge verification, it may send a <b>proceed</b> message asking the client to poll again after a specified time interval. The client should poll after this interval by sending completedchallenge (if it has new successes to claim) or proceed (if not). [Return to step 7.]</li
|
||||
><li>9. If the server believes that one or more challenges have still not been completed, it may send a <b>challenge</b> message indicating the status of the challenges whose completeness has or has not been verified. [Return to step 5.]</li
|
||||
><li>10. If the server believes that more challenges should be completed, it may send a new <b>challenge</b> message presenting the additional challenges. [Return to step 5.]</li
|
||||
><li>11. If the server believes that too much time has elapsed or that some challenge was abandoned or has become impossible to satisfy, it can send a <b>failure</b> message. This terminates the session.<br/><br
|
||||
/></li></ul
|
||||
><b>VERIFICATION CHALLENGES</b><br
|
||||
/><br
|
||||
/>During the course of a session, the Trustify server presents the client with one or more <b>challenges</b>, which are messages specifying tasks that must be completed to verify the preconditions of issuance for the certificate(s) that the client has requested. Challenges are an abstraction layer to allow the Trustify protocol to be enhanced and expanded over time.<br
|
||||
/><br
|
||||
/>Several challenges may be presented at once, but further or additional challenges may be presented after previous sets, possibly as a result of information that the server obtained while verfiying the earlier challenges.<br
|
||||
/><br
|
||||
/>Clients may decide to meet all of the outstanding challenges at once, or may decide to send responses to the challenges one at a time. The semantic relationship between the outstanding challenges may be conjunctive (all challenges must eventually be met before issuance), disjunctive (only one or a subset of challenges must be met), or variable (the set of challenges that are required depends on the manner in which the earlier ones are completed)<br
|
||||
/><br
|
||||
/>As a matter of protocol synchronization, there are two subtypes of challenges: those where completion of the challenge is signalled by the client and then verified by the server (such as the DVSNI challenge described below, which requires the client to configure a TLS server in a particular way) and those where completion of the challenge is identified and verified solely by the server (such as a challenge involving a payment or other organizational validation for a high-value domain).<br
|
||||
/><br
|
||||
/><b>DVSNI Challenges:</b><br
|
||||
/><br
|
||||
/>DVSNI challenges are the fundamental method employed by the Trustify protocol to ensure that clients control the DNS names for which they are requesting certificates. It is intended to be more automatable (and in some cases, more secure) than the email receipt verification that is commonly used by DV CAs, and categorically more secure than the HTTP nonce deployment verification used by some DV CAs.<br
|
||||
/><br
|
||||
/>DVSNI requires the client to demonstrate significant administrative control of the domain by not only changing responses from an HTTP server, but by altering the TLS configuration of an HTTPS server to answer specially crafted SNI (Server Name Indication) requests. This shows that the client at least has adminitrative control of the DNS name's web server software, and probably has full adminstrative control of the servers that the DNS name points to.<br
|
||||
/><br
|
||||
/><b>DVSNI implementation specifics:</b><br
|
||||
/><br
|
||||
/><i> Shared parameters (chosen and sent by CA in the Challenge message):</i><br
|
||||
/> ${nonce} A randomly chosen nonce hex output of digest of (random 32-bytes)<br
|
||||
/> ${y} [:= E(r)] r is a random secret (32 bytes, raw binary)<br
|
||||
/> ${ext} x.509 extension (format? e.g: 1.3.3.7)<br
|
||||
/><br
|
||||
/><i> Client setup:</i><br
|
||||
/> Setup SNI for <i>${nonce}</i>.chocolate<br
|
||||
/> Serve a self-signed certificate with (critical(?)) X.509 extension <i>${ext}</i>,<br
|
||||
/> with value z = HMAC_r(s) || s (random s, r = D(${y}) (private key decrypt))<br
|
||||
/><br
|
||||
/><i> Chocolate Server (CA) verification steps:</i><br
|
||||
/> Connect to domain.com, with TLS SNI ${nonce}.chocolate<br
|
||||
/> Check certificate for X.509 extension ${ext}. Verify that value z is formed as expected.<br
|
||||
/> <br
|
||||
/> <i> Purpose:</i><br
|
||||
/> Proves that whoever has access to the corresponding private key also has the ability<br
|
||||
/> to serve (change configuration for) an arbitrary certificate for an arbitrary<br
|
||||
/> subdomain under the desired domain name.<br
|
||||
/><br
|
||||
/> <i>Explanation:</i><br
|
||||
/>Using ${nonce}.chocolate (instead of domain.com) allows a currently running web server to continue serving domain.com without interruption. (Note we still use the IP for domain.com when we connect to the server) It is of slight importance that the .chocolate TLD is not real; it may protect against elaborate attacks against domains like dyndns.com which would allow attackers to control nonce.dyndns.com. If such a domain were also hosted on a virtual hosting service/CDN with the same IP as the attacker, and which really did SNI, it would be important that .chocolate not be real.<br
|
||||
/>We require the certificate to be modified to ensure that it is freshly generated for this purpose, and not just being served a wildcard certificate. We could do this several ways, but it is neccessary to tie this modification to control of the private key (hence the need for z) and to the current Chocolate request (hence the need for ${ext}). Otherwise, a "rogue CA" could receive a request from domain.com, forward it to a "real CA" with a different public key, and have the real domain.com carry out this challenge (which real CA would verify, and give rogue CA a signed cert for domain.com).<br
|
||||
/><br
|
||||
/> <i>Deviations from model:</i><br
|
||||
/> SNI only allows multiplexing over the domain. Apache always has a default SNI page... even if you provide a completely wrong header. The real challenge happens with y and z.<br
|
||||
/> Apache uses the first virtual server specifed on the ip/port to act as a default server if the SNI extension is specified by the client but does not match any of the virtual servers.<br
|
||||
/><br
|
||||
/><b>DVSNI Security Considerations:</b><br
|
||||
/><br
|
||||
/>Shared hosting environments with multiple DNS names pointed a single host and IP are a common situation on the modern Internet. We expect that the hosting provider managing these guest domains should take its own steps to prevent one from listening on port 443 in an unconstrained manner for the other guests' domains. If it does not, the DVSNI verification step will be unable to distinguish the owner of the domain from others who can listen on the domain's privileged ports, and, depending on what other verification steps are applicable, may issue certificates to these other parties.<br
|
||||
/><br
|
||||
/>Verification of control on TLS ports other than 443 (such as the TLS email serivces on ports 465, 993 and 995) in the presence of virtual hosting presents similar but possibly more serious challenges, since use of these ports on virtual private servers may be rarer, and policies about them more varied.<br
|
||||
/><br
|
||||
/><b>Proof-of-posession challenges:</b><br
|
||||
/><br
|
||||
/>The server MUST check whether there are pre-existing valid certificates for the requested DNS names by consulting Certificate Transparency logs and/or the EFF SSL Observatory. If such certificates exist, the server SHOULD NOT proceed unless it can ensure that issuance will not reduce the security of TLS services deployed with those existing certificates.<br
|
||||
/><br
|
||||
/>Traditional OV verification processes may be one way of achieving this, but it is recommended that a Proof of Possession challenge be offered as an alternative. This challenge type requires the client to sign a challenge nonce using the private key from one of the existing valid certificates for the DNS name in question.<br
|
||||
/><br
|
||||
/><br
|
||||
/><b>Payment challenges:</b><br
|
||||
/><br
|
||||
/>In limited situations, it may be determined that traditional OV or EV processes are required for the issuance of ceriticates for high-value, high-traffic DNS names. In such cases a payment challenge may be used to facilitate the transition to the OV or EV process at the client end. The Trustify governance foundation may or may not decide to allow such challenges, but if it does they will be in limited circumstances, where the OV or EV process significant enhances the security of the domain in question, and not a common case for Trustify protocol execution.<br
|
||||
/><br
|
||||
/>If Payment challenges exist, they will simply contain a URL for a web page that can specify and accept the payment required.<br
|
||||
/><br
|
||||
/><br
|
||||
/><b>FAILURE REASONS</b><br
|
||||
/><br
|
||||
/>Currently, the following reasons for the failure of a session are defined:<br
|
||||
/><br
|
||||
/><b>UnsupportedVersion</b>: the requested protocol version is not available.<br
|
||||
/><b>AbandonedRequest</b>: the client has abandoned the session and is no longer requesting issuance of the certificate.<br
|
||||
/><b>ServerOutage</b>: the service is temporarily unavailable.<br
|
||||
/><b>ServerGone</b>: the service is permanently unavailable.<br
|
||||
/><b>StaleRequest</b>: the request is expired as a result of its age, excessive delay in the client-server interaction, or because the request has previously failed for another reason.<br
|
||||
/><b>BadSignature</b>: the digital signature used by the client to prove its possession of the private key corresponding to the subject public key is invalid.<br
|
||||
/><b>BadCSR</b>: the certificate signing request sent by the client is invalid in some way.<br
|
||||
/><b>BadRequest</b>: the subject public key, one or more subject host names, or some other aspect of the signing request is invalid.<br
|
||||
/><b>NeedClientPuzzle</b>: as a denial-of-service mitigation measure, the server cannot accept the request without additional proof of work by the client.<br
|
||||
/><b>CannotIssueThatName</b>: the issuance of a certificate for one or more of the subject host names is administratively prohibited.<br
|
||||
/><b>ExistingCertificate</b>: a previous certificate for one or more of the subject host names is known to exist and the CA policy does not permit the automated issuance of a new one in response to the current request.<br
|
||||
/><b>UnsafeKey</b>: the subject public key violates a CA policy or is known to be insecure.<br
|
||||
/><b>ChallengeFailed</b>: the client's attempt to comply with a challenge was unsuccessful.<br
|
||||
/><b>ChallengeTimeout</b>: the client did not appear to comply with a challenge within the required period of time.<br
|
||||
/><br
|
||||
/>A message of type Failure must contain one of these reasons, and may contain a URI with additional human-readable information about the reason for the failure of the request.<br
|
||||
/><br
|
||||
/><b>SECURITY CONSIDERATIONS</b><br
|
||||
/><br
|
||||
/>Because the intended application of this protocol causes valuable digital certificates to be issued automatically with no time delay and without human intervention, attackers are likely to be interested in trying to use this protocol to request certificates fraudulently. It should be possible to implement the protocol and verification steps in such a way that the system as a whole is more secure than some existing certificate authority verification processes.<br
|
||||
/><br
|
||||
/>Before issuing a certificate, the server needs to perform a large number of validation and policy enforcement steps which are outside the scope of this protocol. For example, the server SHOULD check that the RSA modulus of the submitted subject public key is >=2048 bits and that it is not on any weak RSA modulus blacklist. The server SHOULD check that no CAA records forbid it from issuing a certificate for this domain and that the subject domain is not a high-value domain for which automated certificate issuance should be prevented. The server SHOULD check whether there is any known existing valid certificate for the requested domain and determine under what conditions server policy permits the issuance of a new certificate with concurrent validity.<br
|
||||
/><br
|
||||
/>When using DVSNI validation, the server SHOULD perform the probe connection from multiple locations on the Internet to achieve geographic and network topology diversity to reduce the risk that an attacker performs a DNS or BGP attack to appear to control a particular web server.<br
|
||||
/><br
|
||||
/>The server SHOULD avoid passing unvalidated or unsanitized data from the client to any server code implemented in a non-bounds-checked language.<br
|
||||
/><br
|
||||
/>The server SHOULD use physical controls to isolate a machine capable of directly causing certificate issuance from the public Internet.<br
|
||||
/><br
|
||||
/>The server SHOULD attempt to detect fraudulent attempts to issue certificates.<br
|
||||
/><br
|
||||
/>The server SHOULD attempt to notify the owner of a site when a certificate was issued, and SHOULD memorialize the issuance publicly using a system such as Certificate Transparency.<br
|
||||
/><br
|
||||
/>The client SHOULD be encouraged to use best practices to increase the security of its TLS deployment using the new certificate, such as HSTS and DANE.<br
|
||||
/><br
|
||||
/>The server MUST securely generate random session IDs to prevent a malicious client from guessing a valid session ID. The server MUST cause sessions to terminate after a specified period of inactivity or after a fatal error, and prevent any further activity from occurring on a terminated session. After sending a single error message indicating why a session was terminated, the server SHOULD not convey any information to clients about why a particular session is nonexistent or inactive, including whether or not the specified session ever existed.<br
|
||||
/><br
|
||||
/>The above list of verification steps may not be exhaustive. Full policy guidelines on these questions will be maintained by the Trustify governance foundation.<br
|
||||
/><br
|
||||
/><b>MESSAGE DATA TYPES</b><br
|
||||
/><br
|
||||
/>SigningRequest:<br
|
||||
/><br
|
||||
/> message SigningRequest {<br
|
||||
/> required int64 timestamp = 2;<br
|
||||
/> required string recipient = 3;<br
|
||||
/> required string csr = 4;<br
|
||||
/> required bytes sig = 5;<br
|
||||
/> optional string clientpuzzle = 6;<br
|
||||
/> }<br
|
||||
/><br
|
||||
/>timestamp is the Unix timestamp when the request was made. (The server SHOULD verify that this is not significantly in the past or future relative to the time that the server received it.)<br
|
||||
/><br
|
||||
/>recipient is the URI of the service to which the client intended to submit the request.<br
|
||||
/><br
|
||||
/>csr is a PEM-encoded certificate signing request containing the subject public key and all subject names to which the request relates (using "\n" rather than "\r\n" as newline delimiter).<br
|
||||
/><br
|
||||
/>sig is an RSA signature over the preceding values using the private key that corresponds to the subject public key. <b><i>TODO</i></b>: describe how the signature is calculated.<br
|
||||
/><br
|
||||
/>clientpuzzle is a hashcash string that refers to the hostname of the server.<br
|
||||
/><br
|
||||
/><br
|
||||
/>Failure:<br
|
||||
/><br
|
||||
/> message Failure {<br
|
||||
/> required FailureReason cause = 1;<br
|
||||
/> optional string URI = 2; /* for more human-readable information */<br
|
||||
/> }<br
|
||||
/><br
|
||||
/>cause is the FailureReason enumerated type reason why the session or request failed or is being abandoned.<br
|
||||
/><br
|
||||
/>URI is an optional refernce to a URI where more human-readable information about the failure can be obtained. This can be used to clarify to the human user whether there is an action that could be taken to correct the problem.<br
|
||||
/><br
|
||||
/><br
|
||||
/>Proceed: <br
|
||||
/><br
|
||||
/> message Proceed {<br
|
||||
/> required int64 timestamp = 1;<br
|
||||
/> optional int32 polldelay = 2;<br
|
||||
/> }<br
|
||||
/><br
|
||||
/>timestamp is the Unix time when the message was issued. When sent by the server, polldelay is a suggested number of seconds to wait before contacting the server again.<br
|
||||
/><br
|
||||
/>Challenge:<br
|
||||
/><br
|
||||
/> message Challenge {<br
|
||||
/> required ChallengeType type = 1;<br
|
||||
/> optional string name = 2;<br
|
||||
/> repeated bytes data = 3;<br
|
||||
/> optional string URI = 4;<br
|
||||
/> optional bool succeeded = 5;<br
|
||||
/> }<br
|
||||
/><br
|
||||
/>type is the type of the challenge (as defined in the enumerated type ChallengeType).<br
|
||||
/><br
|
||||
/>name is the name or identifier the server assigned to this particular challenge instance.<br
|
||||
/><br
|
||||
/>data is an array of arbitrary byte values used by a particular challenge, whose semantics are defined by the corresponding challenge type.<br
|
||||
/><br
|
||||
/>URI is a URI associated with the challenge, whose semantics are defined by the corresponding challenge type.<br
|
||||
/><br
|
||||
/>succeeded can be used by the client or the server to indicate whether the party mentioning the challenge believes that the challenge has already been satisfied.<br
|
||||
/><br
|
||||
/>Success:<br
|
||||
/><br
|
||||
/> message Success {<br
|
||||
/> required string certificate = 1;<br
|
||||
/> optional string chain = 2;<br
|
||||
/> }<br
|
||||
/><br
|
||||
/>certificate is the PEM-encoded certificate that was successfully issued.<br
|
||||
/><br
|
||||
/>chain is an optional PEM-encoded certificate chain that chains up from the intermediate certificate authority that issued this certificate to a root certificate authority, in order to allow a verifier to validate this certificate.<br
|
||||
/><br
|
||||
/></body>
|
||||
</html>
|
||||
14
docker-compose.yml
Normal file
14
docker-compose.yml
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
production:
|
||||
build: .
|
||||
ports:
|
||||
- "443:443"
|
||||
|
||||
# For development, mount git root to /opt/letsencrypt/src in order to
|
||||
# make the dev workflow more vagrant-like.
|
||||
development:
|
||||
build: .
|
||||
ports:
|
||||
- "443:443"
|
||||
volumes:
|
||||
- .:/opt/letsencrypt/src
|
||||
- /opt/letsencrypt/venv
|
||||
1
docs/.gitignore
vendored
Normal file
1
docs/.gitignore
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
_build/
|
||||
183
docs/Makefile
Normal file
183
docs/Makefile
Normal file
|
|
@ -0,0 +1,183 @@
|
|||
# Makefile for Sphinx documentation
|
||||
#
|
||||
|
||||
# You can set these variables from the command line.
|
||||
SPHINXOPTS =
|
||||
SPHINXBUILD = sphinx-build
|
||||
PAPER =
|
||||
BUILDDIR = _build
|
||||
|
||||
# User-friendly check for sphinx-build
|
||||
ifeq ($(shell which $(SPHINXBUILD) >/dev/null 2>&1; echo $$?), 1)
|
||||
$(error The '$(SPHINXBUILD)' command was not found. Make sure you have Sphinx installed, then set the SPHINXBUILD environment variable to point to the full path of the '$(SPHINXBUILD)' executable. Alternatively you can add the directory with the executable to your PATH. If you don't have Sphinx installed, grab it from http://sphinx-doc.org/)
|
||||
endif
|
||||
|
||||
# Internal variables.
|
||||
PAPEROPT_a4 = -D latex_paper_size=a4
|
||||
PAPEROPT_letter = -D latex_paper_size=letter
|
||||
ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) .
|
||||
# the i18n builder cannot share the environment and doctrees with the others
|
||||
I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) .
|
||||
|
||||
.PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest coverage gettext
|
||||
|
||||
help:
|
||||
@echo "Please use \`make <target>' where <target> is one of"
|
||||
@echo " html to make standalone HTML files"
|
||||
@echo " dirhtml to make HTML files named index.html in directories"
|
||||
@echo " singlehtml to make a single large HTML file"
|
||||
@echo " pickle to make pickle files"
|
||||
@echo " json to make JSON files"
|
||||
@echo " htmlhelp to make HTML files and a HTML help project"
|
||||
@echo " qthelp to make HTML files and a qthelp project"
|
||||
@echo " devhelp to make HTML files and a Devhelp project"
|
||||
@echo " epub to make an epub"
|
||||
@echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter"
|
||||
@echo " latexpdf to make LaTeX files and run them through pdflatex"
|
||||
@echo " latexpdfja to make LaTeX files and run them through platex/dvipdfmx"
|
||||
@echo " text to make text files"
|
||||
@echo " man to make manual pages"
|
||||
@echo " texinfo to make Texinfo files"
|
||||
@echo " info to make Texinfo files and run them through makeinfo"
|
||||
@echo " gettext to make PO message catalogs"
|
||||
@echo " changes to make an overview of all changed/added/deprecated items"
|
||||
@echo " xml to make Docutils-native XML files"
|
||||
@echo " pseudoxml to make pseudoxml-XML files for display purposes"
|
||||
@echo " linkcheck to check all external links for integrity"
|
||||
@echo " doctest to run all doctests embedded in the documentation (if enabled)"
|
||||
@echo " coverage to run coverage check of the documentation (if enabled)"
|
||||
|
||||
clean:
|
||||
rm -rf $(BUILDDIR)/*
|
||||
|
||||
html:
|
||||
$(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html
|
||||
@echo
|
||||
@echo "Build finished. The HTML pages are in $(BUILDDIR)/html."
|
||||
|
||||
dirhtml:
|
||||
$(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml
|
||||
@echo
|
||||
@echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml."
|
||||
|
||||
singlehtml:
|
||||
$(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml
|
||||
@echo
|
||||
@echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml."
|
||||
|
||||
pickle:
|
||||
$(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle
|
||||
@echo
|
||||
@echo "Build finished; now you can process the pickle files."
|
||||
|
||||
json:
|
||||
$(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json
|
||||
@echo
|
||||
@echo "Build finished; now you can process the JSON files."
|
||||
|
||||
htmlhelp:
|
||||
$(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp
|
||||
@echo
|
||||
@echo "Build finished; now you can run HTML Help Workshop with the" \
|
||||
".hhp project file in $(BUILDDIR)/htmlhelp."
|
||||
|
||||
qthelp:
|
||||
$(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp
|
||||
@echo
|
||||
@echo "Build finished; now you can run "qcollectiongenerator" with the" \
|
||||
".qhcp project file in $(BUILDDIR)/qthelp, like this:"
|
||||
@echo "# qcollectiongenerator $(BUILDDIR)/qthelp/LetsEncrypt.qhcp"
|
||||
@echo "To view the help file:"
|
||||
@echo "# assistant -collectionFile $(BUILDDIR)/qthelp/LetsEncrypt.qhc"
|
||||
|
||||
devhelp:
|
||||
$(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp
|
||||
@echo
|
||||
@echo "Build finished."
|
||||
@echo "To view the help file:"
|
||||
@echo "# mkdir -p $$HOME/.local/share/devhelp/LetsEncrypt"
|
||||
@echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/LetsEncrypt"
|
||||
@echo "# devhelp"
|
||||
|
||||
epub:
|
||||
$(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub
|
||||
@echo
|
||||
@echo "Build finished. The epub file is in $(BUILDDIR)/epub."
|
||||
|
||||
latex:
|
||||
$(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex
|
||||
@echo
|
||||
@echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex."
|
||||
@echo "Run \`make' in that directory to run these through (pdf)latex" \
|
||||
"(use \`make latexpdf' here to do that automatically)."
|
||||
|
||||
latexpdf:
|
||||
$(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex
|
||||
@echo "Running LaTeX files through pdflatex..."
|
||||
$(MAKE) -C $(BUILDDIR)/latex all-pdf
|
||||
@echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex."
|
||||
|
||||
latexpdfja:
|
||||
$(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex
|
||||
@echo "Running LaTeX files through platex and dvipdfmx..."
|
||||
$(MAKE) -C $(BUILDDIR)/latex all-pdf-ja
|
||||
@echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex."
|
||||
|
||||
text:
|
||||
$(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text
|
||||
@echo
|
||||
@echo "Build finished. The text files are in $(BUILDDIR)/text."
|
||||
|
||||
man:
|
||||
$(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man
|
||||
@echo
|
||||
@echo "Build finished. The manual pages are in $(BUILDDIR)/man."
|
||||
|
||||
texinfo:
|
||||
$(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo
|
||||
@echo
|
||||
@echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo."
|
||||
@echo "Run \`make' in that directory to run these through makeinfo" \
|
||||
"(use \`make info' here to do that automatically)."
|
||||
|
||||
info:
|
||||
$(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo
|
||||
@echo "Running Texinfo files through makeinfo..."
|
||||
make -C $(BUILDDIR)/texinfo info
|
||||
@echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo."
|
||||
|
||||
gettext:
|
||||
$(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale
|
||||
@echo
|
||||
@echo "Build finished. The message catalogs are in $(BUILDDIR)/locale."
|
||||
|
||||
changes:
|
||||
$(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes
|
||||
@echo
|
||||
@echo "The overview file is in $(BUILDDIR)/changes."
|
||||
|
||||
linkcheck:
|
||||
$(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck
|
||||
@echo
|
||||
@echo "Link check complete; look for any errors in the above output " \
|
||||
"or in $(BUILDDIR)/linkcheck/output.txt."
|
||||
|
||||
doctest:
|
||||
$(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest
|
||||
@echo "Testing of doctests in the sources finished, look at the " \
|
||||
"results in $(BUILDDIR)/doctest/output.txt."
|
||||
|
||||
coverage:
|
||||
$(SPHINXBUILD) -b coverage $(ALLSPHINXOPTS) $(BUILDDIR)/coverage
|
||||
@echo "Testing of coverage in the sources finished, look at the " \
|
||||
"results in $(BUILDDIR)/coverage/python.txt."
|
||||
|
||||
xml:
|
||||
$(SPHINXBUILD) -b xml $(ALLSPHINXOPTS) $(BUILDDIR)/xml
|
||||
@echo
|
||||
@echo "Build finished. The XML files are in $(BUILDDIR)/xml."
|
||||
|
||||
pseudoxml:
|
||||
$(SPHINXBUILD) -b pseudoxml $(ALLSPHINXOPTS) $(BUILDDIR)/pseudoxml
|
||||
@echo
|
||||
@echo "Build finished. The pseudo-XML files are in $(BUILDDIR)/pseudoxml."
|
||||
8
docs/api.rst
Normal file
8
docs/api.rst
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
=================
|
||||
API Documentation
|
||||
=================
|
||||
|
||||
.. toctree::
|
||||
:glob:
|
||||
|
||||
api/**
|
||||
5
docs/api/account.rst
Normal file
5
docs/api/account.rst
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
:mod:`letsencrypt.account`
|
||||
--------------------------
|
||||
|
||||
.. automodule:: letsencrypt.account
|
||||
:members:
|
||||
5
docs/api/achallenges.rst
Normal file
5
docs/api/achallenges.rst
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
:mod:`letsencrypt.achallenges`
|
||||
------------------------------
|
||||
|
||||
.. automodule:: letsencrypt.achallenges
|
||||
:members:
|
||||
5
docs/api/auth_handler.rst
Normal file
5
docs/api/auth_handler.rst
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
:mod:`letsencrypt.auth_handler`
|
||||
-------------------------------
|
||||
|
||||
.. automodule:: letsencrypt.auth_handler
|
||||
:members:
|
||||
5
docs/api/client.rst
Normal file
5
docs/api/client.rst
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
:mod:`letsencrypt.client`
|
||||
-------------------------
|
||||
|
||||
.. automodule:: letsencrypt.client
|
||||
:members:
|
||||
5
docs/api/configuration.rst
Normal file
5
docs/api/configuration.rst
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
:mod:`letsencrypt.configuration`
|
||||
--------------------------------
|
||||
|
||||
.. automodule:: letsencrypt.configuration
|
||||
:members:
|
||||
5
docs/api/constants.rst
Normal file
5
docs/api/constants.rst
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
:mod:`letsencrypt.constants`
|
||||
-----------------------------------
|
||||
|
||||
.. automodule:: letsencrypt.constants
|
||||
:members:
|
||||
5
docs/api/continuity_auth.rst
Normal file
5
docs/api/continuity_auth.rst
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
:mod:`letsencrypt.continuity_auth`
|
||||
----------------------------------
|
||||
|
||||
.. automodule:: letsencrypt.continuity_auth
|
||||
:members:
|
||||
5
docs/api/crypto_util.rst
Normal file
5
docs/api/crypto_util.rst
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
:mod:`letsencrypt.crypto_util`
|
||||
------------------------------
|
||||
|
||||
.. automodule:: letsencrypt.crypto_util
|
||||
:members:
|
||||
29
docs/api/display.rst
Normal file
29
docs/api/display.rst
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
:mod:`letsencrypt.display`
|
||||
--------------------------
|
||||
|
||||
.. automodule:: letsencrypt.display
|
||||
:members:
|
||||
|
||||
:mod:`letsencrypt.display.util`
|
||||
===============================
|
||||
|
||||
.. automodule:: letsencrypt.display.util
|
||||
:members:
|
||||
|
||||
:mod:`letsencrypt.display.ops`
|
||||
==============================
|
||||
|
||||
.. automodule:: letsencrypt.display.ops
|
||||
:members:
|
||||
|
||||
:mod:`letsencrypt.display.enhancements`
|
||||
=======================================
|
||||
|
||||
.. automodule:: letsencrypt.display.enhancements
|
||||
:members:
|
||||
|
||||
:mod:`letsencrypt.display.revocation`
|
||||
=====================================
|
||||
|
||||
.. automodule:: letsencrypt.display.revocation
|
||||
:members:
|
||||
5
docs/api/errors.rst
Normal file
5
docs/api/errors.rst
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
:mod:`letsencrypt.errors`
|
||||
-------------------------
|
||||
|
||||
.. automodule:: letsencrypt.errors
|
||||
:members:
|
||||
5
docs/api/index.rst
Normal file
5
docs/api/index.rst
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
:mod:`letsencrypt`
|
||||
------------------
|
||||
|
||||
.. automodule:: letsencrypt
|
||||
:members:
|
||||
5
docs/api/interfaces.rst
Normal file
5
docs/api/interfaces.rst
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
:mod:`letsencrypt.interfaces`
|
||||
-----------------------------
|
||||
|
||||
.. automodule:: letsencrypt.interfaces
|
||||
:members:
|
||||
5
docs/api/le_util.rst
Normal file
5
docs/api/le_util.rst
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
:mod:`letsencrypt.le_util`
|
||||
--------------------------
|
||||
|
||||
.. automodule:: letsencrypt.le_util
|
||||
:members:
|
||||
5
docs/api/log.rst
Normal file
5
docs/api/log.rst
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
:mod:`letsencrypt.log`
|
||||
----------------------
|
||||
|
||||
.. automodule:: letsencrypt.log
|
||||
:members:
|
||||
5
docs/api/plugins/common.rst
Normal file
5
docs/api/plugins/common.rst
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
:mod:`letsencrypt.plugins.common`
|
||||
---------------------------------
|
||||
|
||||
.. automodule:: letsencrypt.plugins.common
|
||||
:members:
|
||||
5
docs/api/plugins/disco.rst
Normal file
5
docs/api/plugins/disco.rst
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
:mod:`letsencrypt.plugins.disco`
|
||||
--------------------------------
|
||||
|
||||
.. automodule:: letsencrypt.plugins.disco
|
||||
:members:
|
||||
5
docs/api/plugins/manual.rst
Normal file
5
docs/api/plugins/manual.rst
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
:mod:`letsencrypt.plugins.manual`
|
||||
---------------------------------
|
||||
|
||||
.. automodule:: letsencrypt.plugins.manual
|
||||
:members:
|
||||
11
docs/api/plugins/standalone.rst
Normal file
11
docs/api/plugins/standalone.rst
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
:mod:`letsencrypt.plugins.standalone`
|
||||
-------------------------------------
|
||||
|
||||
.. automodule:: letsencrypt.plugins.standalone
|
||||
:members:
|
||||
|
||||
:mod:`letsencrypt.plugins.standalone.authenticator`
|
||||
===================================================
|
||||
|
||||
.. automodule:: letsencrypt.plugins.standalone.authenticator
|
||||
:members:
|
||||
5
docs/api/proof_of_possession.rst
Normal file
5
docs/api/proof_of_possession.rst
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
:mod:`letsencrypt.proof_of_possession`
|
||||
--------------------------------------
|
||||
|
||||
.. automodule:: letsencrypt.proof_of_possession
|
||||
:members:
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue