Skip to content

Commit ae1ece9

Browse files
committed
Add script
1 parent f7ba253 commit ae1ece9

File tree

2 files changed

+391
-1
lines changed

2 files changed

+391
-1
lines changed

README.md

Lines changed: 74 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,75 @@
11
# gpg-serve-key
2-
Serve a public/private GPG key over https
2+
3+
This script allows to transfer a public/private GPG key from a server to
4+
another device where communication is only possible over the https. Note that
5+
in general this should not be a first choice. For example, if you have ssh
6+
access, a better way to transfer a key would be
7+
8+
ssh user@remote gpg2 --export-secret-key KEYID | gpg2 --import
9+
10+
However, transfer over https is usually a better choice than e.g. emailing an
11+
exported secret-key file to yourself. The one particular use case motivating
12+
this script was the import of a secret key into the [Pass iOS app][1]
13+
14+
While transfer over https in principle makes it accessible to anyone, the
15+
script takes strong measures to protect the key data:
16+
17+
* They key is directly read through a pip from the `gpg` executable. The
18+
secret key is never written to disk
19+
20+
* The server encrypts the communication with SSL (that is, using the https
21+
protocol) by default. While this creates the additional overhead of
22+
requiring valid SSL certificates for the public hostname under which the
23+
server will be reached, it is essential to guarantee that the key cannot be
24+
sniffed in transit. For use within a trusted network, the encryption can be
25+
disabled, although you are strongly discouraged from doing so.
26+
27+
* The key is exposed at a url that contains a random token and using a random
28+
port number (by default), e.g. for the KEYID 57A6CAA6
29+
30+
https://michaelgoerz.net:47409/v1f4Y7XixMQ/57A6CAA6-secret.key
31+
32+
* Brute-forcing the token is prevented through rate limiting, that is, by an
33+
exponentially increasing delay after an invalid request
34+
35+
* The server responds with HTTP headers that disable caching by the client.
36+
*
37+
* The server writes log messages about every served request. This allows to
38+
monitor for unexpected access and to detect if the key has been compromised
39+
(as a last resort)
40+
41+
42+
43+
## Requirements ##
44+
45+
* Python >3.5
46+
* [click package][2]
47+
* A server that is accessible through a public hostname, with GPG installed
48+
and the private key for the KEYID that is to be exported in its keychain
49+
* SSL certificates for the public hostname. It is recommended to use
50+
[Let's Encrypt][3]. You may use an existing certificate for a webserver
51+
running on the host
52+
53+
54+
## Usage ##
55+
56+
Run the script directly as e.g.
57+
58+
./gpg-serve-key \
59+
--cert-file=/etc/letsencrypt/live/michaelgoerz.net/cert.pem \
60+
--key-file=/etc/letsencrypt/live/michaelgoerz.net/privkey.pem \
61+
--host=michaelgoerz.net 57A6CAA6
62+
63+
See `./gpg-serve-key --help` for more details.
64+
65+
This will start temporary webserver at a random port and serve both the public
66+
and the private key at URLs such as
67+
68+
https://michaelgoerz.net:47409/v1f4Y7XixMQ/57A6CAA6-public.key
69+
https://michaelgoerz.net:47409/v1f4Y7XixMQ/57A6CAA6-secret.key
70+
71+
After importing the keys from these URLs, stop the serve by hitting `ctrl+c`.
72+
73+
[1]: https://mssun.github.io/passforios/
74+
[2]: http://click.pocoo.org/5/
75+
[3]: https://letsencrypt.org

gpg-serve-key

Lines changed: 317 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,317 @@
1+
#!/usr/bin/env python
2+
# MIT License
3+
#
4+
# Copyright (c) 2017 Michael Goerz
5+
#
6+
# Permission is hereby granted, free of charge, to any person obtaining a copy
7+
# of this software and associated documentation files (the "Software"), to deal
8+
# in the Software without restriction, including without limitation the rights
9+
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
10+
# copies of the Software, and to permit persons to whom the Software is
11+
# furnished to do so, subject to the following conditions:
12+
#
13+
# The above copyright notice and this permission notice shall be included
14+
# in all copies or substantial portions of the Software.
15+
#
16+
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
17+
# OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
18+
# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN
19+
# NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
20+
# DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
21+
# OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE
22+
# USE OR OTHER DEALINGS IN THE SOFTWARE.
23+
#
24+
"""Serve a public/private GPG key over http(s)
25+
26+
This script allows to transfer a public/private GPG key from a server to
27+
another device where communication is only possible over the https. Note that
28+
in general this should not be a first choice. For example, if you have ssh
29+
access, a better way to transfer a key would be
30+
31+
ssh user@remote gpg2 --export-secret-key KEYID | gpg2 --import
32+
33+
However, transfer over https is usually a better choice than e.g. emailing an
34+
exported secret-key file to yourself. The one particular use case motivating
35+
this script was the import of a secret key into the [Pass iOS app][1]
36+
37+
While transfer over https in principle makes it accessible to anyone, the
38+
script takes strong measures to protect the key data:
39+
40+
* They key is directly read through a pip from the `gpg` executable. The
41+
secret key is never written to disk
42+
43+
* The server encrypts the communication with SSL (that is, using the https
44+
protocol) by default. While this creates the additional overhead of
45+
requiring valid SSL certificates for the public hostname under which the
46+
server will be reached, it is essential to guarantee that the key cannot be
47+
sniffed in transit. For use within a trusted network, the encryption can be
48+
disabled, although you are strongly discouraged from doing so.
49+
50+
* The key is exposed at a url that contains a random token and using a random
51+
port number (by default), e.g. for the KEYID 57A6CAA6
52+
53+
https://michaelgoerz.net:47409/v1f4Y7XixMQ/57A6CAA6-secret.key
54+
55+
* Brute-forcing the token is prevented through rate limiting, that is, by an
56+
exponentially increasing delay after an invalid request
57+
58+
* The server responds with HTTP headers that disable caching by the client.
59+
*
60+
* The server writes log messages about every served request. This allows to
61+
monitor for unexpected access and to detect if the key has been compromised
62+
(as a last resort)
63+
64+
65+
66+
## Requirements ##
67+
68+
* Python >3.5
69+
* [click package][2]
70+
* A server that is accessible through a public hostname, with GPG installed
71+
and the private key for the KEYID that is to be exported in its keychain
72+
* SSL certificates for the public hostname. It is recommended to use
73+
[Let's Encrypt][3]. You may use an existing certificate for a webserver
74+
running on the host
75+
76+
77+
## Usage ##
78+
79+
Run the script directly as e.g.
80+
81+
./gpg-serve-key \
82+
--cert-file=/etc/letsencrypt/live/michaelgoerz.net/cert.pem \
83+
--key-file=/etc/letsencrypt/live/michaelgoerz.net/privkey.pem \
84+
--host=michaelgoerz.net 57A6CAA6
85+
86+
See `./gpg-serve-key --help` for more details.
87+
88+
This will start temporary webserver at a random port and serve both the public
89+
and the private key at URLs such as
90+
91+
https://michaelgoerz.net:47409/v1f4Y7XixMQ/57A6CAA6-public.key
92+
https://michaelgoerz.net:47409/v1f4Y7XixMQ/57A6CAA6-secret.key
93+
94+
After importing the keys from these URLs, stop the serve by hitting `ctrl+c`.
95+
96+
[1]: https://mssun.github.io/passforios/
97+
[2]: http://click.pocoo.org/5/
98+
[3]: https://letsencrypt.org
99+
100+
"""
101+
import base64
102+
import io
103+
import logging
104+
import os
105+
import ssl
106+
import sys
107+
import time
108+
import types
109+
from subprocess import Popen, PIPE
110+
from http import HTTPStatus
111+
from http.server import HTTPServer, SimpleHTTPRequestHandler
112+
113+
import click
114+
115+
116+
__version__ = '0.1'
117+
118+
119+
class InvalidToken(ValueError):
120+
pass
121+
122+
123+
class ShowKeyHTTPRequestHandler(SimpleHTTPRequestHandler):
124+
"""Respond to HTTP requests for key files"""
125+
126+
server_version = "ShowKeyHTTP/" + __version__
127+
token = ''
128+
rate_delay = 1
129+
key_id = ''
130+
gpg = 'gpg2'
131+
secret_name = '{key_id}-secret.key'
132+
public_name = '{key_id}-public.key'
133+
134+
def send_head(self):
135+
"""Common code for GET and HEAD commands.
136+
137+
This sends the response code and MIME headers.
138+
"""
139+
f = None
140+
secret_name = self.secret_name.format(key_id=self.key_id)
141+
public_name = self.public_name.format(key_id=self.key_id)
142+
if self.path.startswith("/" + self.token):
143+
if self.path.endswith(secret_name):
144+
cmd = [self.gpg, '--armor', '--export-secret-keys',
145+
self.key_id]
146+
elif self.path.endswith(public_name):
147+
cmd = [self.gpg, '--armor', '--export', self.key_id]
148+
else:
149+
self.send_error(HTTPStatus.NOT_FOUND, "File not found")
150+
return None
151+
try:
152+
with Popen(cmd, stdout=PIPE) as proc:
153+
data = proc.stdout.read()
154+
f = io.BytesIO(data)
155+
except OSError:
156+
self.send_error(HTTPStatus.NOT_FOUND, "File not found")
157+
return None
158+
try:
159+
self.send_response(HTTPStatus.OK)
160+
self.send_header("Content-type", 'text/plain')
161+
self.send_header("Content-Length", len(data))
162+
self.send_header("Cache-Control", 'no-store')
163+
self.send_header('X-Content-Type-Options', 'nosniff')
164+
self.send_header(
165+
'strict-transport-security',
166+
'max-age=31536000; includeSubDomains')
167+
self.send_header('x-frame-options', 'SAMEORIGIN')
168+
self.send_header('Referrer-Policy', 'no-referrer')
169+
self.send_header('X-XSS-Protection', '1; mode=block')
170+
self.send_header(
171+
'Content-Security-Policy', "default-src 'none'")
172+
self.end_headers()
173+
return f
174+
except:
175+
f.close()
176+
raise
177+
else:
178+
if self.path == '/':
179+
msg = "Missing token"
180+
invalid_token = False
181+
else:
182+
self.__class__.rate_delay *= 2
183+
msg = "Invalid token: %s. Sleep for %s seconds" % (
184+
self.path[1:], self.rate_delay)
185+
invalid_token = True
186+
self.send_error(
187+
HTTPStatus.FORBIDDEN, msg)
188+
if invalid_token:
189+
raise InvalidToken(self.rate_delay)
190+
return None
191+
192+
193+
def generate_token(nbytes):
194+
"""Generate a URL-safe one-time token with `nbytes` of entropy"""
195+
tok = os.urandom(nbytes)
196+
return base64.urlsafe_b64encode(tok).rstrip(b'=').decode('ascii')
197+
198+
199+
def handle_rate_delay(http_server, request, client_address):
200+
"""Handle an InvalidToken exception by sleeping for the duration of
201+
`rate_delay` passed in the exception"""
202+
type, exc, traceback = sys.exc_info()
203+
if isinstance(exc, InvalidToken):
204+
rate_delay = int(exc.args[0])
205+
click.echo("sleeping for %d seconds (rate delay)" % rate_delay)
206+
time.sleep(rate_delay)
207+
else:
208+
raise
209+
210+
211+
def run_server(
212+
cert_file, key_file, host, key_id, port, gpg,
213+
server_class=HTTPServer, handler_class=ShowKeyHTTPRequestHandler):
214+
"""Run the HTTPS server
215+
216+
Args:
217+
cert_file (str): full path to SSL certificate file
218+
key_file (str): full path to SSL private-key file
219+
host (str): the public hostname where the server will be available. May
220+
be the empty string to bind to every available network interface.
221+
In general, set this to the domain for which the SSL certificate
222+
was generated
223+
key_id (str): The ID of the GPG key that should be exported.
224+
port (int): the port number on which to run the server. If 0, a random
225+
port will be selected
226+
gpg (str): the name (or full path) to the gpg executable
227+
server_class (class): Class that should be instantiated as the HTTP
228+
server
229+
handler_class (class): Class that should be instantiated to handle HTTP
230+
requests
231+
"""
232+
233+
server_address = (host, port)
234+
token = generate_token(nbytes=8)
235+
236+
handler_class.token = token
237+
handler_class.key_id = key_id
238+
handler_class.gpg = gpg
239+
handler_class.secret_name = key_id + "-secret.key"
240+
handler_class.public_name = key_id + "-public.key"
241+
242+
httpd = server_class(server_address, handler_class)
243+
if cert_file is not None or key_file is not None:
244+
protocol = 'https'
245+
httpd.socket = ssl.wrap_socket(
246+
httpd.socket, certfile=cert_file, keyfile=key_file,
247+
server_side=True)
248+
else:
249+
protocol = 'http'
250+
handler_class.have_fork = False
251+
httpd.handle_error = types.MethodType(handle_rate_delay, httpd)
252+
253+
if port == 0:
254+
port = httpd.socket.getsockname()[1]
255+
256+
if host == '':
257+
host = '<host>'
258+
click.echo(
259+
"\nServer running ...\n\n"
260+
"{protocol}://{host}:{port}/{token}/{public_name}\n"
261+
"{protocol}://{host}:{port}/{token}/{secret_name}\n".format(
262+
protocol=protocol, host=host, port=port, token=token,
263+
public_name=handler_class.public_name,
264+
secret_name=handler_class.secret_name))
265+
266+
try:
267+
while True:
268+
httpd.handle_request()
269+
except KeyboardInterrupt:
270+
click.echo("\nQuit.")
271+
272+
273+
@click.command()
274+
@click.help_option('--help', '-h')
275+
@click.version_option(version=__version__)
276+
@click.option(
277+
'--debug', is_flag=True,
278+
help='enable debug logging')
279+
@click.option(
280+
'--cert-file', type=click.Path(exists=True),
281+
help='SSL certificate file. Required unless --no-ssl is given.')
282+
@click.option(
283+
'--key-file', type=click.Path(exists=True),
284+
help='SSL key file. Required unless --no-ssl is given.')
285+
@click.option('--ssl/--no-ssl', default=True,
286+
help='Whether to use encryption (https, on by default). '
287+
'USE AN UNENCRYPTED CONNECTION AT YOUR OWN RISK.')
288+
@click.option('--host', default='', metavar='HOST',
289+
help='hostname to which to bind the server. Defaults to binding to any '
290+
'available interface')
291+
@click.option('--port', default=0,
292+
help='port to which to bind the server. The default is to choose a '
293+
'random port')
294+
@click.option('--gpg', default='gpg2', show_default=True, metavar='EXE',
295+
help='gpg executable')
296+
@click.argument('key_id')
297+
def main(debug, cert_file, key_file, ssl, host, port, gpg, key_id):
298+
"""Serve the public and private GPG key with KEY_ID over https"""
299+
logging.basicConfig(level=logging.WARNING)
300+
logger = logging.getLogger(__name__)
301+
if debug:
302+
logger.setLevel(logging.DEBUG)
303+
logger.debug("Enabled debug output")
304+
305+
if ssl:
306+
if cert_file is None or key_file is None:
307+
click.echo("--cert-file and --key-file must be given")
308+
return None
309+
else:
310+
cert_file = None
311+
key_file = None
312+
313+
run_server(cert_file, key_file, host, key_id, port, gpg)
314+
315+
316+
if __name__ == "__main__":
317+
main()

0 commit comments

Comments
 (0)