├── MANIFEST.in ├── .coveragerc ├── .gitignore ├── .travis.yml ├── UNLICENSE ├── setup.py ├── README.rst └── humanhash.py /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include humanhash.py 2 | include README.rst 3 | -------------------------------------------------------------------------------- /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | branch = True 3 | source = humanhash.py 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.egg-info/ 2 | *.pyc 3 | *.pyo 4 | .coverage 5 | .DS_Store 6 | MANIFEST 7 | build/ 8 | dist/ 9 | htmlcov/ 10 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | sudo: false 2 | language: python 3 | python: 4 | - "2.7" 5 | # Coverage doesn't support Python 3.2 6 | - "3.3" 7 | - "3.4" 8 | - "3.5" 9 | - "3.6" 10 | install: pip install coverage coveralls flake8 11 | script: 12 | - flake8 humanhash.py 13 | - coverage run humanhash.py 14 | after_script: 15 | - coverage report 16 | - coverage combine 17 | - coveralls 18 | -------------------------------------------------------------------------------- /UNLICENSE: -------------------------------------------------------------------------------- 1 | This is free and unencumbered software released into the public domain. 2 | 3 | Anyone is free to copy, modify, publish, use, compile, sell, or 4 | distribute this software, either in source code form or as a compiled 5 | binary, for any purpose, commercial or non-commercial, and by any 6 | means. 7 | 8 | In jurisdictions that recognize copyright laws, the author or authors 9 | of this software dedicate any and all copyright interest in the 10 | software to the public domain. We make this dedication for the benefit 11 | of the public at large and to the detriment of our heirs and 12 | successors. We intend this dedication to be an overt act of 13 | relinquishment in perpetuity of all present and future rights to this 14 | software under copyright law. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 19 | IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR 20 | OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, 21 | ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 22 | OTHER DEALINGS IN THE SOFTWARE. 23 | 24 | For more information, please refer to 25 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | from distutils.core import setup 5 | 6 | with open('README.rst', 'r') as f: 7 | long_description = f.read() 8 | 9 | setup( 10 | name='humanhash3', 11 | version='0.0.6', 12 | description='Human-readable representations of digests.', 13 | long_description=long_description, 14 | author='Zachary Voase', 15 | author_email='z@zacharyvoase.com', 16 | url='https://github.com/blag/humanhash', 17 | py_modules=['humanhash'], 18 | license='Public Domain', 19 | classifiers=[ 20 | 'Development Status :: 3 - Alpha', 21 | 'Intended Audience :: Developers', 22 | 'Intended Audience :: End Users/Desktop', 23 | 'Topic :: Security', 24 | 'Topic :: Utilities', 25 | 26 | # Pick your license as you wish (should match "license" above) 27 | 'License :: Public Domain', 28 | 29 | # Specify the Python versions you support here. In particular, ensure 30 | # that you indicate whether you support Python 2, Python 3 or both. 31 | 'Programming Language :: Python :: 2', 32 | 'Programming Language :: Python :: 2.7', 33 | 'Programming Language :: Python :: 3', 34 | # 'Programming Language :: Python :: 3.2', # Not tested 35 | 'Programming Language :: Python :: 3.3', 36 | 'Programming Language :: Python :: 3.4', 37 | 'Programming Language :: Python :: 3.5', 38 | 'Programming Language :: Python :: 3.6', 39 | ], 40 | ) 41 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | humanhash 2 | ========= 3 | 4 | humanhash provides human-readable representations of digests. 5 | 6 | .. image:: https://img.shields.io/travis/blag/humanhash.svg 7 | :target: https://travis-ci.org/blag/humanhash 8 | 9 | .. image:: https://img.shields.io/coveralls/blag/humanhash.svg 10 | :target: https://coveralls.io/github/blag/humanhash 11 | 12 | .. image:: https://img.shields.io/pypi/v/humanhash3.svg 13 | :target: https://pypi.python.org/pypi/humanhash3 14 | 15 | .. image:: https://img.shields.io/pypi/l/humanhash3.svg 16 | :target: https://github.com/blag/humanhash/blob/master/UNLICENSE 17 | 18 | .. image:: https://img.shields.io/pypi/pyversions/humanhash3.svg 19 | :target: https://github.com/blag/humanhash/blob/master/.travis.yml 20 | 21 | Example 22 | ------- 23 | 24 | .. code-block:: python 25 | 26 | >>> import humanhash 27 | 28 | >>> digest = '7528880a986c40e78c38115e640da2a1' 29 | >>> humanhash.humanize(digest) 30 | 'three-georgia-xray-jig' 31 | >>> humanhash.humanize(digest, words=6) 32 | 'high-mango-white-oregon-purple-charlie' 33 | 34 | >>> humanhash.uuid() 35 | ('potato-oranges-william-friend', '9d2278759ae24698b1345525bd53358b') 36 | 37 | Install 38 | ------- 39 | 40 | This module is available on PyPI as the ``humanhash3`` package. You can install 41 | it with ``pip``: 42 | 43 | .. code-block:: bash 44 | 45 | $ pip install humanhash3 46 | 47 | Caveats 48 | ------- 49 | 50 | Don’t store the humanhash output, as its statistical uniqueness is only 51 | around 1 in 4.3 billion. Its intended use is as a human-readable (and, 52 | most importantly, **memorable**) representation of a longer digest, 53 | unique enough for display in a user interface, where a user may need to 54 | remember or verbally communicate the identity of a hash, without having 55 | to remember a 40-character hexadecimal sequence. Nevertheless, you 56 | should keep original digests around, then pass them through 57 | ``humanize()`` only as you’re displaying them. 58 | 59 | How It Works 60 | ------------ 61 | 62 | The procedure for generating a humanhash involves compressing the input 63 | to a fixed length (default: 4 bytes), then mapping each of these bytes 64 | to a word in a pre-defined wordlist (a default wordlist is supplied with 65 | the library). This algorithm is consistent, so the same input, given the 66 | same wordlist, will always give the same output. You can also use your 67 | own wordlist, and specify a different number of words for output. 68 | 69 | Inspiration 70 | ----------- 71 | 72 | - `Chroma-Hash`_ - A human-viewable representation of a hash (albeit not 73 | one that can be output on a terminal, or shouted down a hallway). 74 | - `The NATO Phonetic Alphabet`_ - A great example of the trade-off 75 | between clarity of human communication and byte-wise efficiency of 76 | representation. 77 | 78 | .. _Chroma-Hash: http://mattt.github.com/Chroma-Hash/ 79 | .. _The NATO Phonetic Alphabet: http://en.wikipedia.org/wiki/NATO_phonetic_alphabet 80 | -------------------------------------------------------------------------------- /humanhash.py: -------------------------------------------------------------------------------- 1 | """ 2 | humanhash: Human-readable representations of digests. 3 | 4 | The simplest ways to use this module are the :func:`humanize` and :func:`uuid` 5 | functions. For tighter control over the output, see :class:`HumanHasher`. 6 | """ 7 | 8 | import operator 9 | import uuid as uuidlib 10 | import math 11 | import sys 12 | 13 | if sys.version_info.major == 3: # pragma: nocover 14 | # Map returns an iterator in PY3K 15 | py3_map = map 16 | 17 | def map(*args, **kwargs): 18 | return [i for i in py3_map(*args, **kwargs)] 19 | 20 | # Functionality of xrange is in range now 21 | xrange = range 22 | 23 | 24 | DEFAULT_WORDLIST = ( 25 | 'ack', 'alabama', 'alanine', 'alaska', 'alpha', 'angel', 'apart', 'april', 26 | 'arizona', 'arkansas', 'artist', 'asparagus', 'aspen', 'august', 'autumn', 27 | 'avocado', 'bacon', 'bakerloo', 'batman', 'beer', 'berlin', 'beryllium', 28 | 'black', 'blossom', 'blue', 'bluebird', 'bravo', 'bulldog', 'burger', 29 | 'butter', 'california', 'carbon', 'cardinal', 'carolina', 'carpet', 'cat', 30 | 'ceiling', 'charlie', 'chicken', 'coffee', 'cola', 'cold', 'colorado', 31 | 'comet', 'connecticut', 'crazy', 'cup', 'dakota', 'december', 'delaware', 32 | 'delta', 'diet', 'don', 'double', 'early', 'earth', 'east', 'echo', 33 | 'edward', 'eight', 'eighteen', 'eleven', 'emma', 'enemy', 'equal', 34 | 'failed', 'fanta', 'fifteen', 'fillet', 'finch', 'fish', 'five', 'fix', 35 | 'floor', 'florida', 'football', 'four', 'fourteen', 'foxtrot', 'freddie', 36 | 'friend', 'fruit', 'gee', 'georgia', 'glucose', 'golf', 'green', 'grey', 37 | 'hamper', 'happy', 'harry', 'hawaii', 'helium', 'high', 'hot', 'hotel', 38 | 'hydrogen', 'idaho', 'illinois', 'india', 'indigo', 'ink', 'iowa', 39 | 'island', 'item', 'jersey', 'jig', 'johnny', 'juliet', 'july', 'jupiter', 40 | 'kansas', 'kentucky', 'kilo', 'king', 'kitten', 'lactose', 'lake', 'lamp', 41 | 'lemon', 'leopard', 'lima', 'lion', 'lithium', 'london', 'louisiana', 42 | 'low', 'magazine', 'magnesium', 'maine', 'mango', 'march', 'mars', 43 | 'maryland', 'massachusetts', 'may', 'mexico', 'michigan', 'mike', 44 | 'minnesota', 'mirror', 'mississippi', 'missouri', 'mobile', 'mockingbird', 45 | 'monkey', 'montana', 'moon', 'mountain', 'muppet', 'music', 'nebraska', 46 | 'neptune', 'network', 'nevada', 'nine', 'nineteen', 'nitrogen', 'north', 47 | 'november', 'nuts', 'october', 'ohio', 'oklahoma', 'one', 'orange', 48 | 'oranges', 'oregon', 'oscar', 'oven', 'oxygen', 'papa', 'paris', 'pasta', 49 | 'pennsylvania', 'pip', 'pizza', 'pluto', 'potato', 'princess', 'purple', 50 | 'quebec', 'queen', 'quiet', 'red', 'river', 'robert', 'robin', 'romeo', 51 | 'rugby', 'sad', 'salami', 'saturn', 'september', 'seven', 'seventeen', 52 | 'shade', 'sierra', 'single', 'sink', 'six', 'sixteen', 'skylark', 'snake', 53 | 'social', 'sodium', 'solar', 'south', 'spaghetti', 'speaker', 'spring', 54 | 'stairway', 'steak', 'stream', 'summer', 'sweet', 'table', 'tango', 'ten', 55 | 'tennessee', 'tennis', 'texas', 'thirteen', 'three', 'timing', 'triple', 56 | 'twelve', 'twenty', 'two', 'uncle', 'undress', 'uniform', 'uranus', 'utah', 57 | 'vegan', 'venus', 'vermont', 'victor', 'video', 'violet', 'virginia', 58 | 'washington', 'west', 'whiskey', 'white', 'william', 'winner', 'winter', 59 | 'wisconsin', 'wolfram', 'wyoming', 'xray', 'yankee', 'yellow', 'zebra', 60 | 'zulu') 61 | 62 | 63 | class HumanHasher(object): 64 | 65 | """ 66 | Transforms hex digests to human-readable strings. 67 | 68 | The format of these strings will look something like: 69 | `victor-bacon-zulu-lima`. The output is obtained by compressing the input 70 | digest to a fixed number of bytes, then mapping those bytes to one of 256 71 | words. A default wordlist is provided, but you can override this if you 72 | prefer. 73 | 74 | As long as you use the same wordlist, the output will be consistent (i.e. 75 | the same digest will always render the same representation). 76 | """ 77 | 78 | def __init__(self, wordlist=DEFAULT_WORDLIST): 79 | """ 80 | >>> HumanHasher(wordlist=[]) 81 | Traceback (most recent call last): 82 | ... 83 | ValueError: Wordlist must have exactly 256 items 84 | """ 85 | if len(wordlist) != 256: 86 | raise ValueError("Wordlist must have exactly 256 items") 87 | self.wordlist = wordlist 88 | 89 | def humanize_list(self, hexdigest, words=4): 90 | """ 91 | Human a given hexadecimal digest, returning a list of words. 92 | 93 | Change the number of words output by specifying `words`. 94 | 95 | >>> digest = '60ad8d0d871b6095808297' 96 | >>> HumanHasher().humanize_list(digest) 97 | ['equal', 'monkey', 'lake', 'beryllium'] 98 | """ 99 | # Gets a list of byte values between 0-255. 100 | bytes_ = map(lambda x: int(x, 16), 101 | map(''.join, zip(hexdigest[::2], hexdigest[1::2]))) 102 | # Compress an arbitrary number of bytes to `words`. 103 | compressed = self.compress(bytes_, words) 104 | 105 | return [str(self.wordlist[byte]) for byte in compressed] 106 | 107 | def humanize(self, hexdigest, words=4, separator='-'): 108 | """ 109 | Humanize a given hexadecimal digest. 110 | 111 | Change the number of words output by specifying `words`. Change the 112 | word separator with `separator`. 113 | 114 | >>> digest = '60ad8d0d871b6095808297' 115 | >>> HumanHasher().humanize(digest) 116 | 'equal-monkey-lake-beryllium' 117 | >>> HumanHasher().humanize(digest, words=6) 118 | 'sodium-magnesium-nineteen-william-alanine-nebraska' 119 | >>> HumanHasher().humanize(digest, separator='*') 120 | 'equal*monkey*lake*beryllium' 121 | """ 122 | # Map the compressed byte values through the word list. 123 | return separator.join(self.humanize_list(hexdigest, words)) 124 | 125 | @staticmethod 126 | def compress(bytes_, target): 127 | 128 | """ 129 | Compress a list of byte values to a fixed target length. 130 | 131 | >>> bytes_ = [96, 173, 141, 13, 135, 27, 96, 149, 128, 130, 151] 132 | >>> list(HumanHasher.compress(bytes_, 4)) 133 | [64, 145, 117, 21] 134 | 135 | If there are less than the target number bytes, return input bytes 136 | 137 | >>> list(HumanHasher.compress(bytes_, 15)) # doctest: +ELLIPSIS 138 | [96, 173, 141, 13, 135, 27, 96, 149, 128, 130, 151] 139 | """ 140 | 141 | bytes_list = list(bytes_) 142 | 143 | length = len(bytes_list) 144 | # If there are less than the target number bytes, return input bytes 145 | if target >= length: 146 | return bytes_ 147 | 148 | # Split `bytes` evenly into `target` segments 149 | # Each segment hashes `seg_size` bytes, rounded down for some 150 | seg_size = float(length) / float(target) 151 | # Initialize `target` number of segments 152 | segments = [0] * target 153 | seg_num = 0 154 | 155 | # Use a simple XOR checksum-like function for compression 156 | for i, byte in enumerate(bytes_list): 157 | # Divide the byte index by the segment size to assign its segment 158 | # Floor to create a valid segment index 159 | # Min to ensure the index is within `target` 160 | seg_num = min(int(math.floor(i / seg_size)), target-1) 161 | # Apply XOR to the existing segment and the byte 162 | segments[seg_num] = operator.xor(segments[seg_num], byte) 163 | 164 | return segments 165 | 166 | def uuid(self, **params): 167 | 168 | """ 169 | Generate a UUID with a human-readable representation. 170 | 171 | Returns `(human_repr, full_digest)`. Accepts the same keyword arguments 172 | as :meth:`humanize` (they'll be passed straight through). 173 | 174 | >>> import re 175 | >>> hh = HumanHasher() 176 | >>> result = hh.uuid() 177 | >>> type(result) == tuple 178 | True 179 | >>> bool(re.match(r'^(\w+-){3}\w+$', result[0])) 180 | True 181 | >>> bool(re.match(r'^[0-9a-f]{32}$', result[1])) 182 | True 183 | """ 184 | digest = str(uuidlib.uuid4()).replace('-', '') 185 | return self.humanize(digest, **params), digest 186 | 187 | 188 | DEFAULT_HASHER = HumanHasher() 189 | uuid = DEFAULT_HASHER.uuid 190 | humanize = DEFAULT_HASHER.humanize 191 | humanize_list = DEFAULT_HASHER.humanize_list 192 | 193 | if __name__ == "__main__": # pragma: nocover 194 | import doctest 195 | # http://stackoverflow.com/a/25691978/6461688 196 | # This will force Python to exit with the number of failing tests as the 197 | # exit code, which should be interpreted as a failing test by Travis. 198 | sys.exit(doctest.testmod()) 199 | --------------------------------------------------------------------------------