[
  {
    "path": ".editorconfig",
    "content": "# http://editorconfig.org/\nroot = true\n\n[*]\ncharset = utf-8\nend_of_line = lf\nindent_style = space\nindent_size = 4\ninsert_final_newline = true\ntrim_trailing_whitespace = true\n\n[*.schema]\nindent_style = tab\n\n[bin/ssh-ldap-pubkey-wrapper]\nindent_style = tab\n\n[*.yml]\nindent_size = 2\n"
  },
  {
    "path": ".github/FUNDING.yml",
    "content": "# These are supported funding model platforms\n\ngithub: jirutka\n"
  },
  {
    "path": ".github/workflows/ci.yml",
    "content": "name: CI\non:\n  - push\n  - pull_request\n\njobs:\n  test:\n    name: Test on Python ${{ matrix.python }}\n    runs-on: ubuntu-latest\n    strategy:\n      fail-fast: false\n      matrix:\n        python:\n          - '3.6'\n          - '3.7'\n          - '3.8'\n          - '3.9'\n          - '3.10'\n    steps:\n      - uses: actions/checkout@v3\n\n      - name: Install Python ${{ matrix.python }}\n        uses: actions/setup-python@v3\n        with:\n          python-version: ${{ matrix.python }}\n\n      - name: Install system dependencies\n        run: |\n          sudo apt update -qq\n          sudo apt install -q libldap-dev libsasl2-dev\n\n      - name: Install project requirements\n        run: |\n          pip install -U -r requirements.txt\n          python3 setup.py install\n\n      - name: Run tests\n        run: py.test --cov=ssh_ldap_pubkey --cov-report term -vv\n\n      - name: Run linter\n        run: pycodestyle\n\n  publish:\n    name: Publish to PyPI\n    if: startsWith(github.ref, 'refs/tags/v') && github.event_name == 'push'\n    needs: [test]\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/checkout@v3\n\n      - uses: actions/setup-python@v3\n        with:\n          python-version: '3.10'\n\n      - name: Build source tarball\n        run: python3 setup.py sdist\n\n      - name: Publish package to PyPI\n        uses: pypa/gh-action-pypi-publish@release/v1\n        with:\n          user: __token__\n          password: ${{ secrets.PYPI_API_TOKEN }}\n"
  },
  {
    "path": ".gitignore",
    "content": "# Byte-compiled / optimized files\n__pycache__/\n*.py[cod]\n\n# Distribution / packaging\nenv/\nbuild/\ndist/\n/MANIFEST\n*.egg-info/\n*.egg\n\n# pytest\n.cache/\n\n# Coverage report\n.coverage\n"
  },
  {
    "path": "CHANGELOG.adoc",
    "content": "= Changelog\n:repo-uri: https://github.com/jirutka/ssh-ldap-pubkey\n:issues: {repo-uri}/issues\n:pulls: {repo-uri}/pull\n:tags: {repo-uri}/releases/tag\n\n\n== unreleased\n\n\n== link:{tags}/v1.4.0[1.4.0] (2022-05-19)\n\n* Replace deprecated pyldap with python-ldap.\n* Drop support for Python 2.\n* Replace `base64.decodestring()`, which has been removed in Python 3.9, with `base64.decodebytes()`. ({issues}/49[#49])\n\n\n== link:{tags}/v1.3.3[1.3.3] (2020-05-15)\n\n* Fix wrapper script to be compatible with Busybox `logger(1)`. ({issues}/43[#43])\n* Fix `tls_reqcert` value of never to be accepted when defined. (PR {pulls}/47[#47])\n\n\n== link:{tags}/v1.3.2[1.3.2] (2019-08-21)\n\n* Fix broken keys listing due to over-wrapping of search filter. ({issues}/34[#34])\n\n\n== link:{tags}/v1.3.1[1.3.1] (2019-04-27)\n\n* Fix handling of complex LDAP filters. (PR {pulls}/33[#33])\n* Retire Python 3.3 support (due to pyldap). (PR {pulls}/33[#33])\n\n\n== link:{tags}/v1.3.0[1.3.0] (2018-03-02)\n\n* Add support for SASL GSSAPI (Kerberos) authentication. (PR {pulls}/27[#27])\n* Allow to disable LDAP referrals using option `referrals`.\n\n\n== link:{tags}/v1.2.0[1.2.0] (2017-02-24)\n\n* Make pubkey class and attribute configurable. (PR {pulls}/21[#21])\n\n\n== link:{tags}/v1.1.1[1.1.1] (2017-01-04)\n\n* Fix parsing of `uri` from config file. ({issues}/20[#20])\n\n\n== link:{tags}/v1.1.0[1.1.0] (2016-12-28)\n\n* Add support for multiple LDAP servers in ldap.conf.\n* Allow to pass multiple `--uri` options.\n\n\n== link:{tags}/v1.0.0[1.0.0] (2016-10-01)\n\n* Refactor code-base, split it into a module and CLI script.\n* Add support for `TLS_REQCERT` option. (PR {pulls}/11[#12])\n* Add support for StartTLS. (PR {pulls}/14[#14])\n* Replace python-ldap with pyldap.\n* Make it compatible with Python 3. ({issues}/15[#15])\n* Change sshPublicKey in ldapPublicKey objectclass to be optional.\n\n\n== link:{tags}/v0.4.1[0.4.1] (2015-10-08)\n\n* Catch `ldap.INSUFFICIENT_ACCESS` exception when adding/removing key to/from LDAP and print error message. (PR {pulls}/9[#9])\n\n\n== link:{tags}/v0.4.0[0.4.0] (2015-02-07)\n\n* Add option `-D` to specify the bind DN. ({issues}/7[#7])\n\n\n== link:{tags}/v0.3.3[0.3.3] (2015-02-07)\n\n* Fix keys count in the wrapper script to return 0 instead of 1 when no key is found. (PR {pulls}/8[#8])\n\n\n== link:{tags}/v0.3.2[0.3.2] (2014-12-14)\n\n* Remove unnecessary absolute path in wrapper script. ({issues}/6[#6])\n\n\n== link:{tags}/v0.3.1[0.3.1] (2014-09-16)\n\n* Log all info and warn messages to stderr instead of stdout.\n\n\n== link:{tags}/v0.3.0[0.3.0] (2014-09-16)\n\n* Print warnings to stderr, not stdout.\n* Change script option `-h` to `-H` to avoid confusion with help.\n\n\n== link:{tags}/v0.2.3[0.2.3] (2014-09-02)\n\n* Display username in the password prompt. (PR {pulls}/3[#3])\n* Fix SSH key validation to accept keys without a comment part.\n* Treat keys in config file as case-insensitive (always convert them to lowercase).\n\n\n== link:{tags}/v0.2.2[0.2.2] (2014-04-23)\n\n* Add basic validation of SSH public key format.\n* Check if public key doesn’t already exist in the user’s entry before adding it.\n\n\n== link:{tags}/v0.2.1[0.2.1] (2014-04-22)\n\n* Handle tls_cacertdir configuration directive.\n\n\n== link:{tags}/v0.2[0.2] (2014-04-21)\n\n* First public release.\n"
  },
  {
    "path": "LICENSE",
    "content": "The MIT License\n\nCopyright 2014-present Jakub Jirutka <jakub@jirutka.cz>.\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in\nall copies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN\nTHE SOFTWARE.\n"
  },
  {
    "path": "MANIFEST.in",
    "content": "include README.md\ninclude CHANGELOG.adoc\ninclude LICENSE\ninclude MANIFEST.in\ninclude etc/ldap.conf\ninclude etc/openssh-lpk.schema\n"
  },
  {
    "path": "README.md",
    "content": "OpenSSH / LDAP public keys\n==========================\n[![Build Status](https://github.com/jirutka/ssh-ldap-pubkey/workflows/CI/badge.svg)](https://github.com/jirutka/ssh-ldap-pubkey/actions?query=workflow%3A%22CI%22)\n[![Code Climate](https://codeclimate.com/github/jirutka/ssh-ldap-pubkey/badges/gpa.svg)](https://codeclimate.com/github/jirutka/ssh-ldap-pubkey)\n[![version](https://img.shields.io/pypi/v/ssh-ldap-pubkey.svg?style=flat)](https://pypi.python.org/pypi/ssh-ldap-pubkey)\n\nThis project provides an utility to manage SSH public keys stored in LDAP and also a script for\nOpenSSH server to load authorized keys from LDAP.\n\n\nWhy?\n----\n\nWhen you have dozen of servers it becomes difficult to manage your authorized keys. You have to\ncopy all your public keys to `~/.ssh/authorized_keys` on every server you want to login to. And\nwhat if you someday change your keys?\n\nIt’s a good practice to use some kind of a centralized user management, usually an LDAP server.\nThere you have user’s login, uid, e-mail, … and password. What if we could also store public SSH\nkeys on LDAP server? With this utility it’s easy as pie.\n\n\nAlternatives\n------------\n\nIf you need just a lightweight utility for OpenSSH server to load authorized keys from LDAP,\nthen you can use [ssh-getkey-ldap](https://github.com/jirutka/ssh-getkey-ldap) written in Lua\nor [this one](https://gist.github.com/jirutka/b15c31b2739a4f3eab63) written in POSIX shell\n(but it requires `ldapsearch` utility and may not work well on some systems).\n\n\nRequirements\n------------\n\n* Python 3.6+\n* [python-ldap] 3.x\n* [docopt] 0.6.x\n\nYou can install both Python modules from PyPI.\npython-ldap requires additional system dependencies – OpenLDAP.\nRefer to [Stack Overflow](http://stackoverflow.com/q/4768446/240963) for distribution-specific information.\n\n\nInstallation\n------------\n\n### PyPI:\n\n    pip install ssh-ldap-pubkey\n\n### Alpine Linux\n\n    apk add ssh-ldap-pubkey\n\nNote: The package is currently in the (official) _community_ repository; make sure that you have community in `/etc/apk/repositories`.\n\n\nUsage\n-----\n\nList SSH public keys stored in LDAP for the current user:\n\n    ssh-ldap-pubkey list\n\nList SSH public keys stored in LDAP for the specified user:\n\n    ssh-ldap-pubkey list -u flynn\n\nAdd the specified SSH public key for the current user to LDAP:\n\n    ssh-ldap-pubkey add ~/.ssh/id_rsa.pub\n\nRemove SSH public key(s) of the current user that matches the specified pattern:\n\n    ssh-ldap-pubkey del flynn@grid\n\nSpecify LDAP URI and base DN on command line instead of configuration file:\n\n    ssh-ldap-pubkey list -b ou=People,dc=encom,dc=com -H ldaps://encom.com -u flynn\n\nAs the LDAP manager, add SSH public key to LDAP for the specified user:\n\n    ssh-ldap-pubkey add -D cn=Manager,dc=encom,dc=com -u flynn ~/.ssh/id_rsa.pub\n\nShow help for other options:\n\n    ssh-ldap-pubkey --help\n\n\nConfiguration\n-------------\n\nConfiguration is read from /etc/ldap.conf — file used by LDAP nameservice switch library and the\nLDAP PAM module. An example file is included in [etc/ldap.conf][ldap.conf]. The following subset of\nparameters are used:\n\n*  **uri** ... URI(s) of the LDAP server(s) to connect to, separated by a space. The URI scheme may\n               be ldap, or ldaps. Default is `ldap://localhost`.\n*  **nss_base_passwd** ... distinguished name (DN) of the search base.\n*  **base** ... distinguished name (DN) of the search base. Used when *nss_base_passwd* is not set.\n*  **scope** ... search scope; _sub_, _one_, or _base_ (default is _sub_).\n*  **referrals** ... should client automatically follow referrals returned by LDAP servers (default is _on_)?\n*  **pam_filter** ... filter to use when searching for the user’s entry, additional to the login\n        attribute value assertion (`pam_login_attribute=<login>`). Default is\n        _objectclass=posixAccount_.\n*  **pam_login_attribute** ... the user ID attribute (default is _uid_).\n*  **ldap_version** ... LDAP version to use (default is 3).\n*  **sasl** ... enable SASL and specify mechanism to use (currently only GSSAPI is supported).\n*  **binddn** ... distinguished name (DN) to bind when reading the user’s entry (default is to bind\n                  anonymously).\n*  **bindpw** ... credentials to bind with when reading the user’s entry (default is none).\n*  **ssl** ... LDAP SSL/TLS method; _off_, _on_, or _start_tls_. If you use LDAP over SSL (i.e. URI `ldaps://`), leave this empty.\n*  **timelimit** ... search time limit in seconds (default is 10).\n*  **bind_timelimit** ... bind/connect time limit in seconds (default is 10). If multiple URIs are\n                          specified in _uri_, then the next one is tried after this timeout.\n*  **tls_cacertdir** ... path of the directory with CA certificates for LDAP server certificate\n                         verification.\n*  **pubkey_class** ... objectClass that should be added/removed to/from the user’s entry when adding/removing first/last public key and the *pubkey_attr* is mandatory for this class.\n   This is needed for the original openssh-lpk.schema (not for the one in this repository).\n   Default is `ldapPublicKey`.\n*  **pubkey_attr** ... name of LDAP attribute used for SSH public keys (default is `sshPublicKey`).\n\nThe only required parameter is *nss_base_passwd* or _base_, others have sensitive defaults. You\nmight want to define _uri_ parameter as well. These parameters can be also defined/overriden\nwith `--bind` and `--uri` options on command line.\n\nFor more information about these parameters refer to ldap.conf man page.\n\n\nSet up OpenSSH server\n--------------------\n\nTo configure OpenSSH server to fetch users’ authorized keys from LDAP server:\n\n1.  Make sure that you have installed **ssh-ldap-pubkey** and **ssh-ldap-pubkey-wrapper** in\n    `/usr/bin` with owner `root` and mode `0755`.\n2.  Add these two lines to /etc/ssh/sshd_config:\n\n        AuthorizedKeysCommand /usr/bin/ssh-ldap-pubkey-wrapper\n        AuthorizedKeysCommandUser nobody\n\n3.  Restart sshd and check log file if there’s no problem.\n\nNote: This method is supported by OpenSSH since version 6.2-p1 (or 5.3 onRedHat). If you have an\nolder version and can’t upgrade, for whatever weird reason, use [openssh-lpk] patch instead.\n\n\nSet up LDAP server\n------------------\n\nJust add the [openssh-lpk.schema] to your LDAP server, **or** add an attribute named `sshPublicKey`\nto any existing schema which is already defined in people entries. That’s all.\n\nNote: Presumably, you’ve already set up your LDAP server for centralized unix users management,\ni.e. you have the [NIS schema](http://www.zytrax.com/books/ldap/ape/nis.html) and users in LDAP.\n\n\nLicense\n-------\n\nThis project is licensed under [MIT license](http://opensource.org/licenses/MIT).\n\n\n[python-ldap]: https://pypi.python.org/pypi/python-ldap/\n[docopt]: https://pypi.python.org/pypi/docopt/\n[ebuild]: https://github.com/cvut/gentoo-overlay/tree/master/sys-auth/ssh-ldap-pubkey\n[cvut-overlay]: https://github.com/cvut/gentoo-overlay\n[openssh-lpk]: http://code.google.com/p/openssh-lpk/\n\n[ldap.conf]: https://github.com/jirutka/ssh-ldap-pubkey/blob/master/etc/ldap.conf\n[openssh-lpk.schema]: https://github.com/jirutka/ssh-ldap-pubkey/blob/master/etc/openssh-lpk.schema\n"
  },
  {
    "path": "bin/ssh-ldap-pubkey",
    "content": "#!/usr/bin/env python\n# -*- coding: utf-8 -*-\n\n\"\"\"\nssh-ldap-pubkey - Utility to manage SSH public keys stored in LDAP.\n\nUsage:\n  ssh-ldap-pubkey list [-H URI...] [options]\n  ssh-ldap-pubkey add [-H URI...] [options] FILE\n  ssh-ldap-pubkey del [-H URI...] [options] PATTERN\n  ssh-ldap-pubkey --help\n\n  -                      Read public key from stdin.\n  FILE                   Path to the public key file to add.\n  PATTERN                Pattern that specifies public key(s) to delete, i.e.\n                         a complete key or just a part of it.\n\nOptions:\n  -b DN --base=DN        Base DN where to search for the users' entry. If not\n                         provided, then it's read from the config file.\n  -c FILE --conf=FILE    Path of the ldap.conf (default is /etc/ldap.conf).\n                         The ldap.conf is not required when at least --base is\n                         provided.\n  -D DN --binddn=DN      DN to bind with instead of the user's DN.\n  -H URI... --uri=URI... URI of the LDAP server to connect; loaded from the\n                         config file by default. If not defined even there,\n                         then it defaults to ldap://localhost.\n  -q --quiet             Be quiet.\n  -u LOGIN --user=LOGIN  Login of the user to bind as and change public key(s)\n                         (default is the current user).\n  -v --version           Show version information.\n  -h --help              Show this message.\n\"\"\"\n\nfrom __future__ import print_function\n\nimport sys\nfrom docopt import docopt\nfrom getpass import getpass, getuser\nfrom os import access, R_OK\nfrom ssh_ldap_pubkey import LdapSSH, Error, keyname\nfrom ssh_ldap_pubkey.config import LdapConfig\nfrom ssh_ldap_pubkey import __versionstr__\n\n\nDEFAULT_CONFIG_PATH = '/etc/ldap.conf'\n\nquiet = False\n\n\ndef read_file(path):\n    \"\"\"Read file and return its content with stripped newlines.\n\n    This is foolproof against some users that are unable to copy keys properly.\n    \"\"\"\n    with open(path, 'r') as f:\n        return ''.join(f.readlines()).strip()\n\n\ndef read_stdin():\n    \"\"\"Read from standard input and strip newlines.\n\n    This is foolproof against some users that are unable to copy keys properly.\n    \"\"\"\n    return ''.join(sys.stdin.readlines()).strip()\n\n\ndef halt(msg, code=1):\n    \"\"\"Print error message to stderr and exit with the specified code.\"\"\"\n    print('Error: ' + msg, file=sys.stderr)\n    exit(code)\n\n\ndef info(msg, *args):\n    \"\"\"Print message to stderr unless we're quiet.\"\"\"\n    if not quiet:\n        print(msg % args, file=sys.stderr)\n\n\ndef main(**kwargs):\n    global quiet\n\n    quiet = kwargs['--quiet']\n    confpath = kwargs['--conf'] or DEFAULT_CONFIG_PATH\n    login = kwargs['--user'] or getuser()\n    passw = None\n\n    if not access(confpath, R_OK):\n        info(\"Notice: Could not read config: %s; running with defaults.\", confpath)\n        confpath = None\n\n    conf = LdapConfig(confpath)\n    if kwargs['--uri']:\n        conf.uris = kwargs['--uri']\n    if kwargs['--base']:\n        conf.base = kwargs['--base']\n\n    # prompt for password\n    if kwargs['--binddn']:\n        conf.bind_dn = kwargs['--binddn']\n        conf.bind_pass = getpass(\"Enter LDAP password for '%s': \" % conf.bind_dn)\n    elif kwargs['add'] or kwargs['del']:\n        passw = getpass(\"Enter login (LDAP) password for user '%s': \" % login)\n\n    ldapssh = LdapSSH(conf)\n\n    try:\n        ldapssh.connect()\n\n        if kwargs['add']:\n            filesrc = kwargs['FILE'] and kwargs['FILE'] != '-'\n            pubkey = read_file(kwargs['FILE']) if filesrc else read_stdin()\n\n            ldapssh.add_pubkey(login, passw, pubkey)\n            info(\"Key has been stored: %s\", keyname(pubkey))\n\n        elif kwargs['del']:\n            keys = ldapssh.find_and_remove_pubkeys(login, passw, kwargs['PATTERN'])\n            if keys:\n                info('Deleted keys:')\n                print('\\n'.join(keys))\n            else:\n                info('No keys found to delete.')\n\n        else:  # list\n            keys = ldapssh.find_pubkeys(login)\n            if keys:\n                print('\\n'.join(keys))\n            else:\n                info('No public keys found.')\n\n    except Error as e:\n        halt(str(e), e.code)\n\n    except IOError as e:\n        halt(\"%s: %s\" % (e.strerror, e.filename), 1)\n\n    finally:\n        ldapssh.close()\n\n\nif __name__ == '__main__':\n    kwargs = docopt(__doc__, version=\"ssh-ldap-pubkey %s\" % __versionstr__)\n    main(**kwargs)\n"
  },
  {
    "path": "bin/ssh-ldap-pubkey-wrapper",
    "content": "#!/bin/sh\n#\n# Wrapper script for ssh-ldap-pubkey to be used as AuthorizedKeysCommand\n# in OpenSSHd.\nset -eu\n\nSSH_USER=\"$1\"\n\nKEYS=\"$(ssh-ldap-pubkey list -q -u \"$SSH_USER\")\"\nKEYS_COUNT=\"$(printf '%s\\n' \"$KEYS\" | grep '^ssh' | wc -l)\"\n\nlogger -t sshd -p info \\\n\t\"Loaded ${KEYS_COUNT} SSH public key(s) from LDAP for user: ${SSH_USER}\"\n\nprintf '%s\\n' \"$KEYS\"\n"
  },
  {
    "path": "etc/ldap.conf",
    "content": "# /etc/ldap.conf\n#\n# This is the configuration file for OpenSSH LDAP Public Keys (ssh-ldap-pubkey).\n#\n# This file actually uses a subset of directives from configuration file of the\n# LDAP nameservice switch library and the LDAP PAM module, so the same file can\n# be used for all these services.\n#\n\n# Specifies the URI(s) of the LDAP server(s) to connect to. The URI scheme may\n# be ldap, or ldaps, specifying LDAP over TCP or SSL respectively. A port\n# number can be specified; the default port number for the selected protocol\n# is used if omitted.\nuri ldap://localhost\n\n# The distinguished name of the search base.\nbase dc=example,dc=org\n\n# The LDAP version to use. Default is 3 if supported by client library.\n#ldap_version 3\n\n# Enable SASL and specify mechanism to use (currently supported: GSSAPI).\n#sasl GSSAPI\n\n# The distinguished name to bind to the server with.\n# Default is to bind anonymously.\n#binddn cn=proxyuser,dc=example,dc=org\n\n# The credentials to bind with. Default is no credential.\n#bindpw secret\n\n# The search scope; sub, one, or base.\nscope one\n\n# Specifies if the client should automatically follow referrals returned\n# by LDAP servers. This must be typically disabled for Active Directory.\n# Default is \"on\".\nreferrals off\n\n# Search timelimit in seconds (0 for indefinite).\ntimelimit 5\n\n# Bind/connect timelimit (0 for indefinite).\nbind_timelimit 5\n\n# The filter to use when retrieving user information, additional to the login\n# attribute value assertion (pam_login_attribute=<login>).\n#pam_filter objectclass=posixAccount\n\n# The user ID attribute (defaults to 'uid').\n#pam_login_attribute uid\n\n# RFC2307bis naming contexts\n# Syntax is:\n#   nss_base_XXX  base?scope?filter\n# where scope is {base,one,sub} and filter is a filter to be &'d with the\n# default filter.\nnss_base_passwd\t      ou=People,dc=example,dc=org?one\n\n# CA certificates for server certificate verification.\ntls_cacertdir /etc/ssl/certs\n\n# Name of LDAP attribute used for SSH public keys.\npubkey_attr sshPublicKey\n"
  },
  {
    "path": "etc/openssh-lpk.schema",
    "content": "#\n# LDAP Public Key Patch schema for use with openssh-ldappubkey\n# Author: Eric AUGE <eau@phear.org>\n#\n# Based on the proposal of : Mark Ruijter\n#\n\n\n# octetString SYNTAX\nattributetype ( 1.3.6.1.4.1.24552.500.1.1.1.13 NAME 'sshPublicKey'\n\tDESC 'OpenSSH Public key'\n\tEQUALITY octetStringMatch\n\tSYNTAX 1.3.6.1.4.1.1466.115.121.1.40 )\n\n# printableString SYNTAX yes|no\nobjectclass ( 1.3.6.1.4.1.24552.500.1.1.2.0 NAME 'ldapPublicKey' SUP top AUXILIARY\n\tDESC 'OpenSSH LPK objectclass'\n\tMUST uid\n\tMAY sshPublicKey\n\t)\n"
  },
  {
    "path": "requirements.txt",
    "content": "pycodestyle\npytest\npytest-cov\npytest-describe\npytest-mock\n"
  },
  {
    "path": "setup.cfg",
    "content": "[pycodestyle]\nmax-line-length = 100\n# E241 and E302 should be excluded only in tests, but I don't know how to configure it.\nignore = E701,E731,E241,E302\nexclude = .git/,build/,dist/,docs/,setup.py\n\n[tools:pytest]\ndescribe_prefixes = describe_ context_\n"
  },
  {
    "path": "setup.py",
    "content": "#!/usr/bin/env python3\nimport sys\nfrom setuptools import setup\n\nsetup(\n    name='ssh-ldap-pubkey',\n    version='1.4.0',\n    url='https://github.com/jirutka/ssh-ldap-pubkey',\n    description='Utility to manage SSH public keys stored in LDAP.',\n    long_description=open('README.md', 'r').read(),\n    long_description_content_type='text/markdown',\n    author='Jakub Jirutka',\n    author_email='jakub@jirutka.cz',\n    license='MIT',\n    packages=['ssh_ldap_pubkey'],\n    scripts=['bin/ssh-ldap-pubkey', 'bin/ssh-ldap-pubkey-wrapper'],\n    install_requires=[\n        'docopt>=0.6.2,<0.7.0',\n        'python-ldap>=3.0.0,<4'\n    ],\n    classifiers=[\n        'Environment :: Console',\n        'Intended Audience :: Developers',\n        'License :: OSI Approved :: MIT License',\n        'Operating System :: POSIX',\n        'Programming Language :: Python :: 3',\n        'Topic :: System',\n        'Topic :: Utilities'\n    ]\n)\n"
  },
  {
    "path": "ssh_ldap_pubkey/__init__.py",
    "content": "# -*- coding: utf-8 -*-\nimport base64\nimport ldap\nimport struct\nimport sys\n\nfrom .exceptions import *\n\n\nVERSION = (1, 4, 0)\n__version__ = VERSION\n__versionstr__ = '.'.join(map(str, VERSION))\n\nBAD_REQCERT_WARNING = u'''\n!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!\n!! WARNING: You've choosen to ignore TLS errors such as invalid certificate. !!\n!! This is a VERY BAD thing, never ever use this in production! ᕦ(ò_óˇ)ᕤ     !!\n!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!\n'''\n\n\ndef keyname(pubkey):\n    return pubkey.split()[-1]\n\n\ndef is_valid_openssh_pubkey(pubkey):\n    \"\"\"Check if the given string is a valid OpenSSH public key.\n\n    This function is based on http://stackoverflow.com/a/2494645/2217862.\n\n    Arguments:\n        pubkey (str): The string to validate.\n    Returns:\n        bool: `True` if the given string is a valid key, `False` otherwise.\n    \"\"\"\n    try:\n        key_type, data64 = map(_encode, pubkey.split()[0:2])\n    except (ValueError, AttributeError):\n        return False\n    try:\n        data = base64.decodebytes(data64)\n    except base64.binascii.Error:\n        return False\n\n    int_len = 4\n    str_len = struct.unpack('>I', data[:int_len])[0]\n\n    if data[int_len:(int_len + str_len)] != key_type:\n        return False\n\n    return True\n\n\ndef _decode(input):\n    return input.decode('utf8')\n\n\ndef _encode(input):\n    return input.encode('utf8')\n\n\nclass LdapSSH(object):\n\n    def __init__(self, conf):\n        \"\"\"Initialize new LdapSSH instance.\n\n        Arguments:\n            conf (LdapConfig): The LDAP configuration.\n        \"\"\"\n        self.conf = conf\n        self._conn = None\n\n    def connect(self):\n        \"\"\"Connect to the LDAP server.\n        This method must be called before any other methods of this object.\n\n        Raises:\n            ConfigError: If Base DN or LDAP URI is missing in the config.\n            LDAPConnectionError: If can't connect to the LDAP server.\n            ldap.LDAPError:\n        \"\"\"\n        conf = self.conf\n\n        if not conf.uris or not conf.base:\n            raise ConfigError('Base DN and LDAP URI(s) must be provided.', 1)\n\n        if conf.tls_require_cert is not None:\n            if conf.tls_require_cert not in [ldap.OPT_X_TLS_DEMAND, ldap.OPT_X_TLS_HARD]:\n                print(BAD_REQCERT_WARNING, file=sys.stderr)\n            # this is a global option!\n            ldap.set_option(ldap.OPT_X_TLS_REQUIRE_CERT, conf.tls_require_cert)\n\n        if conf.cacert_dir:\n            # this is a global option!\n            ldap.set_option(ldap.OPT_X_TLS_CACERTDIR, conf.cacert_dir)\n\n        if not conf.referrals:\n            # this is a global option!\n            ldap.set_option(ldap.OPT_REFERRALS, 0)\n\n        # NOTE: The uri argument is passed directly to the underlying openldap\n        # library that allows multiple URIs separated by a space for failover.\n        self._conn = conn = ldap.initialize(' '.join(conf.uris))\n        try:\n            conn.protocol_version = conf.ldap_version\n            conn.network_timeout = conf.bind_timeout\n            conn.timeout = conf.search_timeout\n\n            if conf.sasl == 'GSSAPI':\n                self._bind_sasl_gssapi()\n                return\n\n            if conf.ssl == 'start_tls' and conf.ldap_version >= 3:\n                conn.start_tls_s()\n\n            if conf.bind_dn and conf.bind_pass:\n                self._bind(conf.bind_dn, conf.bind_pass)\n        except ldap.SERVER_DOWN:\n            raise LDAPConnectionError('Can\\'t contact LDAP server.', 3)\n\n    def close(self):\n        \"\"\"Unbind from the LDAP server.\"\"\"\n        self._conn and self._conn.unbind_s()\n\n    def add_pubkey(self, login, password, pubkey):\n        \"\"\"Add SSH public key to the user with the given ``login``.\n\n        Arguments:\n            login (str): Login of the user to add the ``pubkey``.\n            password (Optional[str]): The user's password to bind with, or None\n                to not (re)bind with the user's credentials.\n            pubkey (str): The public key to add.\n        Raises:\n            InvalidPubKeyError: If the ``pubkey`` is invalid.\n            PubKeyAlreadyExistsError: If the user already has the given ``pubkey``.\n            UserEntryNotFoundError: If the ``login`` is not found.\n            ConfigError: If LDAP server doesn't define schema for the attribute specified\n                in the config.\n            InsufficientAccessError: If the bind user doesn't have rights to add the pubkey.\n            ldap.LDAPError:\n        \"\"\"\n        conf = self.conf\n\n        if not is_valid_openssh_pubkey(pubkey):\n            raise InvalidPubKeyError('Invalid key, not in OpenSSH Public Key format.', 1)\n\n        dn = self.find_dn_by_login(login)\n        if password:\n            self._bind(dn, password)\n\n        if self._has_pubkey(dn, pubkey):\n            raise PubKeyAlreadyExistsError(\n                \"Public key %s already exists.\" % keyname(pubkey), 1)\n\n        modlist = [(ldap.MOD_ADD, conf.pubkey_attr, _encode(pubkey))]\n        try:\n            self._conn.modify_s(dn, modlist)\n\n        except ldap.OBJECT_CLASS_VIOLATION:\n            modlist += [(ldap.MOD_ADD, 'objectClass', _encode(conf.pubkey_class))]\n            self._conn.modify_s(dn, modlist)\n\n        except ldap.UNDEFINED_TYPE:\n            raise ConfigError(\n                \"LDAP server doesn't define schema for attribute: %s\" % conf.pubkey_attr, 1)\n\n        except ldap.INSUFFICIENT_ACCESS:\n            raise InsufficientAccessError(\"No rights to add key for %s \" % dn, 2)\n\n    def find_and_remove_pubkeys(self, login, password, pattern):\n        \"\"\"Find and remove public keys of the user with the ``login`` that maches the ``pattern``.\n\n        Arguments:\n            login (str): Login of the user to add the ``pubkey``.\n            password (Optional[str]): The user's password to bind with, or None\n                to not (re)bind with the user's credentials.\n            pattern (str): The pattern specifying public keys to be removed.\n        Raises:\n            UserEntryNotFoundError: If the ``login`` is not found.\n            NoPubKeyFoundError: If no public key matching the ``pattern`` is found.\n            InsufficientAccessError: If the bind user doesn't have rights to add the pubkey.\n            ldap.LDAPError:\n        Returns:\n            List[str]: A list of removed public keys.\n        \"\"\"\n        dn = self.find_dn_by_login(login)\n        if password:\n            self._bind(dn, password)\n\n        pubkeys = [key for key in self._find_pubkeys(dn) if pattern in key]\n        for key in pubkeys:\n            self._remove_pubkey(dn, key)\n\n        return pubkeys\n\n    def find_pubkeys(self, login):\n        \"\"\"Return public keys of the user with the given ``login``.\n\n        Arguments:\n            login (str): The login name of the user.\n        Returns:\n            List[str]: A list of public keys.\n        Raises:\n            UserEntryNotFoundError: If the ``login`` is not found.\n            ldap.LDAPError:\n        \"\"\"\n        return self._find_pubkeys(self.find_dn_by_login(login))\n\n    def find_dn_by_login(self, login):\n        \"\"\"Returns Distinguished Name (DN) of the user with the given ``login``.\n\n        Arguments:\n            login (str): The login name of the user to find.\n        Returns:\n            str: User's DN.\n        Raises:\n            UserEntryNotFoundError: If the ``login`` is not found.\n            ldap.LDAPError:\n        \"\"\"\n        conf = self.conf\n        filter_s = conf.filter\n        # RFC4515 requires filters to be wrapped with parenthesis '(' and ')'.\n        # Over-wrapped filters are invalid! e.g. '((uid=x))'\n        #\n        # OpenLDAP permits simple filters to omit parenthesis entirely:\n        # e.g. 'uid=x' is automatically treated as '(uid=x)'\n        #\n        # The OpenLDAP behavior is taken as a given in many uses, which can\n        # lead to bad assumptions merging filters, because over-wrapped filters\n        # ARE still rejected.\n        #\n        # To cope with these cases, only wrap the incoming filter in\n        # parenthesis if it does NOT already have them.\n        if filter_s[0] != '(':\n            filter_s = '(%s)' % filter_s\n        filter_s = \"(&%s(%s=%s))\" % (filter_s, conf.login_attr, login)\n\n        result = self._conn.search_s(conf.base, conf.scope, filter_s, ['dn'])\n        if not result:\n            raise UserEntryNotFoundError(\"No user with login '%s' found.\" % login, 2)\n\n        return result[0][0]\n\n    def _bind(self, dn, password):\n        try:\n            self._conn.simple_bind_s(dn, password)\n        except ldap.INVALID_CREDENTIALS:\n            raise InvalidCredentialsError(\"Invalid credentials for %s.\" % dn, 2)\n\n    def _bind_sasl_gssapi(self):\n        self._conn.sasl_interactive_bind_s('', ldap.sasl.sasl({}, 'GSSAPI'))\n\n    def _find_pubkeys(self, dn):\n        conf = self.conf\n        result = self._conn.search_s(\n            dn, ldap.SCOPE_BASE, attrlist=[conf.pubkey_attr])\n\n        return map(_decode, result[0][1].get(conf.pubkey_attr, []))\n\n    def _has_pubkey(self, dn, pubkey):\n        current = self._find_pubkeys(dn)\n        is_same_key = lambda k1, k2: k1.split()[1] == k2.split()[1]\n\n        return any(key for key in current if is_same_key(key, pubkey))\n\n    def _remove_pubkey(self, dn, pubkey):\n        conf = self.conf\n\n        modlist = [(ldap.MOD_DELETE, conf.pubkey_attr, _encode(pubkey))]\n        try:\n            self._conn.modify_s(dn, modlist)\n\n        except ldap.OBJECT_CLASS_VIOLATION:\n            modlist += [(ldap.MOD_DELETE, 'objectClass', _encode(conf.pubkey_class))]\n            self._conn.modify_s(dn, modlist)\n\n        except ldap.NO_SUCH_ATTRIBUTE:\n            raise NoPubKeyFoundError(\"No such public key exists: %s.\" % keyname(pubkey), 1)\n\n        except ldap.INSUFFICIENT_ACCESS:\n            raise InsufficientAccessError(\"No rights to remove key for %s \" % dn, 2)\n"
  },
  {
    "path": "ssh_ldap_pubkey/config.py",
    "content": "# -*- coding: utf-8 -*-\nimport ldap\nimport re\n\nDEFAULT_FILTER = 'objectclass=posixAccount'\nDEFAULT_HOST = 'localhost'\nDEFAULT_LOGIN_ATTR = 'uid'\nDEFAULT_PORT = 389\nDEFAULT_PUBKEY_ATTR = 'sshPublicKey'\nDEFAULT_PUBKEY_CLASS = 'ldapPublicKey'\nDEFAULT_REFERRALS = 'on'\nDEFAULT_SCOPE = 'sub'\nDEFAULT_TIMEOUT = 10\n\n\ndef parse_config(content):\n    \"\"\"Parse configuration options into a dict.\n\n    Blank lines are ignored. Lines beginning with a hash mark (`#`) are comments, and ignored.\n    Valid lines are made of an option's name (a sequence of non-blanks), followed by a value.\n    The value starts with the first non-blank character after the option's name, and terminates at\n    the end of the line, or at the last sequence of blanks before the end of the line.\n    Option names are case-insensitive, and converted to lower-case.\n\n    Arguments:\n        content (str): The content of a configuration file to parse.\n    Returns:\n        dict: Parsed options.\n    \"\"\"\n    return {\n        match.group(1).lower(): match.group(2).strip()\n        for match in (\n            re.match(r'^(\\w+)\\s+([^#]+)', line)\n            for line in content.splitlines()\n        ) if match\n    }\n\n\ndef parse_config_file(path):\n    \"\"\"Same as :func:`parse_config`, but read options from a file.\n\n    Arguments:\n        path (str): Path of the file to read and parse.\n    Returns:\n        dict: Parsed options.\n    \"\"\"\n    with open(path, 'r') as f:\n        return parse_config(f.read())\n\n\ndef parse_bool(value):\n    \"\"\"Parse string that represents a boolean value.\n\n    Arguments:\n        value (str): The value to parse.\n    Returns:\n        bool: True if the value is \"on\", \"true\", or \"yes\", False otherwise.\n    \"\"\"\n    return (value or '').lower() in ('on', 'true', 'yes')\n\n\ndef parse_tls_reqcert_opt(value):\n    \"\"\"Convert `tls_reqcert` option to ldap's `OPT_X_TLS_*` constant.\"\"\"\n    return {\n        'never': ldap.OPT_X_TLS_NEVER,\n        'allow': ldap.OPT_X_TLS_ALLOW,\n        'try': ldap.OPT_X_TLS_TRY,\n        'demand': ldap.OPT_X_TLS_DEMAND,\n        'hard': ldap.OPT_X_TLS_HARD\n    }[value.lower()] if value else None\n\n\ndef parse_scope_opt(value):\n    \"\"\"Convert `scope` option to ldap's `SCOPE_*` constant.\"\"\"\n    return {\n        'base': ldap.SCOPE_BASE,\n        'one': ldap.SCOPE_ONELEVEL,\n        'sub': ldap.SCOPE_SUBTREE\n    }[value.lower()] if value else None\n\n\nclass LdapConfig(object):\n\n    def __init__(self, path):\n        \"\"\"Initialize new LdapConfig with options parsed from config file on the ``path``.\n\n        Arguments:\n            path (Optional[path]): Path to the config file to read and parse.\n                If not provided, then empty config is initialized.\n        \"\"\"\n        conf = parse_config_file(path) if path else {}\n\n        if 'uri' in conf:\n            self.uris = conf['uri'].split()\n        else:\n            host = conf.get('host', DEFAULT_HOST)\n            port = conf.get('port', DEFAULT_PORT)\n            self.uris = [\"ldap://%s:%s\" % (host, port)]\n\n        self.base = conf.get('nss_base_passwd', '').split('?')[0] or conf.get('base', None)\n        self.bind_dn = conf.get('binddn', None)\n        self.bind_pass = conf.get('bindpw', None)\n        self.bind_timeout = int(conf.get('bind_timelimit', DEFAULT_TIMEOUT))\n        self.cacert_dir = conf.get('tls_cacertdir', None)\n        self.filter = conf.get('pam_filter', DEFAULT_FILTER)\n        self.ldap_version = int(conf.get('ldap_version', ldap.VERSION3))\n        self.login_attr = conf.get('pam_login_attribute', DEFAULT_LOGIN_ATTR)\n        self.pubkey_attr = conf.get('pubkey_attr', DEFAULT_PUBKEY_ATTR)\n        self.pubkey_class = conf.get('pubkey_class', DEFAULT_PUBKEY_CLASS)\n        self.referrals = parse_bool(conf.get('referrals', DEFAULT_REFERRALS))\n        self.sasl = conf.get('sasl', None)\n        self.scope = parse_scope_opt(conf.get('scope', DEFAULT_SCOPE))\n        self.search_timeout = int(conf.get('timelimit', DEFAULT_TIMEOUT))\n        self.ssl = conf.get('ssl', None)\n        self.tls_require_cert = parse_tls_reqcert_opt(conf.get('tls_reqcert'))\n\n    @property\n    def uri(self):  # for backward compatibility with <1.1.0\n        return self.uris[0] if self.uris else None\n\n    @uri.setter\n    def uri(self, uri):  # for backward compatibility with <1.1.0\n        self.uris = [uri] if uri else None\n\n    def __str__(self):\n        return str(self.__dict__)\n"
  },
  {
    "path": "ssh_ldap_pubkey/exceptions.py",
    "content": "# -*- coding: utf-8 -*-\n\nclass Error(Exception):\n\n    def __init__(self, msg, code=1):\n        self.msg = msg\n        self.code = code\n\n    def __str__(self):\n        return self.msg\n\n\nclass ConfigError(Error): pass\nclass InsufficientAccessError(Error): pass\nclass InvalidCredentialsError(Error): pass\nclass InvalidPubKeyError(Error): pass\nclass LDAPConnectionError(Error): pass\nclass NoPubKeyFoundError(Error): pass\nclass PubKeyAlreadyExistsError(Error): pass\nclass UserEntryNotFoundError(Error): pass\n"
  },
  {
    "path": "tests/__init__.py",
    "content": ""
  },
  {
    "path": "tests/config_test.py",
    "content": "# -*- coding: utf-8 -*-\nfrom inspect import cleandoc\nfrom io import StringIO\nfrom pytest import mark, raises\nfrom ssh_ldap_pubkey.config import *\nfrom textwrap import dedent\n\n\ndef describe_parse_config():\n\n    def parses_key_value_separated_by_whitespace():\n        content = dedent('''\\\n            uri  ldap://localhost\n            base dc=example, dc=org\n            ldap_version\\t\\t3\n        ''')\n        expected = {\n            'uri': 'ldap://localhost',\n            'base': 'dc=example, dc=org',\n            'ldap_version': '3'\n        }\n        assert parse_config(content) == expected\n\n    def strips_trailing_whitespaces():\n        content = dedent('''\\\n            scope base\\t\n            timelimit 3\\t\\t\n        ''')\n        expected = {\n            'scope': 'base',\n            'timelimit': '3'\n        }\n        assert parse_config(content) == expected\n\n    def ignores_comments():\n        content = dedent('''\\\n            # The search scope; sub, one, or base.\n            scope one\n            #timelimit 5\n        ''')\n        expected = {\n            'scope': 'one'\n        }\n        assert parse_config(content) == expected\n\n    def converts_keys_to_lowercase():\n        content = dedent('''\\\n            ScoPe base\n            BASE DC=Example,DC=org\n        ''')\n        expected = {\n            'scope': 'base',\n            'base': 'DC=Example,DC=org'\n        }\n        assert parse_config(content) == expected\n\n\ndef describe_parse_config_file():\n\n    def reads_file_and_calls_parse_config(mocker):\n        # This removes the leading whitespace in this file for the test.\n        content = cleandoc(u'''\n        scope one\n        timelimit 3\n        pam_filter (objectclass=posixAccount)\n        nss_reconnect_tries 2 # number of times to double the sleep time\n        trailing_space_after_variable foobar\n        ''')  # noqa\n        open_mock = mocker.patch('builtins.open', return_value=StringIO(initial_value=content))\n\n        result = {\n            'scope': 'one',\n            'timelimit': '3',\n            'pam_filter': '(objectclass=posixAccount)',\n            'nss_reconnect_tries': '2',\n            'trailing_space_after_variable': 'foobar',\n        }\n        parse_config_mock = mocker.patch('ssh_ldap_pubkey.config.parse_config', return_value=result)\n\n        assert parse_config_file('/etc/ldap.conf') == result\n        open_mock.assert_called_with('/etc/ldap.conf', 'r')\n        parse_config_mock.assert_called_with(content)\n\n\ndef describe_parse_bool():\n\n    @mark.parametrize('value', ['on', 'true', 'yes', 'ON', 'TrUe'])\n    def returns_True_when_given_truthy_value(value):\n        assert parse_bool(value)\n\n    @mark.parametrize('value', ['off', 'false', 'no', 'NO', 'fooo'])\n    def returns_False_when_given_non_truthy_value(value):\n        assert not parse_bool(value)\n\n    def returns_False_when_given_None():\n        assert not parse_bool(None)\n\n\ndef describe_parse_tls_reqcert_opt():\n\n    @mark.parametrize('value, expected', [\n        ('never',  ldap.OPT_X_TLS_NEVER),\n        ('allow',  ldap.OPT_X_TLS_ALLOW),\n        ('try',    ldap.OPT_X_TLS_TRY),\n        ('demand', ldap.OPT_X_TLS_DEMAND),\n        ('hard',   ldap.OPT_X_TLS_HARD),\n    ])\n    def returns_ldap_OPT_X_TLS_constant_for_valid_value(value, expected):\n        assert parse_tls_reqcert_opt(value) == expected\n        assert parse_tls_reqcert_opt(value.upper()) == expected\n\n    @mark.parametrize('value', [None, ''])\n    def returns_None_when_given_falsy(value):\n        assert parse_tls_reqcert_opt(value) is None\n\n    def raises_KeyError_for_invalud_value():\n        with raises(KeyError):\n            parse_tls_reqcert_opt('whatever')\n\n\ndef describe_parse_scope_opt():\n\n    @mark.parametrize('value, expected', [\n        ('base', ldap.SCOPE_BASE),\n        ('one',  ldap.SCOPE_ONELEVEL),\n        ('sub',  ldap.SCOPE_SUBTREE)\n    ])\n    def returns_ldap_SCOPE_constant_for_valid_value(value, expected):\n        assert parse_scope_opt(value) == expected\n        assert parse_scope_opt(value.upper()) == expected\n\n    @mark.parametrize('value', [None, ''])\n    def returns_None_when_given_falsy(value):\n        assert parse_scope_opt(value) is None\n\n    def raises_KeyError_for_invalud_value():\n        with raises(KeyError):\n            parse_scope_opt('whatever')\n"
  },
  {
    "path": "tests/fixtures/invalid_ssh_keys",
    "content": "ssh-rsa\n\nAAAAB3NzaC1yc2EAAAADAQABAAAAYQCkNCKyyUPvwwefhEpnD/Khuowbv+13Cv3shWY3ck3j+hbHLqjCGUB66/igg0Atf3giU7sZOdPrSN6xdX43+k6Gn73yzWFJkWyImKzgHW9uVrU7vo3puailY+yC6RpwNME= flynn@encom.com\nssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAAAYQCkNCKyyUPvwwefhEpnD/Khuowbv+13Cv3shWY3ck3j+hbHLqjCGUB66/igg0Atf3giU7sZOdPrSN6xdX43+k6Gn73yzWFJkWyImKzgHW9uVrU7vo3puailY+yC6RpwNM\nssh-dss AAAAB3NzaC1yc2EAAAADAQABAAAAYQCkNCKyyUPvwwefhEpnD/Khuowbv+13Cv3shWY3ck3j+hbHLqjCGUB66/igg0Atf3giU7sZOdPrSN6xdX43+k6Gn73yzWFJkWyImKzgHW9uVrU7vo3puailY+yC6RpwNME=\n"
  },
  {
    "path": "tests/fixtures/valid_ssh_keys",
    "content": "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAAAYQCkNCKyyUPvwwefhEpnD/Khuowbv+13Cv3shWY3ck3j+hbHLqjCGUB66/igg0Atf3giU7sZOdPrSN6xdX43+k6Gn73yzWFJkWyImKzgHW9uVrU7vo3puailY+yC6RpwNME= flynn@encom.com\nssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAAAYQCkNCKyyUPvwwefhEpnD/Khuowbv+13Cv3shWY3ck3j+hbHLqjCGUB66/igg0Atf3giU7sZOdPrSN6xdX43+k6Gn73yzWFJkWyImKzgHW9uVrU7vo3puailY+yC6RpwNME=\nssh-dss AAAAB3NzaC1kc3MAAACBAKMfNb7OtqUc5vje5UMvm5r1javDreL+EvACyU7N/BqO+FnqYx6RWQgp0mgDW5b+eOVBBwCfqn41TKHoXIlOsD3Rci7REBA87jmRbgQnZLSaT6BgVFWtEMI8SXG3EsmC5fXKHfX8j6W6f0Tuqeof4eaFW2RC6pYN3rvVGCvJ68FXAAAAFQDbmXw8U17CkJDvFOZ8KqehrahunQAAAIAKg/NxJVqLTJOM6qp7zI0WOiStotzclC5899w3zqJ446P/jLD5nILpnhbnUHnGmw53Yz6endWKHInsUurODPRZcjgOOd2lG5/RViegrI81YsQtczzIRNU86xL/28UkzzAy1IMeJWiH2vSrLLzx37YKZFH4MBjPhlsFtEIfHeR3dQAAAIEAlAMlPaHMIMEP+4IQP3qUhcZhI8UIYlSag2DqRjPry9jRL6uaYUYZAPC82Bvfi83TqJEhowqIBDFib4HDrgp9BJyTGfDZsHjRcLP15FYQdqIg8Fdo2ptsWGJa6EdFmj8RDIyaHsQ4a/JtnHUu+nC7DMYvTHliWl55UUVCSJzAyzs=\n"
  },
  {
    "path": "tests/functions_test.py",
    "content": "# -*- coding: utf-8 -*-\nfrom pytest import mark\nfrom os import path\nfrom ssh_ldap_pubkey import is_valid_openssh_pubkey\n\nFIXTURES_DIR = path.dirname(__file__) + '/fixtures'\n\n\ndef describe_is_valid_openssh_pubkey():\n\n    @mark.parametrize('key', read_fixtures('valid_ssh_keys'))\n    def returns_true_when_given_valid_pubkey(key):\n        assert is_valid_openssh_pubkey(key)\n\n    @mark.parametrize('key', read_fixtures('invalid_ssh_keys'))\n    def returns_false_when_given_invalid_pubkey(key):\n        assert not is_valid_openssh_pubkey(key)\n\n\ndef read_fixtures(filename):\n    with open(path.join(FIXTURES_DIR, filename)) as f:\n        return f.readlines()\n"
  }
]