394 lines
14 KiB
Python
Executable File
394 lines
14 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
#
|
|
# Copyright (C) 2012-2017 W. Trevor King <wking@tremily.us>
|
|
#
|
|
# This file is part of pyassuan.
|
|
#
|
|
# pyassuan is free software: you can redistribute it and/or modify it under the
|
|
# terms of the GNU General Public License as published by the Free Software
|
|
# Foundation, either version 3 of the License, or (at your option) any later
|
|
# version.
|
|
#
|
|
# pyassuan is distributed in the hope that it will be useful, but WITHOUT ANY
|
|
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
|
|
# A PARTICULAR PURPOSE. See the GNU General Public License for more details.
|
|
#
|
|
# You should have received a copy of the GNU General Public License along with
|
|
# pyassuan. If not, see <http://www.gnu.org/licenses/>.
|
|
|
|
"""Simple pinentry program for getting pins from a terminal.
|
|
"""
|
|
|
|
import copy as _copy
|
|
import os as _os
|
|
import os.path as _os_path
|
|
import pprint as _pprint
|
|
import re as _re
|
|
import signal as _signal
|
|
import sys as _sys
|
|
import termios as _termios
|
|
|
|
from pyassuan import __version__
|
|
from pyassuan import server as _server
|
|
from pyassuan import common as _common
|
|
from pyassuan import error as _error
|
|
|
|
|
|
class PinEntry (_server.AssuanServer):
|
|
"""pinentry protocol server
|
|
|
|
See ``pinentry-0.8.0/doc/pinentry.texi`` at::
|
|
|
|
ftp://ftp.gnupg.org/gcrypt/pinentry/
|
|
http://www.gnupg.org/aegypten/
|
|
|
|
for details on the pinentry interface.
|
|
|
|
Alternatively, you can just watch the logs and guess ;). Here's a
|
|
trace when driven by GnuPG 2.0.28 (libgcrypt 1.6.3)::
|
|
|
|
S: OK Your orders please
|
|
C: OPTION grab
|
|
S: OK
|
|
C: OPTION ttyname=/dev/pts/6
|
|
S: OK
|
|
C: OPTION ttytype=xterm
|
|
S: OK
|
|
C: OPTION lc-ctype=en_US.UTF-8
|
|
S: OK
|
|
C: OPTION lc-messages=en_US.UTF-8
|
|
S: OK
|
|
C: OPTION allow-external-password-cache
|
|
S: OK
|
|
C: OPTION default-ok=_OK
|
|
S: OK
|
|
C: OPTION default-cancel=_Cancel
|
|
S: OK
|
|
C: OPTION default-yes=_Yes
|
|
S: OK
|
|
C: OPTION default-no=_No
|
|
S: OK
|
|
C: OPTION default-prompt=PIN:
|
|
S: OK
|
|
C: OPTION default-pwmngr=_Save in password manager
|
|
S: OK
|
|
C: OPTION default-cf-visi=Do you really want to make your passphrase visible on the screen?
|
|
S: OK
|
|
C: OPTION default-tt-visi=Make passphrase visible
|
|
S: OK
|
|
C: OPTION default-tt-hide=Hide passphrase
|
|
S: OK
|
|
C: GETINFO pid
|
|
S: D 14309
|
|
S: OK
|
|
C: SETKEYINFO u/S9464F2C2825D2FE3
|
|
S: OK
|
|
C: SETDESC Enter passphrase%0A
|
|
S: OK
|
|
C: SETPROMPT Passphrase
|
|
S: OK
|
|
C: GETPIN
|
|
S: D testing!
|
|
S: OK
|
|
C: BYE
|
|
S: OK closing connection
|
|
"""
|
|
_digit_regexp = _re.compile(r'\d+')
|
|
|
|
# from proc(5): pid comm state ppid pgrp session tty_nr tpgid
|
|
_tpgrp_regexp = _re.compile(r'\d+ \(\S+\) . \d+ \d+ \d+ \d+ (\d+)')
|
|
|
|
def __init__(self, name='pinentry', strict_options=False,
|
|
single_request=True, **kwargs):
|
|
self.strings = {}
|
|
self.connection = {}
|
|
super(PinEntry, self).__init__(
|
|
name=name, strict_options=strict_options,
|
|
single_request=single_request, **kwargs)
|
|
self.valid_options.append('ttyname')
|
|
|
|
def reset(self):
|
|
super(PinEntry, self).reset()
|
|
self.strings.clear()
|
|
self.connection.clear()
|
|
|
|
# user interface
|
|
|
|
def _connect(self):
|
|
self.logger.info('connecting to user')
|
|
self.logger.debug('options:\n{}'.format(_pprint.pformat(self.options)))
|
|
tty_name = self.options.get('ttyname', None)
|
|
if tty_name:
|
|
self.connection['tpgrp'] = self._get_pgrp(tty_name)
|
|
self.logger.info(
|
|
'open to-user output stream for {}'.format(tty_name))
|
|
self.connection['to_user'] = open(tty_name, 'w')
|
|
self.logger.info(
|
|
'open from-user input stream for {}'.format(tty_name))
|
|
self.connection['from_user'] = open(tty_name, 'r')
|
|
self.logger.info('get current termios line discipline')
|
|
self.connection['original termios'] = _termios.tcgetattr(
|
|
self.connection['to_user']) # [iflag, oflag, cflag, lflag, ...]
|
|
new_termios = _copy.deepcopy(self.connection['original termios'])
|
|
# translate carriage return to newline on input
|
|
new_termios[0] |= _termios.ICRNL
|
|
# do not ignore carriage return on input
|
|
new_termios[0] &= ~_termios.IGNCR
|
|
# do not echo input characters
|
|
new_termios[3] &= ~_termios.ECHO
|
|
# echo input characters
|
|
#new_termios[3] |= _termios.ECHO
|
|
# echo the NL character even if ECHO is not set
|
|
new_termios[3] |= _termios.ECHONL
|
|
# enable canonical mode
|
|
new_termios[3] |= _termios.ICANON
|
|
self.logger.info('adjust termios line discipline')
|
|
_termios.tcsetattr(
|
|
self.connection['to_user'], _termios.TCSANOW, new_termios)
|
|
self.logger.info('send SIGSTOP to pgrp {}'.format(
|
|
self.connection['tpgrp']))
|
|
#_os.killpg(self.connection['tpgrp'], _signal.SIGSTOP)
|
|
_os.kill(-self.connection['tpgrp'], _signal.SIGSTOP)
|
|
self.connection['tpgrp stopped'] = True
|
|
else:
|
|
self.logger.info('no TTY name given; use stdin/stdout for I/O')
|
|
self.connection['to_user'] = _sys.stdout
|
|
self.connection['from_user'] = _sys.stdin
|
|
self.logger.info('connected to user')
|
|
self.connection['to_user'].write('\n') # give a clean line to work on
|
|
self.connection['active'] = True
|
|
|
|
def _disconnect(self):
|
|
self.logger.info('disconnecting from user')
|
|
try:
|
|
if self.connection.get('original termios', None):
|
|
self.logger.info('restore original termios line discipline')
|
|
_termios.tcsetattr(
|
|
self.connection['to_user'], _termios.TCSANOW,
|
|
self.connection['original termios'])
|
|
if self.connection.get('tpgrp stopped', None) is True:
|
|
self.logger.info(
|
|
'send SIGCONT to pgrp {}'.format(self.connection['tpgrp']))
|
|
#_os.killpg(self.connection['tpgrp'], _signal.SIGCONT)
|
|
_os.kill(-self.connection['tpgrp'], _signal.SIGCONT)
|
|
if self.connection.get('to_user', None) not in [None, _sys.stdout]:
|
|
self.logger.info('close to-user output stream')
|
|
self.connection['to_user'].close()
|
|
if self.connection.get('from_user',None) not in [None,_sys.stdout]:
|
|
self.logger.info('close from-user input stream')
|
|
self.connection['from_user'].close()
|
|
finally:
|
|
self.connection = {'active': False}
|
|
self.logger.info('disconnected from user')
|
|
|
|
def _get_pgrp(self, tty_name):
|
|
self.logger.info('find process group contolling {}'.format(tty_name))
|
|
proc = '/proc'
|
|
for name in _os.listdir(proc):
|
|
path = _os_path.join(proc, name)
|
|
if not (self._digit_regexp.match(name) and _os_path.isdir(path)):
|
|
continue # not a process directory
|
|
self.logger.debug('checking process {}'.format(name))
|
|
fd_path = _os_path.join(path, 'fd', '0')
|
|
try:
|
|
link = _os.readlink(fd_path)
|
|
except OSError as e:
|
|
self.logger.debug('not our process: {}'.format(e))
|
|
continue # permission denied (not one of our processes)
|
|
if link != tty_name:
|
|
self.logger.debug('wrong tty: {}'.format(link))
|
|
continue # not attached to our target tty
|
|
stat_path = _os_path.join(path, 'stat')
|
|
stat = open(stat_path, 'r').read()
|
|
self.logger.debug('check stat for pgrp: {}'.format(stat))
|
|
match = self._tpgrp_regexp.match(stat)
|
|
assert match != None, stat
|
|
pgrp = int(match.group(1))
|
|
self.logger.info('found pgrp {} for {}'.format(pgrp, tty_name))
|
|
return pgrp
|
|
raise ValueError(tty_name)
|
|
|
|
def _write(self, string):
|
|
"Write text to the user's terminal."
|
|
self.connection['to_user'].write(string + '\n')
|
|
self.connection['to_user'].flush()
|
|
|
|
def _read(self):
|
|
"Read and return a line from the user's terminal."
|
|
# drop trailing newline
|
|
return self.connection['from_user'].readline()[:-1]
|
|
|
|
def _prompt(self, prompt='?', error=None, add_colon=True):
|
|
if add_colon:
|
|
prompt += ':'
|
|
if error:
|
|
self.connection['to_user'].write(error)
|
|
self.connection['to_user'].write('\n')
|
|
self.connection['to_user'].write(prompt)
|
|
self.connection['to_user'].write(' ')
|
|
self.connection['to_user'].flush()
|
|
return self._read()
|
|
|
|
# assuan handlers
|
|
|
|
def _handle_GETINFO(self, arg):
|
|
if arg == 'pid':
|
|
yield _common.Response('D', str(_os.getpid()).encode('ascii'))
|
|
elif arg == 'version':
|
|
yield _common.Response('D', __version__.encode('ascii'))
|
|
else:
|
|
raise _error.AssuanError(message='Invalid parameter')
|
|
yield _common.Response('OK')
|
|
|
|
def _handle_SETKEYINFO(self, arg):
|
|
self.strings['key info'] = arg
|
|
yield _common.Response('OK')
|
|
|
|
def _handle_CLEARPASSPHRASE(self, arg):
|
|
yield _common.Response('OK')
|
|
|
|
def _handle_SETDESC(self, arg):
|
|
self.strings['description'] = arg
|
|
yield _common.Response('OK')
|
|
|
|
def _handle_SETPROMPT(self, arg):
|
|
self.strings['prompt'] = arg
|
|
yield _common.Response('OK')
|
|
|
|
def _handle_SETERROR(self, arg):
|
|
self.strings['error'] = arg
|
|
yield _common.Response('OK')
|
|
|
|
def _handle_SETTITLE(self, arg):
|
|
self.strings['title'] = arg
|
|
yield _common.Response('OK')
|
|
|
|
def _handle_SETOK(self, arg):
|
|
self.strings['ok'] = arg
|
|
yield _common.Response('OK')
|
|
|
|
def _handle_SETCANCEL(self, arg):
|
|
self.strings['cancel'] = arg
|
|
yield _common.Response('OK')
|
|
|
|
def _handle_SETNOTOK(self, arg):
|
|
self.strings['not ok'] = arg
|
|
yield _common.Response('OK')
|
|
|
|
def _handle_SETQUALITYBAR(self, arg):
|
|
"""Adds a quality indicator to the GETPIN window.
|
|
|
|
This indicator is updated as the passphrase is typed. The
|
|
clients needs to implement an inquiry named "QUALITY" which
|
|
gets passed the current passphrase (percent-plus escaped) and
|
|
should send back a string with a single numerical vauelue
|
|
between -100 and 100. Negative values will be displayed in
|
|
red.
|
|
|
|
If a custom label for the quality bar is required, just add
|
|
that label as an argument as percent escaped string. You will
|
|
need this feature to translate the label because pinentry has
|
|
no internal gettext except for stock strings from the toolkit
|
|
library.
|
|
|
|
If you want to show a tooltip for the quality bar, you may use
|
|
|
|
C: SETQUALITYBAR_TT string
|
|
S: OK
|
|
|
|
With STRING being a percent escaped string shown as the tooltip.
|
|
|
|
Here is a real world example of these commands in use:
|
|
|
|
C: SETQUALITYBAR Quality%3a
|
|
S: OK
|
|
C: SETQUALITYBAR_TT The quality of the text entered above.%0aPlease ask your administrator for details about the criteria.
|
|
S: OK
|
|
"""
|
|
self.strings['qualitybar'] = arg
|
|
yield _common.Response('OK')
|
|
|
|
def _handle_SETQUALITYBAR_TT(self, arg):
|
|
self.strings['qualitybar_tooltip'] = arg
|
|
yield _common.Response('OK')
|
|
|
|
def _handle_GETPIN(self, arg):
|
|
try:
|
|
self._connect()
|
|
self._write(self.strings['description'])
|
|
if 'key info' in self.strings:
|
|
self._write('key: {}'.format(self.strings['key info']))
|
|
if 'qualitybar' in self.strings:
|
|
self._write(self.strings['qualitybar'])
|
|
pin = self._prompt(
|
|
prompt=self.strings['prompt'],
|
|
error=self.strings.get('error'),
|
|
add_colon=False)
|
|
finally:
|
|
self._disconnect()
|
|
yield _common.Response('D', pin.encode('ascii'))
|
|
yield _common.Response('OK')
|
|
|
|
def _handle_CONFIRM(self, arg):
|
|
try:
|
|
self._connect()
|
|
self._write(self.strings['description'])
|
|
self._write('1) '+self.strings['ok'])
|
|
self._write('2) '+self.strings['not ok'])
|
|
value = self._prompt('?')
|
|
finally:
|
|
self._disconnect()
|
|
if value == '1':
|
|
yield _common.Response('OK')
|
|
else:
|
|
raise _error.AssuanError(message='Not confirmed')
|
|
|
|
def _handle_MESSAGE(self, arg):
|
|
self._write(self.strings['description'])
|
|
yield _common.Response('OK')
|
|
|
|
def _handle_CONFIRM(self, args):
|
|
assert args == '--one-button', args
|
|
try:
|
|
self._connect()
|
|
self._write(self.strings['description'])
|
|
self._write('1) '+self.strings['ok'])
|
|
value = self._prompt('?')
|
|
finally:
|
|
self._disconnect()
|
|
assert value == '1', value
|
|
yield _common.Response('OK')
|
|
|
|
|
|
if __name__ == '__main__':
|
|
import argparse
|
|
import logging
|
|
import traceback
|
|
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument(
|
|
'-v', '--version', action='version',
|
|
version='%(prog)s {}'.format(__version__))
|
|
parser.add_argument(
|
|
'-V', '--verbose', action='count', default=0,
|
|
help='increase verbosity')
|
|
parser.add_argument(
|
|
'--display',
|
|
help='set X display (ignored by this implementation)')
|
|
|
|
args = parser.parse_args()
|
|
|
|
p = PinEntry()
|
|
|
|
if args.verbose:
|
|
p.logger.setLevel(max(
|
|
logging.DEBUG, p.logger.level - 10*args.verbose))
|
|
|
|
try:
|
|
p.run()
|
|
except:
|
|
p.logger.error(
|
|
'exiting due to exception:\n{}'.format(
|
|
traceback.format_exc().rstrip()))
|
|
raise
|