diff --git a/README.md b/README.md index 053fe4c2..0bb3dd2a 100644 --- a/README.md +++ b/README.md @@ -36,9 +36,7 @@ releases. * **Installation** instructions are [here](doc/INSTALL.md) * **SSH** instructions and common use cases are [here](doc/README-SSH.md) - - Note: If you're using Windows, see [trezor-ssh-agent](https://github.com/martin-lizner/trezor-ssh-agent) by Martin Lízner. - * **GPG** instructions and common use cases are [here](doc/README-GPG.md) * **age** instructions and common use cases are [here](doc/README-age.md) * Instructions to configure a Trezor-style **PIN entry** program are [here](doc/README-PINENTRY.md) +* Instructions for using the tools on Windows are [here](doc/README-Windows.md) diff --git a/doc/README-Windows.md b/doc/README-Windows.md new file mode 100644 index 00000000..28d08414 --- /dev/null +++ b/doc/README-Windows.md @@ -0,0 +1,371 @@ +# Windows installation and usage instructions + +## Preface + +Since this library supports multiple hardware security devices, this document uses the term `` in commands to refer to the device of your choice. For example, if using Ledger Nano S, the command `-agent` becomes `ledger-agent`. + +Installation and building has to be done with administrative privileges. Without these, the agent would only be installed for the current user, and could therefore not be used as a service. To run an administrative shell, hold the Windows key on the keyboard, and press R. In the input box that appears, type either "cmd" or "powershell" (Based on your preference. Both work), and then hold the Ctrl and Shift keys, and press Enter. A User Account Control dialog will pop up. Simply press "Yes". + +These instructions makes use of [WinGet Client](https://github.com/microsoft/winget-cli) which is bundled with Windows 10+. If using an older version of Windows, it is possible to use [Chocolatey](https://community.chocolatey.org/courses/installation/installing?method=installing-chocolatey) instead. Direct links to installer downloads are also provided, if manual install is preferred. + +## Installation + +### 1. Install Python + +Install using WinGet, or [download the installer directly](https://www.python.org/downloads/windows/) +``` +winget install python3 +``` + +Verify Python is installed and in the path: +``` +python --version +``` +Example output: +``` +C:\WINDOWS\system32>python --version +Python 3.11.4 +``` +You may need to close and reopen the shell to update environment variables. Alternately, if you have Chocolatey installed, you may use `refreshenv` instead. + +Ensure `pip` is available and up to date: +``` +python -m pip install --upgrade pip +``` + +### 2. Install the agent + +Run the following command: +``` +pip install -agent +``` + +**Note:** Some agent packages use underscore instead of hyphen in the package name. For example, the Trezor agent package is `trezor-agent`, while the Ledger agent package is `ledger_agent`. This only applies to the `pip` package names. All other commands use a hyphen for all devices. + +## Building from source + +First, ensure you have Python installed, as described in the above section. Next, ensure you have Git installed: +``` +winget install -e --id Git.Git +``` + +Create a directory for the source code, and clone the repository. Before running this command, you may want to change to a directory where you usually hold documents or source code packages. +``` +git clone https://github.com/romanz/trezor-agent.git +``` + +Build and install the library: +``` +pip install -e trezor-agent +``` + +Build and install the agent of your choice: +``` +pip install -e trezor-agent/agents/ +``` + +## Usage + +### Using SSH + +You can use SSH either as a service offering keys in the background to any SSH clients, or to run an SSH client directly. For the latter case, you will need to install OpenSSH. + +#### Installing OpenSSH + +If using Windows 10+, first open the optional features dialog: +``` +fodhelper +``` +Click on the "Add a feature" button. In the "Find an available optional feature", type "OpenSSH Client". If you can't find it, it may already be installed. You can, instead, look for it "Find an installed optional feature". If it is not installed, simply click the checkbox next to it, and then click on "Install". + +Alternatively, you can install the latest version using WinGet: +``` +winget install "openssh beta" +``` + +If using an older version of Windows, you can install it using Chocolatey instead: +``` +choco install openssh +``` + +#### Set up a key + +You will need to do this once for every server you to which intend to connect using your device. Create and save the key using the following command: +``` +-agent -e ed25519 user@myserver.com >> %USERPROFILE%/.ssh/.pub +``` +Where `user` is the user with which you intend to connect (e.g. `root`), and `myserver.com` is the server to which you intend to connect (e.g. `github.com`). **Note:** The device will have to be unlocked during this operation. But no confirmation is required. + +You will now need to copy the contents of the created file to the server's `~/.ssh/authorized_keys`. The method from doing this can vary from server to server. If you have direct access (e.g. via a password), you may edit the file directly. Public servers, like GitHub for example, may allow uploading or pasting the key via a `Settings` section, such as `Access` or `SSH keys`. Refer to the specific service's help pages to see instructions regarding adding SSH keys to the server. + +If you do not intend to run the agent as a service, you may delete the `%USERPROFILE%/.ssh/.pub` file after uploading the key. + +#### Connect to an SSH server directly + +Once set up, use the following command to connect to the server: +``` +-agent -e ed25519 user@myserver.com -c +``` + +You will be required to authorize the use of the key on the device. + +#### Running as a service + +Adding services to Windows requires the use of a third-party tool. The recommended tool for this task is [NSSM](https://nssm.cc/download). It can be installed using the direct link, or via Chocolatey: +``` +choco install nssm +``` + +To set up the service, use the following commands: +``` +nssm install "-agent" -agent "file:%USERPROFILE%/.ssh/.pub" -f --sock-path=\\.\pipe\openssh-ssh-agent +nssm set "-agent" DisplayName "Hardware Device SSH Authentication Agent" +``` + +Before running the service, make sure OpenSSH's `ssh-agent` is not running: +``` +nssm stop ssh-agent +nssm set ssh-agent Start SERVICE_DISABLED +``` +If you receive the error `The specified service does not exist as an installed service.`, this just means OpenSSH is not installed, and you may proceed to the next step. + +Then start the service using: +``` +nssm set "-agent" Start SERVICE_AUTO_START +nssm start "-agent" +``` + +If you do not need it anymore, you can delete the service at any time using the command: +``` +nssm remove "-agent" confirm +``` + +#### Using the agent with PuTTY + +The SSH authentication agent is designed to work with OpenSSH and compatible programs. Using it with PuTTY requires a third-party tool. The recommended tool for this task is [WinSSH-Pageant](https://github.com/ndbeals/winssh-pageant/releases). + +You may download the installer directly, or install it using WinGet: +``` +winget install winssh-pageant +``` + +Once installed, it will automatically run on startup, and deliver key requests to any running SSH agent. This requires the agent to be running as a service. See the section above. + +### Using GPG + +To use GPG on Windows, you will need [Gpg4win](https://www.gpg4win.org/). + +You can [download it directly](https://www.gpg4win.org/thanks-for-download.html) or install it via WinGet +``` +winget install -e --id GnuPG.Gpg4win +``` +Or using Chocolatey: +``` +choco install gpg4win +``` + +You must first create a signing identity: +``` +-gpg init -e ed25519 "My Full Name " +``` +You will be asked for confirmation on your device **twice**. + +This will create a new profile in `%USERPROFILE%/.gnupg/`. You may now use GPG while specifying a home folder. For example: +``` +echo 123 | gpg --clearsign --homedir "%USERPROFILE%/.gnupg/" | gpg --verify --homedir "%USERPROFILE%/.gnupg/" +``` +The above example command will require a single confirmation on your device. + +If you wish to use GPG via other programs (e.g. Kleopatra), you will need to set the created folder as your default profile: +``` +setx /m GNUPGHOME "%USERPROFILE%/.gnupg/" +``` + +If you wish to use a different identity, you will need to delete the folder `%USERPROFILE%/.gnupg/`, and create a new identity as described above. + +### Using AGE + +[AGE File Encryption](https://age-encryption.org/) is a tool for encrypting and decrypting files. You will require a Windows version of the tool in order to use it. The recommended tool is [WinAge](https://github.com/spieglt/winage/releases). A WinGet package is not available for this tool. + +Before proceeding, you will need to create an identity: +``` +age-plugin- -i MyIdentityPath > age.identity +``` +Where `MyIdentityPath` is any name of your choice for this encryption identity. This text will appear on your device every time you encrypt or decrypt with this identity. + +The content of the file may look something like this: +``` +# recipient: agewnc7uu1btfhmr95dia9txto4ke1lm7azka3x1zkh17fk52guykrc2xk11 +# SLIP-0017: MyIdentityPath +AGE-PLUGIN-TREZOR-1F4U5JER9DE6XJARE2PSHG6Q4UFNE8 +``` + +Next, in Explorer, right click on the file you want to encrypt, and select `Encrypt with age`. Pick the `Recipient` mode. Copy the code appearing after `recipient:` in your `age.identity` file, e.g. `agewnc7uu1btfhmr95dia9txto4ke1lm7azka3x1zkh17fk52guykrc2xk11`, and paste it in the `Recipient, recipient file, or identity file` box. Finally, click on `Encrypt`, and pick the file location to save the encrypted file. Be sure to give it an `.age` suffix, so it can be easily decrypted. + +To decrypt a file, simply open (double click in Explorer) the `.age` file. A decryption dialog will pop up. In the `Select identity file` box, select your `age.identity` file. Click on `Decrypt`, and pick the file location to save the encrypted file. + +**Note:** At the moment, encrypting using the identity file is not supported. You must use the recipient id instead. + +### Using Signify + +[Signify](https://man.openbsd.org/OpenBSD-current/man1/signify.1) is a tool for signing messages and files, so that third parties may verify the validity of those files. + +To sign a file, use the following command: +``` +type myfile.txt | -signify sign MyIdentityPath -c "My comment" > myfile.sig +``` +You will be asked for confirmation on your device **twice**. + +To verify the signature, you will first need to export your public key associated with the identity: +``` +-signify pubkey MyIdentityPath > myfile.pub +``` +You will not be asked for confirmation, but your device must be unlocked. + +You will need a tool to verify the signature. The recommended tool is [Minisign](https://github.com/jedisct1/minisign/releases). + +You may download it directly, or using Chocolatey: +``` +choco install minisign +``` +Or [Scoop](https://github.com/ScoopInstaller/Scoop): +``` +scoop install minisign +``` +A WinGet package is not available. + +Verify the validity of the signature using the following command: +``` +minisign -V -x myfile.sig -p myfile.pub -m myfile.txt +``` +This can be done without access to the device, allowing third parties to verify the validity of your files. Only the public key file `myfile.pub`, needs to be securely transferred for the signature to be secure. + +An example output would be: +``` +C:\Users\MyUser>minisign -V -x myfile.sig -p myfile.pub -m myfile.txt +Signature and comment signature verified +Trusted comment: My comment +``` +An invalid output (If the file was corrupted or tampered with) would look like: +``` +C:\Users\MyUser>minisign -V -x myfile.sig -p myfile.pub -m myfile.txt +Signature verification failed +``` + +## Troubleshooting + +If you receive the following error while building: +``` +error: [WinError 32] The process cannot access the file because it is being used by another process: 'c:\\python311\\lib\\site-packages\\libagent-0.14.8-py3.11.egg' +``` +Manually delete the specified file, and try again. + +If you receive the following error while building: +``` +Error: Couldn't find a setup script in C:\Users\MyUser\AppData\Local\Temp\easy_install-2mn9q14a\semver-3.0.1.tar.gz +``` +Your Python version may be out of date. Follow the Python installation instructions above. Restart your administrative shell if the update is not being detected. + +If while running you receive the following error: +``` +ModuleNotFoundError: No module named 'pywintypes' +``` +Use the following commands using administrative shell: +``` +pip uninstall -y pywin32 +pip install pywin32 +``` + +If while running you receive the following error: +``` +Failed to enumerate WebUsbTransport. FileNotFoundError: Could not find module 'libusb-1.0.dll' (or one of its dependencies). Try using the full path with constructor syntax. +``` +Use the following commands using administrative shell: +``` +pip uninstall -y libusb1 +pip install libusb1 +``` + +If while running as a service you receive the following error: +``` +pywintypes.error: (5, 'CreateNamedPipe', 'Access is denied.') +``` +Ensure the OpenSSH Authentication Agent is not running: +``` +nssm stop ssh-agent +``` +Also look for any other SSH agents you may have installed on your system. + +### Signing Git commits with GPG + +If you receive the error: +``` +gpg: invalid size of lockfile 'C:\Users\MyUser/gnupg/trezor/pubring.kbx.lock' +``` +It means Git is trying to run the wrong version of GPG. First, Figure out where your GPG is: +``` +where gpg +``` +Example output: +``` +C:\Users\MyUser>where gpg +C:\Program Files (x86)\GnuPG\bin\gpg.exe +``` +Now set this value in your Git global config: +``` +git config --global gpg.program "C:\Program Files (x86)\GnuPG\bin\gpg.exe" +``` + +If you receive the error: +``` +signing failed: No secret key +``` +It means Git isn't selecting the correct key for signing. Normally, Git will look for a key with the same identity name as your committer name and email. However, you can explicitly select the secret key you want to use. First, you need to know your key id. There are three methods to do so. You can do this via command line: +``` +gpg --list-secret-keys --keyid-format=long +``` +Example output: +``` +C:\Users\MyUser>gpg --list-secret-keys --keyid-format=long +C:\Users\MyUser\.gnupg\trezor\pubring.kbx +---------------------------------------- +sec ed25519/100A53DB673C6714 1970-01-01 [SC] + 1E98503AC72ECBF78CDC3E415188B41C865FD25C +uid [ultimate] My Full Name +ssb cv25519/BD4CAB3E278E645F 1970-01-01 [E] +``` +The key id is the value in the `sec` line, coming after the `/`. So, in the above example, it is `100A53DB673C6714`. + +You can also obtain the keyid from `gpg.conf` as so: +``` +type "%USERPROFILE%\.gnupg\\gpg.conf" +``` +Example output: +``` +C:\Users\MyUser>type "%USERPROFILE%\.gnupg\\gpg.conf" +# Hardware-based GPG configuration +agent-program "C:\Users\MyUser/.gnupg/trezor\run-agent.bat" +personal-digest-preferences SHA512 +default-key 0x100A53DB673C6714 +``` +The key id appears after `default-key 0x`. + +The last method is to run Kleopatra, which comes bundled with Gpg4win. Upon opening it, it will show a list of identities associated with your default profile. The key id will simply appear in the rightmost column titled `Key-ID`. However, it will appear with extra spaces, e.g. `100A 53DB 673C 6714`. You can copy it simply by clicking on the text, holding Ctrl, and pressing C. + +Once you have the key id, use the following command to set the default key for Git: +``` +git config --global user.signingkey 100A53DB673C6714 +``` +Alternately, you can pick a specific secret key for a commit using the `-S` command line argument. +``` +git commit -S100A53DB673C6714 -m "My commit message" +``` + +You may also force signing on all commits by default: +``` +git config --global commit.gpgsign true +``` +If you prefer to only sign specific commits, you can turn it off: +``` +git config --global --unset commit.gpgsign +``` diff --git a/libagent/age/__init__.py b/libagent/age/__init__.py index 93a3cf8a..dd2fbe66 100644 --- a/libagent/age/__init__.py +++ b/libagent/age/__init__.py @@ -121,7 +121,7 @@ def run_decrypt(device_type, args): for file_index, stanzas in stanza_map.items(): _handle_single_file(file_index, stanzas, identities, c) - sys.stdout.write('-> done\n\n') + sys.stdout.buffer.write('-> done\n\n'.encode()) sys.stdout.flush() sys.stdout.close() @@ -132,7 +132,7 @@ def _handle_single_file(file_index, stanzas, identities, c): for identity in identities: id_str = identity.to_string() msg = base64_encode(f'Please confirm {id_str} decryption on {d} device...'.encode()) - sys.stdout.write(f'-> msg\n{msg}\n') + sys.stdout.buffer.write(f'-> msg\n{msg}\n'.encode()) sys.stdout.flush() key = c.ecdh(identity=identity, peer_pubkey=peer_pubkey) @@ -140,7 +140,7 @@ def _handle_single_file(file_index, stanzas, identities, c): if not result: continue - sys.stdout.write(f'-> file-key {file_index}\n{base64_encode(result)}\n') + sys.stdout.buffer.write(f'-> file-key {file_index}\n{base64_encode(result)}\n'.encode()) sys.stdout.flush() return diff --git a/libagent/device/ui.py b/libagent/device/ui.py index 00486262..2cf0f130 100644 --- a/libagent/device/ui.py +++ b/libagent/device/ui.py @@ -6,6 +6,7 @@ import sys from .. import util +from ..gpg import keyring try: from trezorlib.client import PASSPHRASE_ON_DEVICE @@ -21,7 +22,7 @@ class UI: def __init__(self, device_type, config=None): """C-tor.""" - default_pinentry = 'pinentry' # by default, use GnuPG pinentry tool + default_pinentry = keyring.get_pinentry_binary() # by default, use GnuPG pinentry tool if config is None: config = {} self.pin_entry_binary = config.get('pin_entry_binary', @@ -78,7 +79,8 @@ def button_request(self, _code=None): def create_default_options_getter(): """Return current TTY and DISPLAY settings for GnuPG pinentry.""" options = [] - if sys.stdin.isatty(): # short-circuit calling `tty` + # Windows reports that it has a TTY but throws FileNotFoundError + if sys.platform != 'win32' and sys.stdin.isatty(): # short-circuit calling `tty` try: ttyname = subprocess.check_output(args=['tty']).strip() options.append(b'ttyname=' + ttyname) @@ -88,7 +90,8 @@ def create_default_options_getter(): display = os.environ.get('DISPLAY') if display is not None: options.append('display={}'.format(display).encode('ascii')) - else: + # Windows likely doesn't support this anyway + elif sys.platform != 'win32': log.warning('DISPLAY not defined') log.info('using %s for pinentry options', options) diff --git a/libagent/gpg/__init__.py b/libagent/gpg/__init__.py index bf147431..6bad4f65 100644 --- a/libagent/gpg/__init__.py +++ b/libagent/gpg/__init__.py @@ -14,11 +14,15 @@ import logging import os import re +import stat import subprocess import sys -import time -import daemon +try: + # TODO: Not supported on Windows. Use daemoniker instead? + import daemon +except ImportError: + daemon = None import pkg_resources import semver @@ -39,6 +43,7 @@ def export_public_key(device_type, args): verifying_key = c.pubkey(identity=identity, ecdh=False) decryption_key = c.pubkey(identity=identity, ecdh=True) signer_func = functools.partial(c.sign, identity=identity) + fingerprints = [] if args.subkey: # add as subkey log.info('adding %s GPG subkey for "%s" to existing key', @@ -47,10 +52,12 @@ def export_public_key(device_type, args): signing_key = protocol.PublicKey( curve_name=args.ecdsa_curve, created=args.time, verifying_key=verifying_key, ecdh=False) + fingerprints.append(util.hexlify(signing_key.fingerprint())) # subkey for encryption encryption_key = protocol.PublicKey( curve_name=formats.get_ecdh_curve_name(args.ecdsa_curve), created=args.time, verifying_key=decryption_key, ecdh=True) + fingerprints.append(util.hexlify(encryption_key.fingerprint())) primary_bytes = keyring.export_public_key(args.user_id) result = encode.create_subkey(primary_bytes=primary_bytes, subkey=signing_key, @@ -65,10 +72,12 @@ def export_public_key(device_type, args): primary = protocol.PublicKey( curve_name=args.ecdsa_curve, created=args.time, verifying_key=verifying_key, ecdh=False) + fingerprints.append(util.hexlify(primary.fingerprint())) # subkey for encryption subkey = protocol.PublicKey( curve_name=formats.get_ecdh_curve_name(args.ecdsa_curve), created=args.time, verifying_key=decryption_key, ecdh=True) + fingerprints.append(util.hexlify(subkey.fingerprint())) result = encode.create_primary(user_id=args.user_id, pubkey=primary, @@ -77,7 +86,7 @@ def export_public_key(device_type, args): subkey=subkey, signer_func=signer_func) - return protocol.armor(result, 'PUBLIC KEY BLOCK') + return (fingerprints, protocol.armor(result, 'PUBLIC KEY BLOCK')) def verify_gpg_version(): @@ -98,10 +107,10 @@ def check_output(args): return out -def check_call(args, stdin=None, env=None): +def check_call(args, stdin=None, input_bytes=None, env=None): """Runs command and verifies its success.""" log.debug('run: %s%s', args, ' {}'.format(env) if env else '') - subprocess.check_call(args=args, stdin=stdin, env=env) + subprocess.run(args=args, stdin=stdin, input=input_bytes, env=env, check=True) def write_file(path, data): @@ -135,32 +144,44 @@ def run_init(device_type, args): 'remove it manually if required', homedir) sys.exit(1) - check_call(['mkdir', '-p', homedir]) - check_call(['chmod', '700', homedir]) + # Prepare the key before making any changes + fingerprints, public_key_bytes = export_public_key(device_type, args) + + os.makedirs(homedir, mode=0o700) agent_path = util.which('{}-gpg-agent'.format(device_name)) # Prepare GPG agent invocation script (to pass the PATH from environment). - with open(os.path.join(homedir, 'run-agent.sh'), 'w') as f: - f.write(r"""#!/bin/sh + with open(os.path.join(homedir, ('run-agent.sh' + if sys.platform != 'win32' else + 'run-agent.bat')), 'w') as f: + if sys.platform != 'win32': + f.write(r"""#!/bin/sh export PATH="{0}" -{1} \ --vv \ ---pin-entry-binary={pin_entry_binary} \ ---passphrase-entry-binary={passphrase_entry_binary} \ ---cache-expiry-seconds={cache_expiry_seconds} \ -$* -""".format(os.environ['PATH'], agent_path, **vars(args))) - check_call(['chmod', '700', f.name]) +""".format(util.escape_cmd_quotes(os.environ['PATH']))) + else: + f.write(r"""@echo off +set PATH={0} +""".format(util.escape_cmd_win(os.environ['PATH']))) + f.write('"{0}" -vv'.format(util.escape_cmd_quotes(agent_path))) + for arg in ['pin_entry_binary', 'passphrase_entry_binary', 'cache_expiry_seconds']: + if hasattr(args, arg): + f.write(' "--{0}={1}"'.format(arg.replace('_', '-'), + util.escape_cmd_quotes(getattr(args, arg)))) + if sys.platform != 'win32': + f.write(' $*\n') + else: + f.write(' %*\n') + os.chmod(f.name, 0o700) run_agent_script = f.name # Prepare GPG configuration file with open(os.path.join(homedir, 'gpg.conf'), 'w') as f: f.write("""# Hardware-based GPG configuration -agent-program {0} +agent-program "{0}" personal-digest-preferences SHA512 -default-key \"{1}\" -""".format(run_agent_script, args.user_id)) +default-key {1} +""".format(util.escape_cmd_quotes(run_agent_script), fingerprints[0])) # Prepare a helper script for setting up the new identity with open(os.path.join(homedir, 'env'), 'w') as f: @@ -175,24 +196,18 @@ def run_init(device_type, args): ${{COMMAND}} fi """.format(homedir)) - check_call(['chmod', '700', f.name]) + os.chmod(f.name, 0o700) # Generate new GPG identity and import into GPG keyring - pubkey = write_file(os.path.join(homedir, 'pubkey.asc'), - export_public_key(device_type, args)) verbosity = ('-' + ('v' * args.verbose)) if args.verbose else '--quiet' check_call(keyring.gpg_command(['--homedir', homedir, verbosity, - '--import', pubkey.name])) + '--import']), + input_bytes=public_key_bytes.encode()) # Make new GPG identity with "ultimate" trust (via its fingerprint) - out = check_output(keyring.gpg_command(['--homedir', homedir, - '--list-public-keys', - '--with-fingerprint', - '--with-colons'])) - fpr = re.findall('fpr:::::::::([0-9A-F]+):', out)[0] - f = write_file(os.path.join(homedir, 'ownertrust.txt'), fpr + ':6\n') check_call(keyring.gpg_command(['--homedir', homedir, - '--import-ownertrust', f.name])) + '--import-ownertrust']), + input_bytes=(fingerprints[0] + ':6\n').encode()) # Load agent and make sure it responds with the new identity check_call(keyring.gpg_command(['--homedir', homedir, @@ -226,8 +241,9 @@ def run_agent(device_type): p.add_argument('-v', '--verbose', default=0, action='count') p.add_argument('--server', default=False, action='store_true', help='Use stdin/stdout for communication with GPG.') - p.add_argument('--daemon', default=False, action='store_true', - help='Daemonize the agent.') + if daemon: + p.add_argument('--daemon', default=False, action='store_true', + help='Daemonize the agent.') p.add_argument('--pin-entry-binary', type=str, default='pinentry', help='Path to PIN entry UI helper.') @@ -238,7 +254,7 @@ def run_agent(device_type): args, _ = p.parse_known_args() - if args.daemon: + if daemon and args.daemon: with daemon.DaemonContext(): run_agent_internal(args, device_type) else: @@ -312,11 +328,11 @@ def main(device_type): p.add_argument('--homedir', type=str, default=os.environ.get('GNUPGHOME'), help='Customize GnuPG home directory for the new identity.') - p.add_argument('--pin-entry-binary', type=str, default='pinentry', + p.add_argument('--pin-entry-binary', type=str, default=argparse.SUPPRESS, help='Path to PIN entry UI helper.') - p.add_argument('--passphrase-entry-binary', type=str, default='pinentry', + p.add_argument('--passphrase-entry-binary', type=str, default=argparse.SUPPRESS, help='Path to passphrase entry UI helper.') - p.add_argument('--cache-expiry-seconds', type=float, default=float('inf'), + p.add_argument('--cache-expiry-seconds', type=float, default=argparse.SUPPRESS, help='Expire passphrase from cache after this duration.') p.set_defaults(func=run_init) diff --git a/libagent/gpg/keyring.py b/libagent/gpg/keyring.py index 2260b82d..46dd00a6 100644 --- a/libagent/gpg/keyring.py +++ b/libagent/gpg/keyring.py @@ -8,9 +8,14 @@ import re import socket import subprocess +import sys +import urllib.parse from .. import util +if sys.platform == 'win32': + from .. import win_server + log = logging.getLogger(__name__) @@ -27,12 +32,8 @@ def check_output(args, env=None, sp=subprocess): def get_agent_sock_path(env=None, sp=subprocess): """Parse gpgconf output to find out GPG agent UNIX socket path.""" - args = [util.which('gpgconf'), '--list-dirs'] - output = check_output(args=args, env=env, sp=sp) - lines = output.strip().split(b'\n') - dirs = dict(line.split(b':', 1) for line in lines) - log.debug('%s: %s', args, dirs) - return dirs[b'agent-socket'] + args = [util.which('gpgconf'), '--list-dirs', 'agent-socket'] + return check_output(args=args, env=env, sp=sp).strip() def connect_to_agent(env=None, sp=subprocess): @@ -40,8 +41,11 @@ def connect_to_agent(env=None, sp=subprocess): sock_path = get_agent_sock_path(sp=sp, env=env) # Make sure the original gpg-agent is running. check_output(args=['gpg-connect-agent', '/bye'], sp=sp) - sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) - sock.connect(sock_path) + if sys.platform == 'win32': + sock = win_server.Client(sock_path) + else: + sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + sock.connect(sock_path) return sock @@ -193,7 +197,8 @@ def get_gnupg_components(sp=subprocess): """Parse GnuPG components' paths.""" args = [util.which('gpgconf'), '--list-components'] output = check_output(args=args, sp=sp) - components = dict(re.findall('(.*):.*:(.*)', output.decode('utf-8'))) + components = {k: urllib.parse.unquote(v) for k, v in re.findall( + r'(?H', self.data()) - def _fingerprint(self): + def fingerprint(self): + """GPG key fingerprint as bytes.""" return hashlib.sha1(self.data_to_hash()).digest() def key_id(self): """Short (8 byte) GPG key ID.""" - return self._fingerprint()[-8:] + return self.fingerprint()[-8:] def __repr__(self): """Short (8 hexadecimal digits) GPG key ID.""" diff --git a/libagent/gpg/tests/test_keyring.py b/libagent/gpg/tests/test_keyring.py index 6faa446f..605ba0c9 100644 --- a/libagent/gpg/tests/test_keyring.py +++ b/libagent/gpg/tests/test_keyring.py @@ -1,4 +1,5 @@ import io +import subprocess import mock @@ -91,16 +92,11 @@ def test_iterlines(): def test_get_agent_sock_path(): - sp = mock_subprocess(b'''sysconfdir:/usr/local/etc/gnupg -bindir:/usr/local/bin -libexecdir:/usr/local/libexec -libdir:/usr/local/lib/gnupg -datadir:/usr/local/share/gnupg -localedir:/usr/local/share/locale -dirmngr-socket:/run/user/1000/gnupg/S.dirmngr -agent-ssh-socket:/run/user/1000/gnupg/S.gpg-agent.ssh -agent-socket:/run/user/1000/gnupg/S.gpg-agent -homedir:/home/roman/.gnupg -''') - expected = b'/run/user/1000/gnupg/S.gpg-agent' - assert keyring.get_agent_sock_path(sp=sp) == expected + expected_prefix = b'/run/user/' + expected_suffix = b'/gnupg/S.gpg-agent' + expected_infix = b'0123456789' + value = keyring.get_agent_sock_path(sp=subprocess) + assert value.startswith(expected_prefix) + assert value.endswith(expected_suffix) + value = value[len(expected_prefix):-len(expected_suffix)] + assert value.strip(expected_infix) == b'' diff --git a/libagent/server.py b/libagent/server.py index 8e16c8dd..43289ce7 100644 --- a/libagent/server.py +++ b/libagent/server.py @@ -4,10 +4,14 @@ import os import socket import subprocess +import sys import threading from . import util +if sys.platform == 'win32': + from . import win_server + log = logging.getLogger(__name__) @@ -28,6 +32,10 @@ def unix_domain_socket_server(sock_path): Listen on it, and delete it after the generated context is over. """ log.debug('serving on %s', sock_path) + if sys.platform == 'win32': + # Return a named pipe emulating a socket server interface + yield win_server.Server(sock_path) + return remove_file(sock_path) server = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) diff --git a/libagent/ssh/__init__.py b/libagent/ssh/__init__.py index 5d6734ea..dee3ee24 100644 --- a/libagent/ssh/__init__.py +++ b/libagent/ssh/__init__.py @@ -1,18 +1,26 @@ """SSH-agent implementation using hardware authentication devices.""" +import argparse import contextlib import functools import io import logging import os +import random import re import signal +import string import subprocess import sys import tempfile import threading import configargparse -import daemon + +try: + # TODO: Not supported on Windows. Use daemoniker instead? + import daemon +except ImportError: + daemon = None import pkg_resources from .. import device, formats, server, util @@ -21,6 +29,9 @@ log = logging.getLogger(__name__) UNIX_SOCKET_TIMEOUT = 0.1 +SOCK_TYPE = 'Windows named pipe' if sys.platform == 'win32' else 'UNIX domain socket' +SOCK_TYPE_PATH = 'Windows named pipe path' if sys.platform == 'win32' else 'UNIX socket path' +FILE_PREFIX = 'file:' def ssh_args(conn): @@ -90,27 +101,30 @@ def create_agent_parser(device_type): p.add_argument('--log-file', type=str, help='Path to the log file (to be written by the agent).') p.add_argument('--sock-path', type=str, - help='Path to the UNIX domain socket of the agent.') + help='Path to the ' + SOCK_TYPE + ' of the agent.') - p.add_argument('--pin-entry-binary', type=str, default='pinentry', + p.add_argument('--pin-entry-binary', type=str, default=argparse.SUPPRESS, help='Path to PIN entry UI helper.') - p.add_argument('--passphrase-entry-binary', type=str, default='pinentry', + p.add_argument('--passphrase-entry-binary', type=str, default=argparse.SUPPRESS, help='Path to passphrase entry UI helper.') - p.add_argument('--cache-expiry-seconds', type=float, default=float('inf'), + p.add_argument('--cache-expiry-seconds', type=float, default=argparse.SUPPRESS, help='Expire passphrase from cache after this duration.') g = p.add_mutually_exclusive_group() - g.add_argument('-d', '--daemonize', default=False, action='store_true', - help='Daemonize the agent and print its UNIX socket path') + if daemon: + g.add_argument('-d', '--daemonize', default=False, action='store_true', + help='Daemonize the agent and print its ' + SOCK_TYPE_PATH) g.add_argument('-f', '--foreground', default=False, action='store_true', - help='Run agent in foreground with specified UNIX socket path') + help='Run agent in foreground with specified ' + SOCK_TYPE_PATH) g.add_argument('-s', '--shell', default=False, action='store_true', help=('run ${SHELL} as subprocess under SSH agent, allowing ' 'regular SSH-based tools to be used in the shell')) g.add_argument('-c', '--connect', default=False, action='store_true', help='connect to specified host via SSH') - g.add_argument('--mosh', default=False, action='store_true', - help='connect to specified host via using Mosh') + # Windows doesn't have native mosh + if sys.platform != 'win32': + g.add_argument('--mosh', default=False, action='store_true', + help='connect to specified host via using Mosh') p.add_argument('identity', type=_to_unicode, default=None, help='proto://[user@]host[:port][/path]') @@ -159,7 +173,10 @@ def run_server(conn, command, sock_path, debug, timeout): if command: ret = server.run_process(command=command, environ=env) else: - signal.pause() # wait for signal (e.g. SIGINT) + try: + signal.pause() # wait for signal (e.g. SIGINT) + except AttributeError: + sys.stdin.read() # Windows doesn't support signal.pause except KeyboardInterrupt: log.info('server stopped') return ret @@ -192,6 +209,34 @@ def import_public_keys(contents): yield line +class ClosableNamedTemporaryFile(): + """Creates a temporary file that is not deleted when the file is closed. + + This allows the file to be opened with an exclusive lock, but used by other programs before + it is deleted + """ + + def __init__(self): + """Create a temporary file.""" + self.file = tempfile.NamedTemporaryFile(prefix='trezor-ssh-pubkey-', mode='w', delete=False) + self.name = self.file.name + + def write(self, buf): + """Write `buf` to the file.""" + self.file.write(buf) + + def close(self): + """Closes the file, allowing it to be opened by other programs. Does not delete the file.""" + self.file.close() + + def __del__(self): + """Deletes the temporary file.""" + try: + os.unlink(self.file.name) + except OSError: + log.warning("Failed to delete temporary file: %s", self.file.name) + + class JustInTimeConnection: """Connect to the device just before the needed operation.""" @@ -221,9 +266,9 @@ def public_keys_as_files(self): """Store public keys as temporary SSH identity files.""" if not self.public_keys_tempfiles: for pk in self.public_keys(): - f = tempfile.NamedTemporaryFile(prefix='trezor-ssh-pubkey-', mode='w') + f = ClosableNamedTemporaryFile() f.write(pk) - f.flush() + f.close() self.public_keys_tempfiles.append(f) return self.public_keys_tempfiles @@ -241,13 +286,15 @@ def _dummy_context(): def _get_sock_path(args): sock_path = args.sock_path - if not sock_path: - if args.foreground: - log.error('running in foreground mode requires specifying UNIX socket path') - sys.exit(1) - else: - sock_path = tempfile.mktemp(prefix='trezor-ssh-agent-') - return sock_path + if sock_path: + return sock_path + elif args.foreground: + log.error('running in foreground mode requires specifying %s', SOCK_TYPE_PATH) + sys.exit(1) + elif sys.platform == 'win32': + return '\\\\.\\pipe\\trezor-ssh-agent-' + os.urandom(10).hex() + else: + return tempfile.mktemp(prefix='trezor-ssh-agent-') @handle_connection_error @@ -258,8 +305,10 @@ def main(device_type): public_keys = None filename = None - if args.identity.startswith('/'): - filename = args.identity + if args.identity.startswith('/') or args.identity.startswith(FILE_PREFIX): + filename = (args.identity[len(FILE_PREFIX):] + if args.identity.startswith(FILE_PREFIX) + else args.identity) contents = open(filename, 'rb').read().decode('utf-8') # Allow loading previously exported SSH public keys if filename.endswith('.pub'): @@ -284,9 +333,9 @@ def main(device_type): context = _dummy_context() if args.connect: command = ['ssh'] + ssh_args(conn) + args.command - elif args.mosh: + elif sys.platform != 'win32' and args.mosh: command = ['mosh'] + mosh_args(conn) + args.command - elif args.daemonize: + elif daemon and args.daemonize: out = 'SSH_AUTH_SOCK={0}; export SSH_AUTH_SOCK;\n'.format(sock_path) sys.stdout.write(out) sys.stdout.flush() @@ -300,7 +349,7 @@ def main(device_type): command = os.environ['SHELL'] sys.stdin.close() - if command or args.daemonize or args.foreground: + if command or (daemon and args.daemonize) or args.foreground: with context: return run_server(conn=conn, command=command, sock_path=sock_path, debug=args.debug, timeout=args.timeout) diff --git a/libagent/ssh/protocol.py b/libagent/ssh/protocol.py index 7c3e6759..020c8f7b 100644 --- a/libagent/ssh/protocol.py +++ b/libagent/ssh/protocol.py @@ -53,7 +53,7 @@ def msg_code(name): def msg_name(code): """Convert integer message code into a string name.""" ids = {v: k for k, v in COMMANDS.items()} - return ids[code] + return ids[code] if code in ids else str(code) def failure(): diff --git a/libagent/util.py b/libagent/util.py index df8c38a2..96ccad2f 100644 --- a/libagent/util.py +++ b/libagent/util.py @@ -5,6 +5,7 @@ import io import logging import struct +import sys import time log = logging.getLogger(__name__) @@ -258,6 +259,30 @@ def assuan_serialize(data): return data +def escape_cmd_quotes(in_str): + """ + Escape a string for use as a command line argument inside quotes. + + Does not add quotes. This allows appending multiple strings inside the quotes. + """ + if sys.platform == 'win32': + return in_str.translate(str.maketrans({'%': '%%', '\"': '\"\"'})) + else: + return in_str.translate(str.maketrans({'\"': '\\\"', '\'': '\\\'', '\\': '\\\\'})) + + +def escape_cmd_win(in_str): + """Escape a string for Windows batch files in a context where quotes cannot be used.""" + return in_str.translate(str.maketrans({'\"': '^\"', + '%': '%%', + '&': '^&', + '\'': '^\'', + '<': '^<', + '>': '^>', + '^': '^^', + '|': '^|'})) + + class ExpiringCache: """Simple cache with a deadline.""" diff --git a/libagent/win_server.py b/libagent/win_server.py new file mode 100644 index 00000000..c0594029 --- /dev/null +++ b/libagent/win_server.py @@ -0,0 +1,422 @@ +"""Windows named pipe server simulating a UNIX socket.""" +import contextlib +import ctypes +import io +import os +import socket + +import win32api +import win32event +import win32file +import win32pipe +import winerror + +from . import util + +kernel32 = ctypes.WinDLL('kernel32', use_last_error=True) + +PIPE_BUFFER_SIZE = 64 * 1024 +CTRL_C_EVENT = 0 +THREAD_SET_CONTEXT = 0x0010 + + +# Workaround for Ctrl+C not stopping IO on Windows +# See https://github.com/python/cpython/issues/85609 +@contextlib.contextmanager +def ctrl_cancel_async_io(file_handle): + """Listen for SIGINT and translate it to interrupting IO on the specified file handle.""" + @ctypes.WINFUNCTYPE(ctypes.c_uint, ctypes.c_uint) + def ctrl_handler(ctrl_event): + if ctrl_event == CTRL_C_EVENT: + kernel32.CancelIoEx(file_handle, None) + return False + + try: + kernel32.SetConsoleCtrlHandler(ctrl_handler, True) + yield + finally: + kernel32.SetConsoleCtrlHandler(ctrl_handler, False) + + +# Based loosely on https://docs.microsoft.com/en-us/windows/win32/ipc/multithreaded-pipe-server +class NamedPipe: + """A Windows named pipe. + + Can act both as a listener waiting for and processing connections, + or as a client connecting to a listener. + """ + + @staticmethod + def __close(handle, disconnect): + """Closes a named pipe handle.""" + if handle == win32file.INVALID_HANDLE_VALUE: + return + win32file.FlushFileBuffers(handle) + if disconnect: + win32pipe.DisconnectNamedPipe(handle) + win32api.CloseHandle(handle) + + @staticmethod + def create(name): + """Opens a named pipe server for receiving connections.""" + handle = win32pipe.CreateNamedPipe( + name, + win32pipe.PIPE_ACCESS_DUPLEX | win32file.FILE_FLAG_OVERLAPPED, + win32pipe.PIPE_TYPE_MESSAGE | win32pipe.PIPE_READMODE_MESSAGE | win32pipe.PIPE_WAIT, + win32pipe.PIPE_UNLIMITED_INSTANCES, + PIPE_BUFFER_SIZE, + PIPE_BUFFER_SIZE, + 0, + None) + + if handle == win32file.INVALID_HANDLE_VALUE: + raise IOError('CreateNamedPipe failed ({0})'.format(win32api.GetLastError())) + + try: + pending_io = False + overlapped = win32file.OVERLAPPED() + overlapped.hEvent = win32event.CreateEvent(None, True, True, None) + error_code = win32pipe.ConnectNamedPipe(handle, overlapped) + if error_code == winerror.ERROR_IO_PENDING: + pending_io = True + else: + win32event.SetEvent(overlapped.hEvent) + if error_code != winerror.ERROR_PIPE_CONNECTED: + raise IOError('ConnectNamedPipe failed ({0})'.format(error_code)) + ret = NamedPipe(name, handle, overlapped, pending_io, True) + handle = win32file.INVALID_HANDLE_VALUE + return ret + finally: + NamedPipe.__close(handle, True) + + @staticmethod + def open(name): + """Opens a named pipe server for receiving connections.""" + handle = win32file.CreateFile( + name, + win32file.GENERIC_READ | win32file.GENERIC_WRITE, + 0, + None, + win32file.OPEN_EXISTING, + win32file.FILE_FLAG_OVERLAPPED, + None) + + if handle == win32file.INVALID_HANDLE_VALUE: + raise IOError('CreateFile failed ({0})'.format(win32api.GetLastError())) + + try: + overlapped = win32file.OVERLAPPED() + overlapped.hEvent = win32event.CreateEvent(None, True, True, None) + win32pipe.SetNamedPipeHandleState(handle, win32pipe.PIPE_READMODE_BYTE, None, None) + ret = NamedPipe(name, handle, overlapped, False, False) + handle = win32file.INVALID_HANDLE_VALUE + return ret + finally: + NamedPipe.__close(handle, False) + + def __init__(self, name, handle, overlapped, pending_io, created): + """Should not be called directly. + + Use ``NamedPipe.create`` or ``NamedPipe.open`` instead. + """ + # pylint: disable=too-many-arguments + self.name = name + self.handle = handle + self.overlapped = overlapped + self.pending_io = pending_io + self.created = created + self.retain_buf = bytes() + self.timeout = win32event.INFINITE + + def __del__(self): + """Close the named pipe.""" + self.close() + + def settimeout(self, timeout): + """Sets the timeout for IO operations on the named pipe in milliseconds.""" + self.timeout = win32event.INFINITE if timeout is None else int(timeout * 1000) + + def close(self): + """Close the named pipe.""" + NamedPipe.__close(self.handle, self.created) + self.handle = win32file.INVALID_HANDLE_VALUE + + def connect(self): + """Connect to a named pipe with the specified timeout.""" + with ctrl_cancel_async_io(self.handle): + waitHandle = win32event.WaitForSingleObject(self.overlapped.hEvent, self.timeout) + if waitHandle == win32event.WAIT_TIMEOUT: + raise TimeoutError('Timed out waiting for client on pipe {0}'.format(self.name)) + if not self.pending_io: + return + win32pipe.GetOverlappedResult( + self.handle, + self.overlapped, + False) + error_code = win32api.GetLastError() + if error_code == winerror.NO_ERROR: + return + raise IOError('Connection to named pipe {0} failed ({1})'.format(self.name, error_code)) + + def recv(self, size): + """Read data from the pipe.""" + rbuf = win32file.AllocateReadBuffer(min(size, PIPE_BUFFER_SIZE)) + try: + error_code, _ = win32file.ReadFile(self.handle, rbuf, self.overlapped) + if error_code not in (winerror.NO_ERROR, + winerror.ERROR_IO_PENDING, + winerror.ERROR_MORE_DATA): + raise IOError('ReadFile failed ({0})'.format(error_code)) + except win32api.error as e: + if e.winerror == winerror.ERROR_NO_DATA: + return None + raise + with ctrl_cancel_async_io(self.handle): + win32event.WaitForSingleObject(self.overlapped.hEvent, self.timeout) + try: + chunk_size = win32pipe.GetOverlappedResult(self.handle, self.overlapped, False) + error_code = win32api.GetLastError() + if error_code != winerror.NO_ERROR: + raise IOError('ReadFile failed ({0})'.format(error_code)) + return rbuf[:chunk_size] if chunk_size > 0 else None + except win32api.error as e: + if e.winerror == winerror.ERROR_BROKEN_PIPE: + return None + raise + + def send(self, data): + """Write from the specified buffer to the pipe.""" + error_code, _ = win32file.WriteFile(self.handle, data, self.overlapped) + if error_code not in (winerror.NO_ERROR, + winerror.ERROR_IO_PENDING, + winerror.ERROR_MORE_DATA): + raise IOError('WriteFile failed ({0})'.format(error_code)) + with ctrl_cancel_async_io(self.handle): + win32event.WaitForSingleObject(self.overlapped.hEvent, self.timeout) + written = win32pipe.GetOverlappedResult(self.handle, self.overlapped, False) + error_code = win32api.GetLastError() + if error_code != winerror.NO_ERROR: + raise IOError('WriteFile failed ({0})'.format(error_code)) + return written + + def sendall(self, data): + """Send the specified reply to the pipe.""" + while len(data) > 0: + written = self.send(data) + data = data[written:] + + +class InterruptibleSocket: + """A wrapper for sockets which allows IO operations to be interrupted by SIGINT.""" + + def __init__(self, sock): + """Wraps the socket object ``sock``.""" + self.sock = sock + + def __del__(self): + """Close the wrapped socket. It should not outlive the wrapper.""" + self.close() + + def settimeout(self, timeout): + """Forward to underlying socket.""" + self.sock.settimeout(timeout) + + def recv(self, size): + """Forward to underlying socket, while monitoring for SIGINT.""" + try: + with ctrl_cancel_async_io(self.sock.fileno()): + return self.sock.recv(size) + except OSError as e: + if e.winerror == 10054: + # Convert socket close to end of file + return None + raise + + def sendall(self, reply): + """Forward to underlying socket, while monitoring for SIGINT.""" + with ctrl_cancel_async_io(self.sock.fileno()): + return self.sock.sendall(reply) + + def close(self): + """Forward to underlying socket.""" + return self.sock.close() + + def getsockname(self): + """Forward to underlying socket.""" + return self.sock.getsockname() + + +class Server: + """Listend on an emulated AF_UNIX socket on Windows. + + Supports both Gpg4win-style AF_UNIX emulation and OpenSSH-style AF_UNIX emulation + """ + + def __init__(self, pipe_name): + """Opens a socket or named pipe. + + If ``pipe_name`` is a byte string, it is interpreted as a Gpg4win-style socket. + The string contains the name of a file which must contain information needed to connect to + a TCP socket listening on localhost emulating an AF_UNIX socket. + Both the file and listening socket are created. + + If it is a string, it is interpreted as an OpenSSH-style socket. + The string contains the name of a Windows named pipe. + """ + self.timeout = None + self.pipe_name = pipe_name + self.sock = None + self.pipe = None + if not isinstance(self.pipe_name, str): + # GPG simulated socket via localhost socket + self.key = os.urandom(16) + self.sock = socket.socket() + self.sock.bind(('127.0.0.1', 0)) + _, port = self.sock.getsockname() + self.sock.listen(1) + # Write key to file + with open(self.pipe_name, 'wb') as f: + with ctrl_cancel_async_io(f.fileno()): + f.write(str(port).encode()) + f.write(b'\n') + f.write(self.key) + + def __del__(self): + """Close the underlying socket or pipe.""" + if self.pipe is not None: + self.pipe.close() + self.pipe = None + if self.sock is not None: + self.sock.close() + self.sock = None + + def settimeout(self, timeout): + """Set the timeout in seconds.""" + if self.sock: + self.sock.settimeout(timeout) + self.timeout = timeout + + def getsockname(self): + """Return the file path or pipe name used for creating this named pipe.""" + return self.pipe_name + + def accept(self): + """Listens for incoming connections on the socket. + + Returns a pair ``(pipe, address)`` where ``pipe`` is a connected socket-like object + representing a client, and ``address`` is some string representing the client's address. + + When a named pipe is used, the client's address is the same as the pipe name. + """ + if self.sock: + with ctrl_cancel_async_io(self.sock.fileno()): + sock, addr = self.sock.accept() + sock = InterruptibleSocket(sock) + sock.settimeout(self.timeout) + if self.key != util.recv(sock, 16): + sock.close() + # Simulate timeout on failed connection to allow the caller to retry + raise TimeoutError('Illegitimate client tried to connect to pipe {0}' + .format(self.pipe_name)) + sock.settimeout(None) + return (sock, addr) + else: + # Named pipe based server + if self.pipe is None: + self.pipe = NamedPipe.create(self.pipe_name) + self.pipe.settimeout(self.timeout) + self.pipe.connect() + self.pipe.settimeout(None) + # A named pipe can only accept a single connection + # It must be recreated if a new connection is to be made + pipe = self.pipe + self.pipe = None + return (pipe, self.pipe_name) + + +class Client: + """Connects to an emulated AF_UNIX socket on Windows. + + Supports both Gpg4win-style AF_UNIX emulation and OpenSSH-style AF_UNIX emulation + """ + + def __init__(self, pipe_name): + """Connects to a socket or named pipe. + + If ``pipe_name`` is a byte string, it is interpreted as a Gpg4win-style socket. + The string contains the name of a file which contains information needed to connect to + a TCP socket listening on localhost emulating an AF_UNIX socket. + + If it is a string, it is interpreted as an OpenSSH-style socket. + The string contains the name of a Windows named pipe. + """ + self.pipe_name = pipe_name + self.sock = None + self.pipe = None + if not isinstance(self.pipe_name, str): + # Read key from file + with open(self.pipe_name, 'rb') as f: + with ctrl_cancel_async_io(f.fileno()): + port = io.BytesIO() + while True: + c = f.read(1) + if not c: + raise OSError('Could not read port for socket {0}'.format(pipe_name)) + if c == b'\n': + break + if c < b'0' or c > b'9': + raise OSError('Could not read port for socket {0}'.format(pipe_name)) + port.write(c) + port = int(port.getvalue()) + key_len = 0 + key = io.BytesIO() + while key: + c = f.read(16-key_len) + if not c: + raise OSError('Could not read nonce for socket {0}'.format(pipe_name)) + key.write(c) + key_len += len(c) + key = key.getvalue() + # Verify end of file + c = f.read(1) + if c: + raise OSError('Corrupt socket {0}'.format(pipe_name)) + # GPG simulated socket via localhost socket + sock = socket.socket() + sock.connect(('127.0.0.1', port)) + self.sock = InterruptibleSocket(sock) + self.sock.sendall(key) + else: + self.pipe = NamedPipe.open(pipe_name) + + def __del__(self): + """Close the underlying socket or named pipe.""" + if self.pipe is not None: + self.pipe.close() + self.pipe = None + if self.sock is not None: + self.sock.close() + self.sock = None + + def settimeout(self, timeout): + """Forward to underlying socket or named pipe.""" + if self.sock: + self.sock.settimeout(timeout) + if self.pipe: + self.pipe.settimeout(timeout) + + def getsockname(self): + """Return the file path or pipe name used for connecting to this named pipe.""" + return self.pipe_name + + def recv(self, size): + """Forward to underlying socket or named pipe.""" + if self.sock is not None: + return self.sock.recv(size) + return self.pipe.recv(size) + + def sendall(self, reply): + """Forward to underlying socket or named pipe.""" + if self.sock is not None: + return self.sock.sendall(reply) + return self.pipe.sendall(reply) diff --git a/setup.py b/setup.py index 34425c83..aefdcdc1 100755 --- a/setup.py +++ b/setup.py @@ -31,8 +31,9 @@ 'pymsgbox>=1.0.6', 'semver>=2.2', 'unidecode>=0.4.20', + 'pywin32>=300;sys_platform=="win32"' ], - platforms=['POSIX'], + platforms=['POSIX', 'win32'], classifiers=[ 'Environment :: Console', 'Development Status :: 5 - Production/Stable', @@ -41,6 +42,7 @@ 'Intended Audience :: System Administrators', 'License :: OSI Approved :: GNU Lesser General Public License v3 (LGPLv3)', 'Operating System :: POSIX', + 'Operating System :: Microsoft :: Windows', 'Programming Language :: Python :: 3 :: Only', 'Topic :: Software Development :: Libraries :: Python Modules', 'Topic :: System :: Networking', diff --git a/tox.ini b/tox.ini index f55016ba..ca6f3c2d 100644 --- a/tox.ini +++ b/tox.ini @@ -5,6 +5,9 @@ max-line-length = 100 [pep257] add-ignore = D401 [testenv] +platform = + lin: linux + win: win32 deps= pytest mock @@ -14,10 +17,12 @@ deps= semver pydocstyle isort + pywin32;sys_platform=="win32" commands= pycodestyle libagent isort --skip-glob .tox -c libagent - pylint --reports=no --rcfile .pylintrc libagent + win: pylint --reports=no --rcfile .pylintrc libagent --extension-pkg-allow-list=win32api,win32event,win32file,win32pipe,winerror --generated-members=socket.AF_UNIX + lin: pylint --reports=no --rcfile .pylintrc libagent --ignore-paths libagent/win_server.py pydocstyle libagent coverage run --source libagent -m pytest -v libagent coverage report