Commits

Peter Sagerson  committed 7188cb4

Documentation and packaging.

  • Participants
  • Parent commits bbe6b8c

Comments (0)

Files changed (19)

+^docs/build
+This project, including all code, documentation, and other components is
+dedicated to the public domain. No rights reserved.
+include CHANGES LICENSE README.rst
+recursive-include docs/*.rst
+This is an implementation of the Yubico OTP algorithm, used on YubiKey devices.
+The primary audience is developers who wish to verify YubiKey tokens in their
+applications, presumably as part of a multi-factor authentication scheme. Note
+that this is *not* a YubiCloud client, it's the low-level implementation.
+
+For testing and experimentation, the included ``yubiotp`` script is a
+command-line interface to the OTP parsing and the ``yubikey`` script simulates
+one or more YubiKey devices using a config file.

File bin/yubikey

Empty file added.
+#!/usr/bin/env python
+
+

File docs/Makefile

+#
+# Makefile for Sphinx documentation
+#
+
+# You can set these variables from the command line.
+SPHINXOPTS    =
+SPHINXBUILD   = sphinx-build
+PAPER         =
+BUILDDIR      = build
+
+# Internal variables.
+PAPEROPT_a4     = -D latex_paper_size=a4
+PAPEROPT_letter = -D latex_paper_size=letter
+ALLSPHINXOPTS   = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) source
+# the i18n builder cannot share the environment and doctrees with the others
+I18NSPHINXOPTS  = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) source
+
+.PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest gettext
+
+help:
+	@echo "Please use \`make <target>' where <target> is one of"
+	@echo "  html       to make standalone HTML files"
+	@echo "  dirhtml    to make HTML files named index.html in directories"
+	@echo "  singlehtml to make a single large HTML file"
+	@echo "  pickle     to make pickle files"
+	@echo "  json       to make JSON files"
+	@echo "  htmlhelp   to make HTML files and a HTML help project"
+	@echo "  qthelp     to make HTML files and a qthelp project"
+	@echo "  devhelp    to make HTML files and a Devhelp project"
+	@echo "  epub       to make an epub"
+	@echo "  latex      to make LaTeX files, you can set PAPER=a4 or PAPER=letter"
+	@echo "  latexpdf   to make LaTeX files and run them through pdflatex"
+	@echo "  text       to make text files"
+	@echo "  man        to make manual pages"
+	@echo "  texinfo    to make Texinfo files"
+	@echo "  info       to make Texinfo files and run them through makeinfo"
+	@echo "  gettext    to make PO message catalogs"
+	@echo "  changes    to make an overview of all changed/added/deprecated items"
+	@echo "  linkcheck  to check all external links for integrity"
+	@echo "  doctest    to run all doctests embedded in the documentation (if enabled)"
+
+clean:
+	-rm -rf $(BUILDDIR)/*
+
+html:
+	$(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html
+	@echo
+	@echo "Build finished. The HTML pages are in $(BUILDDIR)/html."
+
+dirhtml:
+	$(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml
+	@echo
+	@echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml."
+
+singlehtml:
+	$(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml
+	@echo
+	@echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml."
+
+pickle:
+	$(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle
+	@echo
+	@echo "Build finished; now you can process the pickle files."
+
+json:
+	$(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json
+	@echo
+	@echo "Build finished; now you can process the JSON files."
+
+htmlhelp:
+	$(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp
+	@echo
+	@echo "Build finished; now you can run HTML Help Workshop with the" \
+	      ".hhp project file in $(BUILDDIR)/htmlhelp."
+
+qthelp:
+	$(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp
+	@echo
+	@echo "Build finished; now you can run "qcollectiongenerator" with the" \
+	      ".qhcp project file in $(BUILDDIR)/qthelp, like this:"
+	@echo "# qcollectiongenerator $(BUILDDIR)/qthelp/YubiOTP.qhcp"
+	@echo "To view the help file:"
+	@echo "# assistant -collectionFile $(BUILDDIR)/qthelp/YubiOTP.qhc"
+
+devhelp:
+	$(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp
+	@echo
+	@echo "Build finished."
+	@echo "To view the help file:"
+	@echo "# mkdir -p $$HOME/.local/share/devhelp/YubiOTP"
+	@echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/YubiOTP"
+	@echo "# devhelp"
+
+epub:
+	$(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub
+	@echo
+	@echo "Build finished. The epub file is in $(BUILDDIR)/epub."
+
+latex:
+	$(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex
+	@echo
+	@echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex."
+	@echo "Run \`make' in that directory to run these through (pdf)latex" \
+	      "(use \`make latexpdf' here to do that automatically)."
+
+latexpdf:
+	$(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex
+	@echo "Running LaTeX files through pdflatex..."
+	$(MAKE) -C $(BUILDDIR)/latex all-pdf
+	@echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex."
+
+text:
+	$(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text
+	@echo
+	@echo "Build finished. The text files are in $(BUILDDIR)/text."
+
+man:
+	$(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man
+	@echo
+	@echo "Build finished. The manual pages are in $(BUILDDIR)/man."
+
+texinfo:
+	$(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo
+	@echo
+	@echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo."
+	@echo "Run \`make' in that directory to run these through makeinfo" \
+	      "(use \`make info' here to do that automatically)."
+
+info:
+	$(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo
+	@echo "Running Texinfo files through makeinfo..."
+	make -C $(BUILDDIR)/texinfo info
+	@echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo."
+
+gettext:
+	$(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale
+	@echo
+	@echo "Build finished. The message catalogs are in $(BUILDDIR)/locale."
+
+changes:
+	$(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes
+	@echo
+	@echo "The overview file is in $(BUILDDIR)/changes."
+
+linkcheck:
+	$(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck
+	@echo
+	@echo "Link check complete; look for any errors in the above output " \
+	      "or in $(BUILDDIR)/linkcheck/output.txt."
+
+doctest:
+	$(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest
+	@echo "Testing of doctests in the sources finished, look at the " \
+	      "results in $(BUILDDIR)/doctest/output.txt."
+	
+zip:
+	rm build/html.zip || true
+	cd build/html && zip -R ../html.zip '*' -x .buildinfo -x '_sources/*'

File docs/source/conf.py

+# -*- coding: utf-8 -*-
+#
+# YubiOTP documentation build configuration file, created by
+# sphinx-quickstart on Wed Jul 11 10:19:09 2012.
+#
+# This file is execfile()d with the current directory set to its containing dir.
+#
+# Note that not all possible configuration values are present in this
+# autogenerated file.
+#
+# All configuration values have a default; values that are commented out
+# serve to show the default.
+
+import sys, os
+
+# If extensions (or modules to document with autodoc) are in another directory,
+# add these directories to sys.path here. If the directory is relative to the
+# documentation root, use os.path.abspath to make it absolute, like shown here.
+#sys.path.insert(0, os.path.abspath('.'))
+
+# -- General configuration -----------------------------------------------------
+
+# If your documentation needs a minimal Sphinx version, state it here.
+#needs_sphinx = '1.0'
+
+# Add any Sphinx extension module names here, as strings. They can be extensions
+# coming with Sphinx (named 'sphinx.ext.*') or your custom ones.
+extensions = ['sphinx.ext.autodoc']
+
+# Add any paths that contain templates here, relative to this directory.
+templates_path = ['_templates']
+
+# The suffix of source filenames.
+source_suffix = '.rst'
+
+# The encoding of source files.
+#source_encoding = 'utf-8-sig'
+
+# The master toctree document.
+master_doc = 'index'
+
+# General information about the project.
+project = u'YubiOTP'
+copyright = u'2012, Peter Sagerson'
+
+# The version info for the project you're documenting, acts as replacement for
+# |version| and |release|, also used in various other places throughout the
+# built documents.
+#
+# The short X.Y version.
+version = '0.1'
+# The full version, including alpha/beta/rc tags.
+release = '0.1.0'
+
+# The language for content autogenerated by Sphinx. Refer to documentation
+# for a list of supported languages.
+#language = None
+
+# There are two options for replacing |today|: either, you set today to some
+# non-false value, then it is used:
+#today = ''
+# Else, today_fmt is used as the format for a strftime call.
+#today_fmt = '%B %d, %Y'
+
+# List of patterns, relative to source directory, that match files and
+# directories to ignore when looking for source files.
+exclude_patterns = []
+
+# The reST default role (used for this markup: `text`) to use for all documents.
+#default_role = None
+
+# If true, '()' will be appended to :func: etc. cross-reference text.
+#add_function_parentheses = True
+
+# If true, the current module name will be prepended to all description
+# unit titles (such as .. function::).
+#add_module_names = True
+
+# If true, sectionauthor and moduleauthor directives will be shown in the
+# output. They are ignored by default.
+#show_authors = False
+
+# The name of the Pygments (syntax highlighting) style to use.
+pygments_style = 'sphinx'
+
+# A list of ignored prefixes for module index sorting.
+#modindex_common_prefix = []
+
+
+# -- Options for HTML output ---------------------------------------------------
+
+# The theme to use for HTML and HTML Help pages.  See the documentation for
+# a list of builtin themes.
+html_theme = 'default'
+
+# Theme options are theme-specific and customize the look and feel of a theme
+# further.  For a list of options available for each theme, see the
+# documentation.
+#html_theme_options = {}
+
+# Add any paths that contain custom themes here, relative to this directory.
+#html_theme_path = []
+
+# The name for this set of Sphinx documents.  If None, it defaults to
+# "<project> v<release> documentation".
+#html_title = None
+
+# A shorter title for the navigation bar.  Default is the same as html_title.
+#html_short_title = None
+
+# The name of an image file (relative to this directory) to place at the top
+# of the sidebar.
+#html_logo = None
+
+# The name of an image file (within the static path) to use as favicon of the
+# docs.  This file should be a Windows icon file (.ico) being 16x16 or 32x32
+# pixels large.
+#html_favicon = None
+
+# Add any paths that contain custom static files (such as style sheets) here,
+# relative to this directory. They are copied after the builtin static files,
+# so a file named "default.css" will overwrite the builtin "default.css".
+html_static_path = ['_static']
+
+# If not '', a 'Last updated on:' timestamp is inserted at every page bottom,
+# using the given strftime format.
+#html_last_updated_fmt = '%b %d, %Y'
+
+# If true, SmartyPants will be used to convert quotes and dashes to
+# typographically correct entities.
+#html_use_smartypants = True
+
+# Custom sidebar templates, maps document names to template names.
+#html_sidebars = {}
+
+# Additional templates that should be rendered to pages, maps page names to
+# template names.
+#html_additional_pages = {}
+
+# If false, no module index is generated.
+#html_domain_indices = True
+
+# If false, no index is generated.
+#html_use_index = True
+
+# If true, the index is split into individual pages for each letter.
+#html_split_index = False
+
+# If true, links to the reST sources are added to the pages.
+#html_show_sourcelink = True
+
+# If true, "Created using Sphinx" is shown in the HTML footer. Default is True.
+#html_show_sphinx = True
+
+# If true, "(C) Copyright ..." is shown in the HTML footer. Default is True.
+#html_show_copyright = True
+
+# If true, an OpenSearch description file will be output, and all pages will
+# contain a <link> tag referring to it.  The value of this option must be the
+# base URL from which the finished HTML is served.
+#html_use_opensearch = ''
+
+# This is the file name suffix for HTML files (e.g. ".xhtml").
+#html_file_suffix = None
+
+# Output file base name for HTML help builder.
+htmlhelp_basename = 'YubiOTPdoc'
+
+
+# -- Options for LaTeX output --------------------------------------------------
+
+latex_elements = {
+# The paper size ('letterpaper' or 'a4paper').
+#'papersize': 'letterpaper',
+
+# The font size ('10pt', '11pt' or '12pt').
+#'pointsize': '10pt',
+
+# Additional stuff for the LaTeX preamble.
+#'preamble': '',
+}
+
+# Grouping the document tree into LaTeX files. List of tuples
+# (source start file, target name, title, author, documentclass [howto/manual]).
+latex_documents = [
+  ('index', 'YubiOTP.tex', u'YubiOTP Documentation',
+   u'Peter Sagerson', 'manual'),
+]
+
+# The name of an image file (relative to this directory) to place at the top of
+# the title page.
+#latex_logo = None
+
+# For "manual" documents, if this is true, then toplevel headings are parts,
+# not chapters.
+#latex_use_parts = False
+
+# If true, show page references after internal links.
+#latex_show_pagerefs = False
+
+# If true, show URL addresses after external links.
+#latex_show_urls = False
+
+# Documents to append as an appendix to all manuals.
+#latex_appendices = []
+
+# If false, no module index is generated.
+#latex_domain_indices = True
+
+
+# -- Options for manual page output --------------------------------------------
+
+# One entry per manual page. List of tuples
+# (source start file, name, description, authors, manual section).
+man_pages = [
+    ('index', 'yubiotp', u'YubiOTP Documentation',
+     [u'Peter Sagerson'], 1)
+]
+
+# If true, show URL addresses after external links.
+#man_show_urls = False
+
+
+# -- Options for Texinfo output ------------------------------------------------
+
+# Grouping the document tree into Texinfo files. List of tuples
+# (source start file, target name, title, author,
+#  dir menu entry, description, category)
+texinfo_documents = [
+  ('index', 'YubiOTP', u'YubiOTP Documentation',
+   u'Peter Sagerson', 'YubiOTP', 'One line description of project.',
+   'Miscellaneous'),
+]
+
+# Documents to append as an appendix to all manuals.
+#texinfo_appendices = []
+
+# If false, no module index is generated.
+#texinfo_domain_indices = True
+
+# How to display URL addresses: 'footnote', 'no', or 'inline'.
+#texinfo_show_urls = 'footnote'

File docs/source/index.rst

+YubiOTP
+=======
+
+This is an implementation of the Yubico OTP algorithm, used on YubiKey devices.
+The primary audience is developers who wish to verify YubiKey tokens in their
+applications, presumably as part of a multi-factor authentication scheme. Note
+that this is *not* a YubiCloud client, it's the low-level implementation. Those
+wishing to verify YubiKey tokens in their application will be most interested in
+:meth:`yubiotp.otp.parse`.
+
+For testing and experimentation, the included ``yubiotp`` script is a
+command-line interface to the OTP parsing and the ``yubikey`` script simulates
+one or more YubiKey devices using a config file. These tools are documented by
+usage strings.
+
+yubiotp.otp
+-----------
+
+.. automodule:: yubiotp.otp
+    :members:
+
+
+yubiotp.modhex
+--------------
+
+.. automodule:: yubiotp.modhex
+    :members:
+
+
+yubiotp.crc
+-----------
+
+.. automodule:: yubiotp.crc
+    :members:
+
+
+Indices and tables
+==================
+
+* :ref:`genindex`
+* :ref:`modindex`
+* :ref:`search`
+
+from distutils import setup
+
+
+setup(
+    name='YubiOTP',
+    version='0.1.0',
+    description='An implementation of the Yubico OTP algorithm, as used in YubiKey devices.',
+    long_description=open('README.rst').read(),
+    author='Peter Sagerson',
+    author_email='psagersccdwvgsz@ignorare.net',
+    packages='yubiotp',
+    scripts=['bin/yubiotp', 'bin/yubikey'],
+    url='https://bitbucket.org/psagers/yubiotp',
+    license='LICENSE',
+    install_requires=['pycrypto']
+)

File src/yubiotp/__init__.py

Empty file removed.

File src/yubiotp/crc.py

-"""
-CRC16 implementation for Yubico OTP.
-"""
-
-def verify_crc16(data):
-    """
-    Return true if this given byte string has a valid crc-16 residual.
-
-    >>> from binascii import unhexlify
-    >>> verify_crc16(unhexlify('8792ebfe26cc130030c20011c89f23c8'))
-    True
-    >>> verify_crc16(unhexlify('0792ebfe26cc130030c20011c89f23c8'))
-    False
-    """
-    return crc16(data) == 0xf0b8
-
-def crc16(data):
-    """
-    Generate the crc-16 value for a byte string.
-
-    >>> from binascii import unhexlify
-    >>> c = crc16(unhexlify('8792ebfe26cc130030c20011c89f'))
-    >>> hex(~c & 0xffff)
-    '0xc823'
-    >>> v = crc16(unhexlify('8792ebfe26cc130030c20011c89f23c8'))
-    >>> hex(v)
-    '0xf0b8'
-    """
-    crc = 0xffff
-
-    for byte in data:
-        crc ^= ord(byte)
-
-        for i in xrange(8):
-            lsb = crc & 1
-            crc >>= 1
-            if lsb == 1:
-                crc ^= 0x8408
-
-    return crc
-
-
-
-if __name__ == "__main__":
-    import doctest
-
-    doctest.testmod()

File src/yubiotp/modhex.py

-"""
-Implementation of modhex encoding, which uses keyboard-independent characters.
-
-hex digit:     0123456789abcdef
-modehex digit: cbdefghijklnrtuv
-
-http://www.yubico.com/modhex-calculator
-"""
-
-from binascii import hexlify, unhexlify
-from functools import partial
-
-
-def modhex(data):
-    """
-    Encode a string as modhex.
-
-    >>> modhex('abcdefghijklmnop')
-    'hbhdhehfhghhhihjhkhlhnhrhthuhvic'
-    """
-    return hex_to_modhex(hexlify(data))
-
-def unmodhex(encoded):
-    """
-    Decode a modhex string to its binary form.
-
-    >>> unmodhex('hbhdhehfhghhhihjhkhlhnhrhthuhvic')
-    'abcdefghijklmnop'
-    >>> unmodhex('hbhdxx')
-    Traceback (most recent call last):
-        ...
-    ValueError: Illegal modhex character in input
-    """
-    try:
-        return unhexlify(modhex_to_hex(encoded))
-    except StopIteration as e:
-        raise ValueError('Illegal modhex character in input')
-
-def hex_to_modhex(hex_str):
-    """
-    Convert a string of hex digits to a string of modhex digits.
-
-    >>> hex_to_modhex('69b6481c8baba2b60e8f22179b58cd56')
-    'hknhfjbrjnlnldnhcujvddbikngjrtgh'
-    """
-    return ''.join(map(hex_to_modhex_char, hex_str.lower()))
-
-def modhex_to_hex(modhex_str):
-    """
-    Convert a string of modhex digits to a string of hex digits.
-
-    >>> modhex_to_hex('hknhfjbrjnlnldnhcujvddbikngjrtgh')
-    '69b6481c8baba2b60e8f22179b58cd56'
-    """
-    return ''.join(map(modhex_to_hex_char, modhex_str.lower()))
-
-
-#
-# Internals
-#
-
-def lookup(alist, key):
-    return next(v for k, v in alist if k == key)
-
-hex_chars    = '0123456789abcdef'
-modhex_chars = 'cbdefghijklnrtuv'
-
-hex_to_modhex_map = zip(hex_chars, modhex_chars)
-modhex_to_hex_map = zip(modhex_chars, hex_chars)
-
-hex_to_modhex_char = partial(lookup, hex_to_modhex_map)
-modhex_to_hex_char = partial(lookup, modhex_to_hex_map)
-
-
-
-if __name__ == "__main__":
-    import doctest
-
-    doctest.testmod()

File src/yubiotp/otp.py

-"""
-Implementation of the Yubico OTP algorithm. This can generate and parse OTP
-structures.
-"""
-
-from binascii import hexlify
-from datetime import datetime
-from random import randrange
-from struct import pack, unpack
-
-from .crc import crc16, verify_crc16
-from .modhex import modhex, unmodhex
-
-from Crypto.Cipher import AES
-
-
-class CRCError(ValueError):
-    pass
-
-
-class OTPDevice(object):
-    """
-    A simulated Yubico OTP device. This can be used to generate a sequence of
-    Yubikey OTP passwords.
-    """
-    def __init__(self, key, uid, session, counter=0, public_id=''):
-        """
-        key: An AES key.
-        uid: The private ID. This should be a string of up to six bytes. The
-            string will be right-padded with zeros if necessary.
-        session: The non-volatile usage counter. It is the caller's
-            responsibility to persist this. Note that this may increment if the
-            volatile counter wraps, so the correct way to handle this is to
-            store self.session + 1 after you've finished generating passwords.
-        counter: The volatile session counter. This defaults to 0 at init time,
-            but the caller can override this.
-        public_id: An optional public id to identify generated passwords. This
-            will be truncated to 16 bytes.
-        """
-        if len(key) != 16:
-            raise ValueError('key must be exactly 16 bytes')
-
-        self.key = key
-        self.uid = uid
-        self.session = session if (session < 0x7fff) else 0x7fff
-        self.counter = counter
-        self.public_id = public_id[:16]
-
-        self._init_timestamp()
-
-    def generate(self):
-        otp = OTP(self.uid, self.session, self._timestamp(), self.counter, randrange(0xffff))
-        buf = AES.new(self.key, mode=AES.MODE_ECB).encrypt(otp.pack())
-
-        self._increment_counter()
-
-        return modhex(self.public_id + buf)
-
-    def _init_timestamp(self, timestamp):
-        self._timestamp_base = randrange(0xffffff)
-        self._timestamp_start = datetime.now()
-
-    def _timestamp(self):
-        """
-        Returns the current timestamp value, based on the number of seconds
-        since the object was created.
-        """
-        delta = datetime.now() - self._timestamp_start
-        delta = delta.days * 86400 + delta.seconds
-
-        return (self._timestamp_base + (delta * 8)) % 0xffffff
-
-    def _increment_counter(self):
-        if self.counter == 0xff:
-            self._increment_session()
-            self.counter = 0
-        else:
-            self.counter += 1
-
-    def _increment_session(self):
-        self.session = min(self.session + 1, 0x7fff)
-
-
-class OTP(object):
-    """
-    A single YubiKey OTP. This is typically instantiated by parsing and encoded
-    OTP.
-    """
-    def __init__(self, uid, session, timestamp, counter, rand):
-        self.uid = uid
-        self.session = session
-        self.timestamp = timestamp
-        self.counter = counter
-        self.rand = rand
-
-    def __repr__(self):
-        return 'OTP: 0x{0} {1}/{2} ({3}/{4})'.format(
-            hexlify(self.uid),
-            self.session, self.counter,
-            hex(self.timestamp), hex(self.rand)
-        )
-
-    def __eq__(self, other):
-        if self.__class__ is not other.__class__:
-            return False
-
-        self_props = (self.uid, self.session, self.timestamp, self.counter, self.rand)
-        other_props = (other.uid, other.session, other.timestamp, other.counter, other.rand)
-
-        return (self_props == other_props)
-
-    def pack(self):
-        """
-        Returns the OTP packed into a binary string, ready to be encrypted and
-        encoded.
-        """
-        fields = (
-            self.uid,
-            self.session,
-            self.timestamp & 0xff,
-            (self.timestamp >> 8) & 0xff,
-            (self.timestamp >> 16) & 0xff,
-            self.counter,
-            self.rand,
-        )
-
-        buf = pack('<6sH3BBH', *fields)
-
-        crc = ~crc16(buf) & 0xffff
-        buf += pack('<H', crc)
-
-        return buf
-
-    @classmethod
-    def unpack(cls, buf):
-        """
-        Parse a packed OTP. This is the complement to pack(), so the buffer
-        should be a decoded, decrypted OTP buffer. This returns None if the
-        buffer does not pass crc validation.
-        """
-        if not verify_crc16(buf):
-            return None
-
-        uid, session, t1, t2, t3, counter, rand, crc = unpack('<6sH3BBHH', buf)
-
-        timestamp = (t3 << 16) | (t2 << 8) | (t1)
-
-        return cls(uid, session, timestamp, counter, rand)
-
-
-def parse(encoded, key):
-    """
-    Parses a modhex-encoded Yubico OTP value and returns the public ID and the
-    unpacked OTP object.
-
-    encoded: a modhex-encoded buffer. Decoded, this should consist of 0-16
-        bytes of public ID followed by 16 bytes of encrypted OTP data.
-    key: a 16-byte AES key.
-
-    returns: (identity, otp). identity is a decoded byte string and otp is an
-        instance of OTP.
-
-    raises: ValueError if the string can not be decoded.
-            CRCError if the checksum on the decrypted data is incorrect.
-    """
-    if len(key) != 16:
-        raise ValueError('Key must be exactly 16 bytes')
-
-    buf = unmodhex(encoded)
-
-    pub_len = len(buf) - 16
-    identity = buf[:pub_len]
-    buf = buf[pub_len:]
-
-    buf = AES.new(key, AES.MODE_ECB).decrypt(buf)
-    if not verify_crc16(buf):
-        raise CRCError('OTP checksum is invalid')
-
-    otp = OTP.unpack(buf)
-
-    return (identity, otp)

File yubiotp/__init__.py

Empty file added.

File yubiotp/crc.py

+"""
+CRC16 implementation for Yubico OTP.
+"""
+
+def crc16(data):
+    """
+    Generate the crc-16 value for a byte string.
+
+    >>> from binascii import unhexlify
+    >>> c = crc16(unhexlify('8792ebfe26cc130030c20011c89f'))
+    >>> hex(~c & 0xffff)
+    '0xc823'
+    >>> v = crc16(unhexlify('8792ebfe26cc130030c20011c89f23c8'))
+    >>> hex(v)
+    '0xf0b8'
+    """
+    crc = 0xffff
+
+    for byte in data:
+        crc ^= ord(byte)
+
+        for i in xrange(8):
+            lsb = crc & 1
+            crc >>= 1
+            if lsb == 1:
+                crc ^= 0x8408
+
+    return crc
+
+def verify_crc16(data):
+    """
+    Return true if this given byte string has a valid crc-16 residual.
+
+    >>> from binascii import unhexlify
+    >>> verify_crc16(unhexlify('8792ebfe26cc130030c20011c89f23c8'))
+    True
+    >>> verify_crc16(unhexlify('0792ebfe26cc130030c20011c89f23c8'))
+    False
+    """
+    return crc16(data) == 0xf0b8
+
+
+
+if __name__ == "__main__":
+    import doctest
+
+    doctest.testmod()

File yubiotp/modhex.py

+"""
+Implementation of `modhex encoding <http://www.yubico.com/modhex-calculator>`_,
+which uses keyboard-independent characters.
+
+::
+
+    hex digit:    0123456789abcdef
+    modhex digit: cbdefghijklnrtuv
+"""
+
+from binascii import hexlify, unhexlify
+from functools import partial
+
+__all__ = ['modhex', 'unmodhex', 'hex_to_modhex', 'modhex_to_hex']
+
+
+def modhex(data):
+    """
+    Encode a string of bytes as modhex.
+
+    >>> modhex('abcdefghijklmnop')
+    'hbhdhehfhghhhihjhkhlhnhrhthuhvic'
+    """
+    return hex_to_modhex(hexlify(data))
+
+def unmodhex(encoded):
+    """
+    Decode a modhex string to its binary form.
+
+    >>> unmodhex('hbhdhehfhghhhihjhkhlhnhrhthuhvic')
+    'abcdefghijklmnop'
+    """
+    return unhexlify(modhex_to_hex(encoded))
+
+def hex_to_modhex(hex_str):
+    """
+    Convert a string of hex digits to a string of modhex digits.
+
+    >>> hex_to_modhex('69b6481c8baba2b60e8f22179b58cd56')
+    'hknhfjbrjnlnldnhcujvddbikngjrtgh'
+    >>> hex_to_modhex('6j')
+    Traceback (most recent call last):
+        ...
+    ValueError: Illegal hex character in input
+    """
+    try:
+        return ''.join(map(hex_to_modhex_char, hex_str.lower()))
+    except StopIteration:
+        raise ValueError('Illegal hex character in input')
+
+def modhex_to_hex(modhex_str):
+    """
+    Convert a string of modhex digits to a string of hex digits.
+
+    >>> modhex_to_hex('hknhfjbrjnlnldnhcujvddbikngjrtgh')
+    '69b6481c8baba2b60e8f22179b58cd56'
+    >>> modhex_to_hex('hbhdxx')
+    Traceback (most recent call last):
+        ...
+    ValueError: Illegal modhex character in input
+    """
+    try:
+        return ''.join(map(modhex_to_hex_char, modhex_str.lower()))
+    except StopIteration:
+        raise ValueError('Illegal modhex character in input')
+
+
+#
+# Internals
+#
+
+def lookup(alist, key):
+    return next(v for k, v in alist if k == key)
+
+hex_chars    = '0123456789abcdef'
+modhex_chars = 'cbdefghijklnrtuv'
+
+hex_to_modhex_map = zip(hex_chars, modhex_chars)
+modhex_to_hex_map = zip(modhex_chars, hex_chars)
+
+hex_to_modhex_char = partial(lookup, hex_to_modhex_map)
+modhex_to_hex_char = partial(lookup, modhex_to_hex_map)
+
+
+
+if __name__ == "__main__":
+    import doctest
+
+    doctest.testmod()

File yubiotp/otp.py

+"""
+Implementation of the Yubico OTP algorithm. This can generate and parse OTP
+structures.
+"""
+
+from binascii import hexlify
+from datetime import datetime
+from random import randrange
+from struct import pack, unpack
+
+from .crc import crc16, verify_crc16
+from .modhex import modhex, unmodhex
+
+from Crypto.Cipher import AES
+
+
+__all__ = ['parse', 'OTP', 'YubiKey', 'CRCError']
+
+
+class CRCError(ValueError):
+    """
+    Raised when a decrypted token has an invalid checksum.
+    """
+    pass
+
+
+def parse(token, key):
+    """
+    Parses a modhex-encoded Yubico OTP value and returns the public ID and the
+    unpacked OTP object.
+
+    token
+        A modhex-encoded buffer. Decoded, this should consist of 0-16 bytes of
+        public ID followed by 16 bytes of encrypted OTP data.
+    key
+        A 16-byte AES key as a binary string.
+
+    Returns ``(identity, otp)``. ``identity`` is the public identity as a
+    decoded byte string and ``otp`` is an instance of :class:`OTP`.
+
+    Exceptions:
+        - ValueError if the string can not be decoded.
+        - :exc:`CRCError` if the checksum on the decrypted data is incorrect.
+    """
+    if len(key) != 16:
+        raise ValueError('Key must be exactly 16 bytes')
+
+    buf = unmodhex(token)
+    id_len = len(buf) - 16
+
+    identity = buf[:id_len]
+
+    buf = buf[id_len:]
+    buf = AES.new(key, AES.MODE_ECB).decrypt(buf)
+    otp = OTP.unpack(buf)
+
+    return (identity, otp)
+
+
+class OTP(object):
+    """
+    A single YubiKey OTP. This is typically instantiated by parsing an encoded
+    OTP.
+
+    .. attribute:: uid
+
+        The private ID. This should be a string of up to six bytes. The string
+        will be right-padded with zeros if necessary.
+
+    .. attribute:: session
+
+        The non-volatile usage counter.
+
+    .. attribute:: timestamp
+
+        An integer in ``[0..2^24]``.
+
+    .. attribute:: counter
+
+        The volatile usage counter.
+
+    .. attribute:: rand
+
+        An arbitrary number in ``[0..2^16]``.
+    """
+    def __init__(self, uid, session, timestamp, counter, rand):
+        self.uid = uid
+        self.session = session
+        self.timestamp = timestamp
+        self.counter = counter
+        self.rand = rand
+
+    def __repr__(self):
+        return 'OTP: 0x{0} {1}/{2} ({3}/{4})'.format(
+            hexlify(self.uid),
+            self.session, self.counter,
+            hex(self.timestamp), hex(self.rand)
+        )
+
+    def __eq__(self, other):
+        if self.__class__ is not other.__class__:
+            return False
+
+        self_props = (self.uid, self.session, self.timestamp, self.counter, self.rand)
+        other_props = (other.uid, other.session, other.timestamp, other.counter, other.rand)
+
+        return (self_props == other_props)
+
+    def pack(self):
+        """
+        Returns the OTP packed into a binary string, ready to be encrypted and
+        encoded.
+        """
+        fields = (
+            self.uid,
+            self.session,
+            self.timestamp & 0xff,
+            (self.timestamp >> 8) & 0xff,
+            (self.timestamp >> 16) & 0xff,
+            self.counter,
+            self.rand,
+        )
+
+        buf = pack('<6sH3BBH', *fields)
+
+        crc = ~crc16(buf) & 0xffff
+        buf += pack('<H', crc)
+
+        return buf
+
+    @classmethod
+    def unpack(cls, buf):
+        """
+        Parse a packed OTP. This is the complement to :meth:`pack` so the
+        buffer should be a decoded, decrypted OTP buffer. Raises
+        :exc:`CRCError` if the buffer does not pass crc validation.
+        """
+        if not verify_crc16(buf):
+            raise CRCError('OTP checksum is invalid')
+
+        uid, session, t1, t2, t3, counter, rand, crc = unpack('<6sH3BBHH', buf)
+
+        timestamp = (t3 << 16) | (t2 << 8) | (t1)
+
+        return cls(uid, session, timestamp, counter, rand)
+
+
+class YubiKey(object):
+    """
+    A simulated YubiKey device. This can be used to generate a sequence of
+    Yubico OTP passwords.
+
+    .. attribute:: key
+
+        An AES key as a binary string.
+
+    .. attribute:: uid
+
+        The private ID. This should be a string of up to six bytes. The string
+        will be right-padded with zeros if necessary.
+
+    .. attribute:: session
+
+        The non-volatile usage counter. It is the caller's responsibility to
+        persist this. Note that this may increment if the volatile counter
+        wraps, so you should only increment and persist this after you have
+        finished generating tokens.
+
+    .. attribute:: counter
+
+        The volatile session counter. This defaults to 0 at init time, but the
+        caller can override this.
+
+    .. attribute:: public_id
+
+        An optional public id to identify generated passwords. This will be
+        truncated to 16 bytes.
+    """
+    def __init__(self, key, uid, session, counter=0, public_id=''):
+        if len(key) != 16:
+            raise ValueError('key must be exactly 16 bytes')
+
+        self.key = key
+        self.uid = uid
+        self.session = session if (session < 0x7fff) else 0x7fff
+        self.counter = counter
+        self.public_id = public_id[:16]
+
+        self._init_timestamp()
+
+    def generate(self):
+        """
+        Generate a YubiKey token. This simluates pressing the YubiKey button
+        and returns the encoded token.
+        """
+        otp = OTP(self.uid, self.session, self._timestamp(), self.counter, randrange(0xffff))
+        self._increment_counter()
+
+        buf = AES.new(self.key, mode=AES.MODE_ECB).encrypt(otp.pack())
+
+        return modhex(self.public_id + buf)
+
+    def _init_timestamp(self, timestamp):
+        self._timestamp_base = randrange(0xffffff)
+        self._timestamp_start = datetime.now()
+
+    def _timestamp(self):
+        """
+        Returns the current timestamp value, based on the number of seconds
+        since the object was created.
+        """
+        delta = datetime.now() - self._timestamp_start
+        delta = delta.days * 86400 + delta.seconds
+
+        return (self._timestamp_base + (delta * 8)) % 0xffffff
+
+    def _increment_counter(self):
+        if self.counter == 0xff:
+            self._increment_session()
+            self.counter = 0
+        else:
+            self.counter += 1
+
+    def _increment_session(self):
+        self.session = min(self.session + 1, 0x7fff)