-
Notifications
You must be signed in to change notification settings - Fork 2
/
gpgbridge.py
509 lines (440 loc) · 18.6 KB
/
gpgbridge.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
#!/usr/bin/env python3
import os
import sys
import time
import socket
import select
import logging
import argparse
import threading
import subprocess
class AssuanAgentProxy(object):
def __init__(self):
LOGGER.debug("Importing paramiko")
import paramiko # pylint: disable=E0401
# Attempt to get the get the agent target
LOGGER.debug("Connecting to agent with Paramiko")
agent = paramiko.Agent()
if agent._conn is None:
LOGGER.error("Unable to connect to SSH agent")
exit(19)
if not isinstance(agent._conn, paramiko.win_pageant.PageantConnection):
LOGGER.error("Established SSH agent is not a Pageant connection")
exit(20)
LOGGER.debug(
"Successfully found paramiko-provided pageant agent connection")
self.conn = agent._conn
# Create the TCP socket, bind to a system chosen port
self.sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
self.sock.bind(("127.0.0.1", 0))
self.sock.listen()
# Generate a new nonce
# Sort out the SSH socket file from gpgconf.exe via subprocess
# Clobber the SSH socket file with our newly crafted one
self.nonce = os.urandom(16)
socket_path = subprocess.check_output(("gpgconf.exe", "--list-dirs",
"agent-ssh-socket")).strip()
with open(socket_path, "wb") as fp:
fp.write(("%d\n" % self.sock.getsockname()[1]).encode("ascii"))
fp.write(self.nonce)
self.connections = []
def listen(self):
"""
Listen on the TCP socket for connections, and handle all incoming requests in a single thread
to prevent any clobbering of issues with the Pageant process.
"""
LOGGER.debug("Listening to TCP socket...")
while True:
input_ready, _, _ = select.select([self.sock] + self.connections,
[], [], 5)
for sock in input_ready:
if sock == self.sock:
# handle the server socket
try:
client, address = self.sock.accept()
LOGGER.debug("Accepting from (%s, %s)" %
(str(client), str(address)))
LOGGER.debug("Checking nonce preamble from client")
buf = client.recv(16)
if buf != self.nonce:
LOGGER.debug(
"Nonce check failed (%s, %s), not accepting client"
% (repr(buf), repr(self.nonce)))
client.shutdown(socket.SHUT_RDWR)
client.close()
else:
LOGGER.debug(
"Nonce check succeeded, adding client")
self.connections.append(client)
except socket.error as e:
LOGGER.warn("Socket error encountered: %s" % str(e))
else:
LOGGER.debug(
"Non-server socket %s received data" % str(sock))
buf = sock.recv(4096)
if len(buf) == 0:
LOGGER.info("Closing socket %s" % str(sock))
try:
sock.shutdown(socket.SHUT_RDWR)
sock.close()
except:
pass
self.connections = [
c for c in self.connections if c != sock
]
else:
LOGGER.debug("Sending data to agent: %s" % repr(buf))
self.conn.send(buf)
resp = self.conn.recv(4096)
LOGGER.debug(
"Sending response to client: %s" % repr(resp))
sock.sendall(resp if resp != "" else b"")
LOGGER.debug("Finished listening to TCP socket messages.")
# # Once we daemonize, we don't have access to our path anymore, so this causes all kinds of isssues.
# # Use the non-daemon parent to get the absolute paths to the binaries we care about.
# BINARIES = {
# bin_name: subprocess.check_output(("which",
# bin_name)).decode("ascii").strip()
# for bin_name in
# ["gpg-agent.exe", "gpgconf.exe", "gpgconf", "wslpath", "powershell.exe"]
# }
LOGGER = logging.getLogger()
LOGGER.addHandler(logging.StreamHandler(sys.stderr))
def read_assuan_file(filename):
LOGGER.debug("Opening Assuan socket to read nonce: %s" % filename)
with open(filename, "rb") as fp:
windows_port = int(fp.readline().strip().decode("ascii"))
windows_payload = fp.read()
LOGGER.debug("Read %d bytes of nonce for port %d" %
(len(windows_payload), windows_port))
return ("127.0.0.1", windows_port), windows_payload
def handle(sock, address, sock_name):
# Reference:
# - https://dev.gnupg.org/T3883
remote_address, preamble = read_assuan_file(sock_name)
LOGGER.debug("Opening socket to TCP side")
rs = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
rs.connect(remote_address)
LOGGER.debug("TCP socket open %s" % repr(rs))
LOGGER.debug("Sending connection nonce")
rs.sendall(preamble)
while True:
input_ready, _, _ = select.select([rs, sock], [], [], 0.1)
if rs in input_ready:
LOGGER.debug("TCP has stuff")
buf = rs.recv(4096)
# If we've been notified there's a receive, but no bytes read, then
# we close the socket
if len(buf) == 0:
break
else:
LOGGER.debug("To unix: %s" % repr(buf))
sock.sendall(buf)
if sock in input_ready:
LOGGER.debug("Unix has stuff")
buf = sock.recv(4096)
if len(buf) == 0:
break # Connection closed, see above.
else:
LOGGER.debug("To remote: %s" % repr(buf))
rs.sendall(buf)
LOGGER.debug("Closing unix socket %s" % repr(sock))
sock.shutdown(socket.SHUT_RDWR)
sock.close()
LOGGER.debug("Closing TCP socket %s" % repr(rs))
rs.shutdown(socket.SHUT_RDWR)
rs.close()
def derive_assuan_socket(socket_type):
LOGGER.debug("Deriving Assuan socket location")
windows_path = subprocess.check_output(("gpgconf.exe", "--list-dirs",
socket_type)).strip()
return subprocess.check_output(("wslpath", windows_path)).strip()
def derive_unix_socket(socket_type):
LOGGER.debug("Deriving unix socket location")
return subprocess.check_output(("gpgconf", "--list-dirs",
socket_type)).strip()
def start_listener(assuan_socket, unix_socket, no_clobber):
LOGGER.debug("Setting up Unix socket")
us = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) # pylint: disable=E1101
try:
us.bind(unix_socket)
except OSError as e:
# If the errno is 98 (Address In Use), and we're clobbering, then try to remove it and re-bind
if e.errno == 98 and not no_clobber:
LOGGER.debug(
"Unix socket bind unsuccessful due to address already in use. Clobbering."
)
os.remove(unix_socket)
us.bind(unix_socket)
else:
raise e
LOGGER.debug("Unix socket bind successful, listening...")
us.listen(1)
while True:
input_ready, _, _ = select.select([us], [], [], 5)
if len(input_ready) > 0:
# handle the server socket
try:
client, address = us.accept()
LOGGER.debug(
"Accepting from (%s, %s)" % (str(client), str(address)))
thread = threading.Thread(target=lambda c=client, a=address, s=assuan_socket: handle(c, a, s))
thread.start()
except socket.error as e:
LOGGER.warn("Socket error encountered: %s" % str(e))
def start_gpg_agent(verbose, with_ssh_support):
"""
Assuming that gpg-agent.exe is in the PATH, start it and fork it to the background.
"""
# If the call returns 0, then an agent is running and available
# If the call returns 2, then no agent is running
LOGGER.debug(
"Checking on current gpg-agent.exe processes: %s" % "gpg-agent.exe")
returncode = subprocess.call(
("gpg-agent.exe", ),
stdout=(None if verbose else subprocess.DEVNULL), # pylint: disable=E1101
stderr=(None if verbose else subprocess.DEVNULL)) # pylint: disable=E1101
if returncode == 2:
LOGGER.info(
"No existing gpg-agent.exe process detected, starting a new one")
proc = subprocess.Popen(
[
# Not 100% sure why, but this only works if spawned under powershell
"powershell.exe",
"-command",
"gpg-agent.exe",
"--daemon",
("--verbose" if verbose else "--quiet")
] + (
# Enable both SSH and PuTTY support.
["--enable-ssh-support", "--enable-putty-support"]
if with_ssh_support else []),
stdout=(None if verbose else subprocess.DEVNULL), # pylint: disable=E1101
stderr=(None if verbose else subprocess.DEVNULL)) # pylint: disable=E1101
agent_up = False
for num_checks in range(30, 0, -1):
LOGGER.debug("Testing agent until it comes up. %d checks left." %
num_checks)
try:
return_code = subprocess.call(
("gpg-agent.exe"),
stdout=(None if verbose else subprocess.DEVNULL), # pylint: disable=E1101
stderr=(None if verbose else subprocess.DEVNULL),
timeout=1) # pylint: disable=E1101
except subprocess.TimeoutExpired:
return_code = None
if return_code == 0:
agent_up = True
break
else:
time.sleep(1)
if not agent_up:
LOGGER.error("Unable to bring up gpg-agent.exe")
exit(1)
LOGGER.debug(
"Killing process, agent should continue in the background")
proc.kill()
proc.communicate()
def launch_gpg_agent(verbose, with_ssh_support):
if with_ssh_support:
LOGGER.debug("Enableing SSH and PuTTY support via gpg-agent.conf")
for opt in ["enable-ssh-support", "enable-putty-support"]:
LOGGER.debug("Setting %s" % opt)
proc = subprocess.Popen(
("gpgconf.exe", "--change-options", "gpg-agent"),
stdin=subprocess.PIPE,
stdout=(None if verbose else subprocess.DEVNULL),
stderr=(None if verbose else subprocess.DEVNULL))
LOGGER.debug("Writing option config")
proc.communicate(input=("%s:0:1" % opt).encode("ascii"))
LOGGER.debug("Config set with returncode %d" % proc.returncode)
LOGGER.debug("Launching gpg-agent via gpgconf --launch")
returncode = subprocess.check_call(("gpgconf.exe", "--launch",
"gpg-agent"))
LOGGER.debug("Agent launched with returncode %d" % returncode)
def __listen_loop(threads):
LOGGER.debug("Starting all listening threads")
for thread in threads.values():
thread.start()
LOGGER.info("All listening threads ready")
LOGGER.debug("Waiting on the termination of main thread via join()")
threads["agent-socket"].join()
for thread in threads.values():
try:
thread.kill()
thread.join()
except:
pass
def check_for_unix_agent():
"""
Check to see if there is an existing Unix gpg-agent by attempting to connect to the gpg-agent
socket.
"""
unix_sock_name = derive_unix_socket("agent-socket")
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) # pylint: disable=E1101
try:
sock.connect(unix_sock_name)
return True
except ConnectionRefusedError:
return False
except FileNotFoundError:
return False
def pageant_main(parsed_args):
"""
Start the agent proxy between Pageant/Windows memory-mapping and Assuan sockets.
"""
if sys.platform != "win32":
LOGGER.error(
"This proxy must be run as a Windows process to detect the Pageant process handle."
)
exit(17)
LOGGER.debug("Constructing agent proxy")
proxy = AssuanAgentProxy()
LOGGER.debug("Listening on agent proxy")
proxy.listen()
def get_windows_script_location():
unix_path = os.path.realpath(__file__)
windows_path = subprocess.check_output(
("wslpath", "-w", unix_path)).decode("utf-8").strip()
return windows_path
def bridge_main(parsed_args):
"""
Start the GPG bridge between Assuan and Unix sockets.
"""
LOGGER.debug("Checking for existing Unix agent")
if check_for_unix_agent():
LOGGER.error("Existing Unix agent found. Not starting.")
exit(2)
LOGGER.info("Starting gpg-agent.exe process")
if parsed_args.launch:
launch_gpg_agent(parsed_args.verbose, parsed_args.enable_ssh_support)
else:
start_gpg_agent(parsed_args.verbose, parsed_args.enable_ssh_support)
threads = dict()
for socket_name in [
"agent-socket", "agent-extra-socket", "agent-browser-socket"
] + (["agent-ssh-socket"] if parsed_args.enable_ssh_support else []):
assuan_socket = parsed_args.assuan_socket if \
parsed_args.assuan_socket is not None else \
derive_assuan_socket(socket_name)
LOGGER.debug("Assuan socket location set to \"%s\" for \"%s\"" %
(assuan_socket, socket_name))
unix_socket = parsed_args.unix_socket if \
parsed_args.unix_socket is not None else \
derive_unix_socket(socket_name)
LOGGER.debug("Unix socket location set to \"%s\" for \"%s\"" %
(unix_socket, socket_name))
LOGGER.debug("Crafting listening thread for gpg-agent socket \"%s\"" %
socket_name)
threads[socket_name] = threading.Thread(
target=lambda
a=assuan_socket,
u=unix_socket,
nc=parsed_args.no_clobber: start_listener(a, u, nc))
pageant_proxy_proc = None
if parsed_args.enable_ssh_support:
if not parsed_args.no_pageant:
LOGGER.debug("Starting pageant proxy process")
pageant_proxy_proc = subprocess.Popen(
[
"powershell.exe", "-command", "python3.exe",
get_windows_script_location(), "--pageant-proxy"
] + (["--verbose"] if parsed_args.verbose else []),
stdout=(None if parsed_args.verbose else subprocess.DEVNULL),
stderr=(None if parsed_args.verbose else subprocess.DEVNULL))
LOGGER.debug(
"Pageant proxy started as WSL PID %d" % pageant_proxy_proc.pid)
else:
LOGGER.debug(
"Skipping pageant proxy due to command line argument.")
if parsed_args.daemon:
try:
import daemon # pylint:disable=E0401
except:
LOGGER.error((
"Failed to become a daemon, likely due to missing daemon library. "
"HINT: Try a background process instead in a pinch."))
LOGGER.debug("Entering daemon context")
with daemon.DaemonContext():
if parsed_args.verbose:
LOGGER.addHandler(
logging.FileHandler("/tmp/gpgbridge.log", "w"))
LOGGER.debug("Launching listen process inside of daemon")
__listen_loop(threads)
LOGGER.debug("Listen process finished inside of daemon")
else:
__listen_loop(threads)
if pageant_proxy_proc is not None:
LOGGER.debug("Killing and syncing with pagent proxy process")
pageant_proxy_proc.kill()
pageant_proxy_proc.communicate()
if __name__ == "__main__":
parser = argparse.ArgumentParser(
description=
"""A Python bridge to permit WSL gpg toolchain elements to interact with a gpg-agent.exe running natively in Windows."""
)
parser.add_argument(
"--daemon",
action="store_true",
default=False,
help="""Fork into the background quietly.""")
parser.add_argument(
"--launch",
action="store_true",
default=False,
help=
"""Use gpgconf --launch as the mechanism for spawning the gpg-agent, instead of a Python subprocess.
This method is more robust against process exits when closing terminal windows but requires modification of"""
)
parser.add_argument(
"--verbose",
action="store_true",
default=False,
help="""Verbose log to stderr""")
parser.add_argument(
"--enable-ssh-support",
action="store_true",
default=False,
help="""Enable listening on the SSH sockets as well""")
parser.add_argument(
"--assuan-socket",
default=None,
help=
"""Explicitly state the location of the Assuan socket to act as the TCP endpoint.
If unspecified, use the value from gpgconf.exe.""")
parser.add_argument(
"--unix-socket",
default=None,
help=
"""Explicitly state the location of the filesystem location to act as the unix endpoint.
If unspecified, use the value from gpgconf.""")
parser.add_argument(
"--no-clobber",
action="store_true",
default=False,
help=
"""If Unix sockets exist, will not attempt to remove (clobber) them."""
)
parser.add_argument(
"--no-pageant",
action="store_true",
default=False,
help="""
Disable spawning of the Pageant proxy, and attempt to use the GnuPG Assuan socket naively.
""")
parser.add_argument(
"--pageant-proxy",
action="store_true",
default=False,
help=
"""Start the necessary Windows process to interact with the Pageant agent through Assuan sockets."""
)
parsed_args = parser.parse_args()
if parsed_args.verbose:
LOGGER.setLevel(logging.DEBUG)
else:
LOGGER.setLevel(logging.INFO)
if parsed_args.pageant_proxy:
pageant_main(parsed_args)
else:
bridge_main(parsed_args)