1 | """ |
---|
2 | Tests for the TLS part of the HTTP Storage Protocol. |
---|
3 | |
---|
4 | More broadly, these are tests for HTTPS usage as replacement for Foolscap's |
---|
5 | server authentication logic, which may one day apply outside of HTTP Storage |
---|
6 | Protocol. |
---|
7 | """ |
---|
8 | |
---|
9 | from contextlib import asynccontextmanager |
---|
10 | from base64 import b64decode |
---|
11 | |
---|
12 | from yaml import safe_load |
---|
13 | from cryptography import x509 |
---|
14 | |
---|
15 | from twisted.internet.endpoints import serverFromString |
---|
16 | from twisted.internet import reactor |
---|
17 | from twisted.internet.defer import maybeDeferred |
---|
18 | from twisted.web.server import Site |
---|
19 | from twisted.web.static import Data |
---|
20 | from twisted.web.client import Agent, HTTPConnectionPool, ResponseNeverReceived |
---|
21 | from twisted.python.filepath import FilePath |
---|
22 | from treq.client import HTTPClient |
---|
23 | |
---|
24 | from .common import SyncTestCase, AsyncTestCase, SameProcessStreamEndpointAssigner |
---|
25 | from .certs import ( |
---|
26 | generate_certificate, |
---|
27 | generate_private_key, |
---|
28 | private_key_to_file, |
---|
29 | cert_to_file, |
---|
30 | ) |
---|
31 | from ..storage.http_common import get_spki, get_spki_hash |
---|
32 | from ..storage.http_client import _StorageClientHTTPSPolicy |
---|
33 | from ..storage.http_server import _TLSEndpointWrapper |
---|
34 | from ..util.deferredutil import async_to_deferred |
---|
35 | from .common_system import spin_until_cleanup_done |
---|
36 | |
---|
37 | spki_test_vectors_path = FilePath(__file__).sibling("data").child("spki-hash-test-vectors.yaml") |
---|
38 | |
---|
39 | |
---|
40 | class HTTPSNurlTests(SyncTestCase): |
---|
41 | """Tests for HTTPS NURLs.""" |
---|
42 | |
---|
43 | def test_spki_hash(self): |
---|
44 | """ |
---|
45 | The output of ``get_spki_hash()`` matches the semantics of RFC |
---|
46 | 7469. |
---|
47 | |
---|
48 | The test vector certificates were generated using the openssl command |
---|
49 | line tool:: |
---|
50 | |
---|
51 | openssl req -x509 -newkey rsa:4096 -keyout key.pem -out cert.pem -days 365 |
---|
52 | |
---|
53 | The expected hash was generated using Appendix A instructions in the |
---|
54 | RFC:: |
---|
55 | |
---|
56 | openssl x509 -noout -in certificate.pem -pubkey | \ |
---|
57 | openssl asn1parse -noout -inform pem -out public.key |
---|
58 | openssl dgst -sha256 -binary public.key | openssl enc -base64 |
---|
59 | |
---|
60 | The OpenSSL base64-encoded output was then adjusted into the URL-safe |
---|
61 | base64 variation: `+` and `/` were replaced with `-` and `_` and the |
---|
62 | trailing `=` padding was removed. |
---|
63 | |
---|
64 | The expected SubjectPublicKeyInfo bytes were extracted from the |
---|
65 | implementation of `get_spki_hash` after its result matched the |
---|
66 | expected value generated by the command above. |
---|
67 | """ |
---|
68 | spki_cases = safe_load(spki_test_vectors_path.getContent())["vector"] |
---|
69 | for n, case in enumerate(spki_cases): |
---|
70 | certificate_text = case["certificate"].encode("ascii") |
---|
71 | expected_spki = b64decode(case["expected-spki"]) |
---|
72 | expected_hash = case["expected-hash"].encode("ascii") |
---|
73 | |
---|
74 | try: |
---|
75 | certificate = x509.load_pem_x509_certificate(certificate_text) |
---|
76 | except Exception as e: |
---|
77 | self.fail(f"Loading case {n} certificate failed: {e}") |
---|
78 | |
---|
79 | self.assertEqual( |
---|
80 | expected_spki, |
---|
81 | get_spki(certificate), |
---|
82 | f"case {n} spki data mismatch", |
---|
83 | ) |
---|
84 | self.assertEqual( |
---|
85 | expected_hash, |
---|
86 | get_spki_hash(certificate), |
---|
87 | f"case {n} spki hash mismatch", |
---|
88 | ) |
---|
89 | |
---|
90 | |
---|
91 | class PinningHTTPSValidation(AsyncTestCase): |
---|
92 | """ |
---|
93 | Test client-side validation logic of HTTPS certificates that uses |
---|
94 | Tahoe-LAFS's pinning-based scheme instead of the traditional certificate |
---|
95 | authority scheme. |
---|
96 | |
---|
97 | https://cryptography.io/en/latest/x509/tutorial/#creating-a-self-signed-certificate |
---|
98 | """ |
---|
99 | |
---|
100 | def setUp(self): |
---|
101 | self._port_assigner = SameProcessStreamEndpointAssigner() |
---|
102 | self._port_assigner.setUp() |
---|
103 | self.addCleanup(self._port_assigner.tearDown) |
---|
104 | return AsyncTestCase.setUp(self) |
---|
105 | |
---|
106 | def tearDown(self): |
---|
107 | d = maybeDeferred(AsyncTestCase.tearDown, self) |
---|
108 | return d.addCallback(lambda _: spin_until_cleanup_done()) |
---|
109 | |
---|
110 | @asynccontextmanager |
---|
111 | async def listen(self, private_key_path: FilePath, cert_path: FilePath): |
---|
112 | """ |
---|
113 | Context manager that runs a HTTPS server with the given private key |
---|
114 | and certificate. |
---|
115 | |
---|
116 | Returns a URL that will connect to the server. |
---|
117 | """ |
---|
118 | location_hint, endpoint_string = self._port_assigner.assign(reactor) |
---|
119 | underlying_endpoint = serverFromString(reactor, endpoint_string) |
---|
120 | endpoint = _TLSEndpointWrapper.from_paths( |
---|
121 | underlying_endpoint, private_key_path, cert_path |
---|
122 | ) |
---|
123 | root = Data(b"YOYODYNE", "text/plain") |
---|
124 | root.isLeaf = True |
---|
125 | listening_port = await endpoint.listen(Site(root)) |
---|
126 | try: |
---|
127 | yield f"https://127.0.0.1:{listening_port.getHost().port}/" # type: ignore[attr-defined] |
---|
128 | finally: |
---|
129 | result = listening_port.stopListening() |
---|
130 | if result is not None: |
---|
131 | await result |
---|
132 | |
---|
133 | def request(self, url: str, expected_certificate: x509.Certificate): |
---|
134 | """ |
---|
135 | Send a HTTPS request to the given URL, ensuring that the given |
---|
136 | certificate is the one used via SPKI-hash-based pinning comparison. |
---|
137 | """ |
---|
138 | # No persistent connections, so we don't have dirty reactor at the end |
---|
139 | # of the test. |
---|
140 | treq_client = HTTPClient( |
---|
141 | Agent( |
---|
142 | reactor, |
---|
143 | _StorageClientHTTPSPolicy( |
---|
144 | expected_spki_hash=get_spki_hash(expected_certificate) |
---|
145 | ), |
---|
146 | pool=HTTPConnectionPool(reactor, persistent=False), |
---|
147 | ) |
---|
148 | ) |
---|
149 | return treq_client.get(url) |
---|
150 | |
---|
151 | @async_to_deferred |
---|
152 | async def test_success(self): |
---|
153 | """ |
---|
154 | If all conditions are met, a TLS client using the Tahoe-LAFS policy can |
---|
155 | connect to the server. |
---|
156 | """ |
---|
157 | private_key = generate_private_key() |
---|
158 | certificate = generate_certificate(private_key) |
---|
159 | async with self.listen( |
---|
160 | private_key_to_file(FilePath(self.mktemp()), private_key), |
---|
161 | cert_to_file(FilePath(self.mktemp()), certificate), |
---|
162 | ) as url: |
---|
163 | response = await self.request(url, certificate) |
---|
164 | self.assertEqual(await response.content(), b"YOYODYNE") |
---|
165 | |
---|
166 | @async_to_deferred |
---|
167 | async def test_server_certificate_has_wrong_hash(self): |
---|
168 | """ |
---|
169 | If the server's certificate hash doesn't match the hash the client |
---|
170 | expects, the request to the server fails. |
---|
171 | """ |
---|
172 | private_key1 = generate_private_key() |
---|
173 | certificate1 = generate_certificate(private_key1) |
---|
174 | private_key2 = generate_private_key() |
---|
175 | certificate2 = generate_certificate(private_key2) |
---|
176 | |
---|
177 | async with self.listen( |
---|
178 | private_key_to_file(FilePath(self.mktemp()), private_key1), |
---|
179 | cert_to_file(FilePath(self.mktemp()), certificate1), |
---|
180 | ) as url: |
---|
181 | with self.assertRaises(ResponseNeverReceived): |
---|
182 | await self.request(url, certificate2) |
---|
183 | |
---|
184 | @async_to_deferred |
---|
185 | async def test_server_certificate_expired(self): |
---|
186 | """ |
---|
187 | If the server's certificate has expired, the request to the server |
---|
188 | succeeds if the hash matches the one the client expects; expiration has |
---|
189 | no effect. |
---|
190 | """ |
---|
191 | private_key = generate_private_key() |
---|
192 | certificate = generate_certificate(private_key, expires_days=-10) |
---|
193 | |
---|
194 | async with self.listen( |
---|
195 | private_key_to_file(FilePath(self.mktemp()), private_key), |
---|
196 | cert_to_file(FilePath(self.mktemp()), certificate), |
---|
197 | ) as url: |
---|
198 | response = await self.request(url, certificate) |
---|
199 | self.assertEqual(await response.content(), b"YOYODYNE") |
---|
200 | |
---|
201 | @async_to_deferred |
---|
202 | async def test_server_certificate_not_valid_yet(self): |
---|
203 | """ |
---|
204 | If the server's certificate is only valid starting in The Future, the |
---|
205 | request to the server succeeds if the hash matches the one the client |
---|
206 | expects; start time has no effect. |
---|
207 | """ |
---|
208 | private_key = generate_private_key() |
---|
209 | certificate = generate_certificate( |
---|
210 | private_key, expires_days=10, valid_in_days=5 |
---|
211 | ) |
---|
212 | |
---|
213 | async with self.listen( |
---|
214 | private_key_to_file(FilePath(self.mktemp()), private_key), |
---|
215 | cert_to_file(FilePath(self.mktemp()), certificate), |
---|
216 | ) as url: |
---|
217 | response = await self.request(url, certificate) |
---|
218 | self.assertEqual(await response.content(), b"YOYODYNE") |
---|
219 | |
---|
220 | # A potential attack to test is a private key that doesn't match the |
---|
221 | # certificate... but OpenSSL (quite rightly) won't let you listen with that |
---|
222 | # so I don't know how to test that! See |
---|
223 | # https://tahoe-lafs.org/trac/tahoe-lafs/ticket/3884 |
---|