Source code for cryptnox_cli.command.helper.masked_input

# -*- coding: utf-8 -*-
"""
Masked entry of PINs, PUKs and passphrases: one mask character is echoed per
character entered, on Windows consoles and POSIX terminals alike.
"""
import codecs
import os
import sys

# UTF-16 surrogate ranges. A character outside the Basic Multilingual Plane,
# an emoji for instance, reaches a Windows console as two of these halves.
_HIGH_SURROGATE = range(0xD800, 0xDC00)
_LOW_SURROGATE = range(0xDC00, 0xE000)


def _masked_input(read_char, prompt, mask):
    """
    Read a secret, echoing one mask character per character entered.

    :param read_char: Callable returning the next UTF-16 code unit as a
                      one-character string, an empty string for a key that
                      carries no character, such as an arrow or function key,
                      or raising EOFError when the input ends.
    :param str prompt: Text to show before the entry
    :param str mask: Character to echo for each character entered

    :return: The entered text
    :rtype: str

    An astral character arrives as a surrogate pair and is joined back into one
    entry, so it echoes one mask character and one backspace removes all of it.
    Only ASCII control characters are left out. Separators and format
    characters, such as the ideographic space a Japanese keyboard produces, are
    part of a passphrase and change the wallet it derives.
    """
    entered = []
    pending = ''
    sys.stdout.write(prompt)
    sys.stdout.flush()
    while True:
        char = read_char()
        if not char:
            continue
        if ord(char) in _HIGH_SURROGATE:
            pending = char
            continue
        if pending:
            if ord(char) in _LOW_SURROGATE:
                # Rebuild the character the pair stands for. Joining the halves
                # as text would leave two unusable ones that cannot be encoded.
                char = chr(0x10000 + (ord(pending) - 0xD800) * 0x400
                           + (ord(char) - 0xDC00))
            pending = ''
        elif ord(char) in _LOW_SURROGATE:
            continue  # Half of a character without its partner, not text

        if char in ('\r', '\n'):  # Enter
            return ''.join(entered)
        if char == '\x03':  # Ctrl+C
            raise KeyboardInterrupt
        if char in ('\b', '\x7f'):  # Backspace/Del
            if entered:
                sys.stdout.write('\b \b')
                sys.stdout.flush()
                entered.pop()
        elif ord(char) > 31:
            entered.append(char)
            sys.stdout.write(mask)
            sys.stdout.flush()


def _console_reader(handle, kernel32, clipboard=None):
    """
    Build a read_char callable over a Windows console input handle.

    Keys are taken from ReadConsoleInputW rather than msvcrt, because the
    console records say whether a key produced a character. Through msvcrt an
    arrow key is announced by a lead byte that cannot be told apart from a
    typed 'a with grave accent', which would either swallow the next character
    or add the arrow's scan code to the secret.
    """
    import ctypes
    import ctypes.wintypes

    class CharUnion(ctypes.Union):
        """Character of a key event, as text or as a byte."""
        _fields_ = [("UnicodeChar", ctypes.c_wchar), ("AsciiChar", ctypes.c_char)]

    class KeyEvent(ctypes.Structure):
        """KEY_EVENT_RECORD of the Windows console API."""
        _fields_ = [("bKeyDown", ctypes.wintypes.BOOL), ("wRepeatCount", ctypes.wintypes.WORD),
                    ("wVirtualKeyCode", ctypes.wintypes.WORD), ("wVirtualScanCode", ctypes.wintypes.WORD),
                    ("uChar", CharUnion), ("dwControlKeyState", ctypes.wintypes.DWORD)]

    class EventUnion(ctypes.Union):
        """Event of an input record. Only key events are read."""
        _fields_ = [("KeyEvent", KeyEvent), ("padding", ctypes.c_byte * 16)]

    class InputRecord(ctypes.Structure):
        """INPUT_RECORD of the Windows console API."""
        _fields_ = [("EventType", ctypes.wintypes.WORD), ("Event", EventUnion)]

    key_event = 0x0001
    vk_menu = 0x12  # Alt
    record = InputRecord()
    read = ctypes.wintypes.DWORD()
    buffered = []

    def read_char():
        while not buffered:
            if not kernel32.ReadConsoleInputW(handle, ctypes.byref(record), 1,
                                              ctypes.byref(read)) or not read.value:
                raise EOFError
            if record.EventType != key_event:
                continue
            # A key delivers its character on both key-down and key-up, so only
            # key-down is taken. The exception is Alt: a character composed with
            # Alt and the numeric keypad arrives on the Alt key-up alone, and the
            # classic console's paste delivers each half of a character outside
            # the Basic Multilingual Plane, an emoji for instance, the same way.
            if (not record.Event.KeyEvent.bKeyDown
                    and record.Event.KeyEvent.wVirtualKeyCode != vk_menu):
                continue
            char = record.Event.KeyEvent.uChar.UnicodeChar
            if char == '\x00':
                continue  # A key that produces no character, such as an arrow
            if char == '\x16' and clipboard:
                # The classic console hands Ctrl+V to a program reading keys instead
                # of pasting. Paste the first line here, as text only, so pasting
                # neither submits the entry unseen nor acts as Backspace or Ctrl+C.
                line = clipboard().replace('\r', '\n').split('\n', 1)[0]
                buffered.extend(c for c in line if c > '\x1f' and c != '\x7f')
                continue
            buffered.extend([char] * max(1, record.Event.KeyEvent.wRepeatCount))
        return buffered.pop(0)

    return read_char


def _clipboard_text():
    """
    Text on the Windows clipboard, or an empty string when it holds none.
    """
    import ctypes

    user32 = ctypes.WinDLL("user32", use_last_error=True)
    kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
    # Handles and pointers are pointer sized; left untyped, ctypes would cut them to 32 bits
    user32.GetClipboardData.restype = ctypes.c_void_p
    kernel32.GlobalLock.argtypes = [ctypes.c_void_p]
    kernel32.GlobalLock.restype = ctypes.c_void_p
    kernel32.GlobalUnlock.argtypes = [ctypes.c_void_p]
    cf_unicodetext = 13
    if not user32.OpenClipboard(None):
        return ''
    try:
        handle = user32.GetClipboardData(cf_unicodetext)
        pointer = kernel32.GlobalLock(handle) if handle else None
        if not pointer:
            return ''
        try:
            return ctypes.wstring_at(pointer)
        finally:
            kernel32.GlobalUnlock(handle)
    finally:
        user32.CloseClipboard()


def _getpass_windows(prompt, mask):
    """
    Masked input for a Windows console.

    Line editing, echo and Ctrl+C handling are switched off for the duration,
    so every keypress is delivered as it is typed and Ctrl+C reaches the loop
    as a character. The console mode is restored afterwards. When standard
    input is not a console there is nothing to mask and the line is read as is.
    """
    import ctypes

    kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
    # A HANDLE is pointer sized; left untyped, ctypes would cut it to 32 bits
    kernel32.GetStdHandle.restype = ctypes.c_void_p
    std_input_handle = -10
    handle = ctypes.c_void_p(kernel32.GetStdHandle(std_input_handle))
    mode = ctypes.c_ulong()
    if not kernel32.GetConsoleMode(handle, ctypes.byref(mode)):
        return input(prompt)

    # ENABLE_PROCESSED_INPUT | ENABLE_LINE_INPUT | ENABLE_ECHO_INPUT
    # | ENABLE_WINDOW_INPUT | ENABLE_VIRTUAL_TERMINAL_INPUT
    line_editing_and_echo = 0x0001 | 0x0002 | 0x0004 | 0x0008 | 0x0200
    try:
        kernel32.SetConsoleMode(handle, mode.value & ~line_editing_and_echo)
        return _masked_input(_console_reader(handle, kernel32, _clipboard_text), prompt, mask)
    finally:
        kernel32.SetConsoleMode(handle, mode.value)
        sys.stdout.write('\n')
        sys.stdout.flush()


def _getpass_posix(prompt, mask):
    """
    Masked input for POSIX terminals.
    The terminal is switched to cbreak mode: keys arrive one at a time without
    echo, while Ctrl+C still raises KeyboardInterrupt. Bytes are decoded
    incrementally, so a multi-byte UTF-8 character counts as one entry.
    When stdin is not a terminal there is nothing to mask and the line is read as is.
    """
    import termios
    import tty

    try:
        fd = sys.stdin.fileno()
        old_settings = termios.tcgetattr(fd)
    except (AttributeError, ValueError, termios.error):
        return input(prompt)

    decoder = codecs.getincrementaldecoder(sys.stdin.encoding or 'utf-8')(errors='ignore')
    entered = []
    sys.stdout.write(prompt)
    sys.stdout.flush()
    try:
        tty.setcbreak(fd)
        while True:
            data = os.read(fd, 1)
            if not data:
                raise EOFError
            char = decoder.decode(data)
            if not char:  # Incomplete multi-byte character
                continue
            if char in ('\n', '\r'):  # Enter
                return ''.join(entered)
            if char in ('\b', '\x7f'):  # Backspace/Del
                if entered:
                    sys.stdout.write('\b \b')
                    sys.stdout.flush()
                    entered.pop()
            elif ord(char) > 31:
                # Only ASCII control characters are left out. Separators and format
                # characters (ideographic space, no-break space, zero width joiner)
                # are part of a passphrase and change the wallet it derives.
                entered.append(char)
                sys.stdout.write(mask)
                sys.stdout.flush()
    finally:
        termios.tcsetattr(fd, termios.TCSADRAIN, old_settings)
        sys.stdout.write('\n')
        sys.stdout.flush()


[docs] def getpass(prompt='Password: ', mask='*'): """ Cross-platform getpass that raises KeyboardInterrupt on Ctrl+C. """ if sys.platform == 'win32': return _getpass_windows(prompt, mask) return _getpass_posix(prompt, mask)