├── .github └── workflows │ └── tests.yml ├── .gitignore ├── CHANGELOG.md ├── LICENSE ├── MANIFEST.in ├── README.md ├── branca.py ├── branca_test.py ├── codecov.yml ├── requirements.txt ├── setup.cfg ├── setup.py └── xchacha20poly1305.py /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | build: 7 | 8 | runs-on: ubuntu-latest 9 | strategy: 10 | matrix: 11 | python-version: [2.7, 3.5, 3.6, 3.7, 3.8] 12 | 13 | steps: 14 | - uses: actions/checkout@v2 15 | - name: Set up Python ${{ matrix.python-version }} 16 | uses: actions/setup-python@v2 17 | with: 18 | python-version: ${{ matrix.python-version }} 19 | - name: Install dependencies 20 | run: | 21 | python -m pip install --upgrade pip 22 | pip install flake8 pytest pytest-cov codecov 23 | if [ -f requirements.txt ]; then pip install -r requirements.txt; fi 24 | - name: Lint with flake8 25 | run: | 26 | # stop the build if there are Python syntax errors or undefined names 27 | flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics 28 | # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide 29 | flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics 30 | - name: Test with pytest 31 | run: | 32 | pytest --cov=./ 33 | - name: Upload coverage 34 | run: | 35 | codecov -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .coverage 2 | .pytest_cache/ 3 | __pycache__/ 4 | pybranca.egg-info/ 5 | dist/ 6 | bin/ 7 | lib/ 8 | pyvenv.cfg 9 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file, in reverse chronological order by release. 4 | 5 | ## [0.5.0](https://github.com/tuupola/pybranca/compare/0.4.0...0.5.0) - 2021-08-17 6 | 7 | ### Changed 8 | - Assume string keys are hex encoded ([#11](https://github.com/tuupola/pybranca/pull/11)). 9 | 10 | ### Added 11 | - Test vectors from the spec ([#8](https://github.com/tuupola/pybranca/pull/8)). 12 | 13 | ## [0.4.0](https://github.com/tuupola/pybranca/compare/0.3.0...0.4.0) - 2020-11-04 14 | 15 | ### Fixed 16 | - Timestamp is now checked as the last step ([#5](https://github.com/tuupola/pybranca/pull/5)). 17 | 18 | ## [0.3.0](https://github.com/tuupola/pybranca/compare/0.2.0...0.3.0) - 2019-02-03 19 | 20 | ### Added 21 | - Helper to get the timestamp from the token ([#1](https://github.com/tuupola/pybranca/pull/1)). 22 | 23 | 24 | ## 0.2.0 - 2018-03-20 25 | 26 | Initial realease. -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2018-2021 Mika Tuupola 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of 4 | this software and associated documentation files (the "Software"), to deal in 5 | the Software without restriction, including without limitation the rights to 6 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies 7 | of the Software, and to permit persons to whom the Software is furnished to do 8 | so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | SOFTWARE. -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | # Include the license file 2 | include LICENSE.txt -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Branca Tokens for Python 2 | 3 | Authenticated and encrypted API tokens using modern crypto. 4 | 5 | 6 | [![Latest Version](https://img.shields.io/pypi/v/pybranca.svg?style=flat-square)](https://pypi.org/project/pybranca/) 7 | [![Software License](https://img.shields.io/badge/license-MIT-brightgreen.svg?style=flat-square)](LICENSE) 8 | [![Build Status](https://img.shields.io/github/workflow/status/tuupola/pybranca/Tests/master?style=flat-square)](https://github.com/tuupola/pybranca/actions) 9 | [![Coverage](https://img.shields.io/codecov/c/github/tuupola/pybranca.svg?style=flat-square)](https://codecov.io/github/tuupola/pybranca) 10 | 11 | ## What? 12 | 13 | [Branca](https://github.com/tuupola/branca-spec/) is a secure easy to use token format which makes it hard to shoot yourself in the foot. It uses IETF XChaCha20-Poly1305 AEAD symmetric encryption to create encrypted and tamperproof tokens. Payload itself is an arbitrary sequence of bytes. You can use for example a JSON object, plain text string or even binary data serialized by [MessagePack](http://msgpack.org/) or [Protocol Buffers](https://developers.google.com/protocol-buffers/). 14 | 15 | Although not a design goal, it is possible to use [Branca as an alternative to JWT](https://appelsiini.net/2017/branca-alternative-to-jwt/). 16 | 17 | ## Install 18 | 19 | Install the library using pip. Note that you also must have [libsodium](https://download.libsodium.org/doc/) installed. 20 | 21 | ``` 22 | $ brew install libsodium 23 | $ pip install pybranca 24 | ``` 25 | 26 | ## Usage 27 | 28 | The payload of the token can be anything, like a simple string. 29 | 30 | ```python 31 | import secrets 32 | from branca import Branca 33 | 34 | key = secrets.token_bytes(32) 35 | branca = Branca(key) 36 | 37 | token = branca.encode("Hello world!") 38 | payload = branca.decode(token) 39 | 40 | print(token) 41 | print(payload) 42 | 43 | # 87xqn4ACMhqDZvoNuO0pXykuDlCwRz4Vg7LS3klfHpTiOUw1ramOqfWoaA6bvsGwOQ49MDFOERU0T 44 | # b'Hello world!' 45 | ``` 46 | 47 | For more complicated data structures JSON is an usual choice. 48 | 49 | ```python 50 | import json 51 | import secrets 52 | from branca import Branca 53 | 54 | key = secrets.token_bytes(32) 55 | branca = Branca(key) 56 | 57 | string = json.dumps({"scope" : ["read", "write", "delete"]}) 58 | 59 | token = branca.encode(string) 60 | payload = branca.decode(token) 61 | 62 | print(token) 63 | print(payload) 64 | print(json.loads(payload)) 65 | 66 | # 6AlLJaBIFpXbwKTFsI3xXsk4se8YsdEKOtxYwtYDQHpoqabwZzmxAUS99BLxBJpmfJqnJ9VvzJYO1FXfsX78d0YsvTe43opYbUPgUao0EGV5qBli 67 | # b'{"scope": ["read", "write", "delete"]}' 68 | # {'scope': ['read', 'write', 'delete']} 69 | ``` 70 | 71 | By using [MessagePack](https://msgpack.org/) you can have more compact tokens. 72 | 73 | ```python 74 | import msgpack 75 | from branca import Branca 76 | 77 | key = secrets.token_bytes(32) 78 | branca = Branca(key) 79 | 80 | packed = msgpack.dumps({"scope" : ["read", "write", "delete"]}) 81 | 82 | token = branca.encode(packed) 83 | payload = branca.decode(token) 84 | 85 | print(token) 86 | print(payload) 87 | print(msgpack.loads(payload, raw=False)) 88 | 89 | # 3iJOQqw5CWjCRRDnsd7Jh4dfsyf7a4qbuEO0uT8MBEvnMVaR8rOW4dFKBVFKKgxZkVlNchGJSIgPdHtHIM4rF4mZYsriTE37 90 | # b'\x81\xa5scope\x93\xa4read\xa5write\xa6delete' 91 | # {'scope': ['read', 'write', 'delete']} 92 | ``` 93 | 94 | ## License 95 | 96 | The MIT License (MIT). Please see [License File](LICENSE) for more information. -------------------------------------------------------------------------------- /branca.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2018-2021 Mika Tuupola 2 | # 3 | # Permission is hereby granted, free of charge, to any person obtaining a copy 4 | # of this software and associated documentation files (the "Software"), to 5 | # deal in the Software without restriction, including without limitation the 6 | # rights to use, copy, modify, merge, publish, distribute, sublicense, and/or 7 | # sell copied of the Software, and to permit persons to whom the Software is 8 | # furnished to do so, subject to the following conditions: 9 | # 10 | # The above copyright notice and this permission notice shall be included in 11 | # all copies or substantial portions of the Software. 12 | # 13 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | # SOFTWARE. 20 | 21 | """ 22 | Branca 23 | 24 | Authenticated and encrypted API tokens using modern crypto. 25 | """ 26 | 27 | import base62 28 | import calendar 29 | import ctypes 30 | import struct 31 | from binascii import unhexlify 32 | from datetime import datetime 33 | from xchacha20poly1305 import generate_nonce 34 | from xchacha20poly1305 import crypto_aead_xchacha20poly1305_ietf_encrypt 35 | from xchacha20poly1305 import crypto_aead_xchacha20poly1305_ietf_decrypt 36 | from xchacha20poly1305 import CRYPTO_AEAD_XHCACHA20POLY1305_IETF_NPUBBYTES 37 | from xchacha20poly1305 import CRYPTO_AEAD_XHCACHA20POLY1305_IETF_KEYBYTES 38 | 39 | class Branca: 40 | VERSION = 0xBA 41 | 42 | def __init__(self, key): 43 | if isinstance(key, bytes): 44 | self._key = key 45 | else: 46 | self._key = unhexlify(key) 47 | 48 | if len(self._key) is not CRYPTO_AEAD_XHCACHA20POLY1305_IETF_KEYBYTES: 49 | raise ValueError( 50 | "Secrect key should be {} bytes long".format( 51 | CRYPTO_AEAD_XHCACHA20POLY1305_IETF_KEYBYTES 52 | ) 53 | ) 54 | 55 | self._nonce = None # Used only for unit testing! 56 | 57 | def encode(self, payload, timestamp=None): 58 | 59 | if not isinstance(payload, bytes): 60 | payload = payload.encode() 61 | 62 | if timestamp is None: 63 | timestamp = calendar.timegm(datetime.utcnow().timetuple()) 64 | 65 | version = struct.pack("B", self.VERSION) 66 | time = struct.pack(">L", timestamp) 67 | 68 | if self._nonce is None: 69 | nonce = generate_nonce() 70 | else: 71 | nonce = self._nonce 72 | 73 | header = version + time + nonce 74 | ciphertext = crypto_aead_xchacha20poly1305_ietf_encrypt(payload, header, nonce, self._key) 75 | 76 | return base62.encodebytes(header + ciphertext) 77 | 78 | def decode(self, token, ttl=None): 79 | token = base62.decodebytes(token) 80 | header = token[0:CRYPTO_AEAD_XHCACHA20POLY1305_IETF_NPUBBYTES + 5] 81 | nonce = header[5:CRYPTO_AEAD_XHCACHA20POLY1305_IETF_NPUBBYTES + 5] 82 | ciphertext = token[CRYPTO_AEAD_XHCACHA20POLY1305_IETF_NPUBBYTES + 5:] 83 | 84 | version, time = struct.unpack(">BL", bytes(header[0:5])) 85 | 86 | # Implementation should accept only current version. 87 | if version is not self.VERSION: 88 | raise RuntimeError("Invalid token version") 89 | 90 | payload = crypto_aead_xchacha20poly1305_ietf_decrypt(ciphertext, header, nonce, self._key) 91 | 92 | if ttl is not None: 93 | future = time + ttl 94 | timestamp = calendar.timegm(datetime.utcnow().timetuple()) 95 | if future < timestamp: 96 | raise RuntimeError("Token is expired") 97 | 98 | return payload 99 | 100 | def timestamp(self, token): 101 | token = base62.decodebytes(token) 102 | version, time = struct.unpack(">BL", bytes(token[0:5])) 103 | 104 | return time -------------------------------------------------------------------------------- /branca_test.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2018-2021 Mika Tuupola 2 | # 3 | # Permission is hereby granted, free of charge, to any person obtaining a copy 4 | # of this software and associated documentation files (the "Software"), to 5 | # deal in the Software without restriction, including without limitation the 6 | # rights to use, copy, modify, merge, publish, distribute, sublicense, and/or 7 | # sell copied of the Software, and to permit persons to whom the Software is 8 | # furnished to do so, subject to the following conditions: 9 | # 10 | # The above copyright notice and this permission notice shall be included in 11 | # all copies or substantial portions of the Software. 12 | # 13 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | # SOFTWARE. 20 | 21 | from branca import Branca 22 | from binascii import unhexlify, hexlify 23 | import base62 24 | import pytest 25 | import struct 26 | import sys 27 | import xchacha20poly1305 28 | 29 | # 30 | # Decoding vectors 31 | # 32 | 33 | # Test vector 8 34 | def test_decode_hello_world_with_zero_timestamp(): 35 | key = unhexlify("73757065727365637265746b6579796f7573686f756c646e6f74636f6d6d6974") 36 | branca = Branca(key) 37 | token = "870S4BYxgHw0KnP3W9fgVUHEhT5g86vJ17etaC5Kh5uIraWHCI1psNQGv298ZmjPwoYbjDQ9chy2z" 38 | 39 | assert branca.decode(token) == b"Hello world!" 40 | assert branca.timestamp(token) == 0 41 | 42 | # Test vector 9 43 | def test_decode_hello_world_with_max_timestamp(): 44 | key = unhexlify("73757065727365637265746b6579796f7573686f756c646e6f74636f6d6d6974") 45 | branca = Branca(key) 46 | token = "89i7YCwu5tWAJNHUDdmIqhzOi5hVHOd4afjZcGMcVmM4enl4yeLiDyYv41eMkNmTX6IwYEFErCSqr" 47 | 48 | assert branca.decode(token) == b"Hello world!" 49 | assert branca.timestamp(token) == 4294967295 50 | 51 | # Test vector 10 52 | def test_decode_hello_world_with_nov27_timestamp(): 53 | key = unhexlify("73757065727365637265746b6579796f7573686f756c646e6f74636f6d6d6974") 54 | branca = Branca(key) 55 | 56 | token = "875GH23U0Dr6nHFA63DhOyd9LkYudBkX8RsCTOMz5xoYAMw9sMd5QwcEqLDRnTDHPenOX7nP2trlT" 57 | 58 | assert branca.decode(token) == b"Hello world!" 59 | assert branca.timestamp(token) == 123206400 60 | 61 | # Test vector 11 62 | def test_decode_eight_nul_bytes_with_zero_timestamp(): 63 | key = unhexlify("73757065727365637265746b6579796f7573686f756c646e6f74636f6d6d6974") 64 | branca = Branca(key) 65 | 66 | token = "1jIBheHbDdkCDFQmtgw4RUZeQoOJgGwTFJSpwOAk3XYpJJr52DEpILLmmwYl4tjdSbbNqcF1" 67 | 68 | assert branca.decode(token) == b"\x00\x00\x00\x00\x00\x00\x00\x00" 69 | assert branca.timestamp(token) == 0 70 | 71 | # Test vector 12 72 | def test_decode_eight_nul_bytes_with_max_timestamp(): 73 | key = unhexlify("73757065727365637265746b6579796f7573686f756c646e6f74636f6d6d6974") 74 | branca = Branca(key) 75 | 76 | token = "1jrx6DUu5q06oxykef2e2ZMyTcDRTQot9ZnwgifUtzAphGtjsxfbxXNhQyBEOGtpbkBgvIQx" 77 | 78 | assert branca.decode(token) == b"\x00\x00\x00\x00\x00\x00\x00\x00" 79 | assert branca.timestamp(token) == 4294967295 80 | 81 | # Test vector 13 82 | def test_decode_eight_nul_bytes_with_nov27_timestamp(): 83 | key = unhexlify("73757065727365637265746b6579796f7573686f756c646e6f74636f6d6d6974") 84 | branca = Branca(key) 85 | 86 | token = "1jJDJOEjuwVb9Csz1Ypw1KBWSkr0YDpeBeJN6NzJWx1VgPLmcBhu2SbkpQ9JjZ3nfUf7Aytp" 87 | 88 | assert branca.decode(token) == b"\x00\x00\x00\x00\x00\x00\x00\x00" 89 | assert branca.timestamp(token) == 123206400 90 | 91 | # Test vector 14 92 | def test_decode_empty_payload(): 93 | key = unhexlify("73757065727365637265746b6579796f7573686f756c646e6f74636f6d6d6974") 94 | branca = Branca(key) 95 | 96 | token = "4sfD0vPFhIif8cy4nB3BQkHeJqkOkDvinI4zIhMjYX4YXZU5WIq9ycCVjGzB5" 97 | 98 | assert branca.decode(token) == b"" 99 | assert branca.timestamp(token) == 0 100 | 101 | # Test vector 15 102 | def test_decode_non_utf8_payload(): 103 | key = unhexlify("73757065727365637265746b6579796f7573686f756c646e6f74636f6d6d6974") 104 | branca = Branca(key) 105 | 106 | token = "K9u6d0zjXp8RXNUGDyXAsB9AtPo60CD3xxQ2ulL8aQoTzXbvockRff0y1eXoHm" 107 | 108 | assert branca.decode(token) == b"\x80" 109 | assert branca.timestamp(token) == 123206400 110 | 111 | # Test vector 16 112 | def test_should_throw_with_wrong_version(): 113 | key = unhexlify("73757065727365637265746b6579796f7573686f756c646e6f74636f6d6d6974") 114 | branca = Branca(key) 115 | 116 | token = "89mvl3RkwXjpEj5WMxK7GUDEHEeeeZtwjMIOogTthvr44qBfYtQSIZH5MHOTC0GzoutDIeoPVZk3w" 117 | 118 | with pytest.raises(RuntimeError): 119 | branca.decode(token) 120 | 121 | # Test vector 17 122 | def test_should_throw_with_invalid_base62(): 123 | key = unhexlify("73757065727365637265746b6579796f7573686f756c646e6f74636f6d6d6974") 124 | branca = Branca(key) 125 | 126 | token = "875GH23U0Dr6nHFA63DhOyd9LkYudBkX8RsCTOMz5xoYAMw9sMd5QwcEqLDRnTDHPenOX7nP2trlT_" 127 | 128 | with pytest.raises(ValueError): 129 | branca.decode(token) 130 | 131 | # Test vector 18 132 | def test_should_throw_with_modified_version(): 133 | key = unhexlify("73757065727365637265746b6579796f7573686f756c646e6f74636f6d6d6974") 134 | branca = Branca(key) 135 | 136 | token = "89mvl3S0BE0UCMIY94xxIux4eg1w5oXrhvCEXrDAjusSbO0Yk7AU6FjjTnbTWTqogLfNPJLzecHVb" 137 | 138 | with pytest.raises(RuntimeError): 139 | branca.decode(token) 140 | 141 | # Test vector 19 142 | def test_should_throw_with_modified_nonce(): 143 | key = unhexlify("73757065727365637265746b6579796f7573686f756c646e6f74636f6d6d6974") 144 | branca = Branca(key) 145 | 146 | token = "875GH233SUysT7fQ711EWd9BXpwOjB72ng3ZLnjWFrmOqVy49Bv93b78JU5331LbcY0EEzhLfpmSx" 147 | 148 | with pytest.raises(RuntimeError): 149 | branca.decode(token) 150 | 151 | # Test vector 20 152 | def test_should_throw_with_modified_timestamp(): 153 | key = unhexlify("73757065727365637265746b6579796f7573686f756c646e6f74636f6d6d6974") 154 | branca = Branca(key) 155 | 156 | token = "870g1RCk4lW1YInhaU3TP8u2hGtfol16ettLcTOSoA0JIpjCaQRW7tQeP6dQmTvFIB2s6wL5deMXr" 157 | 158 | with pytest.raises(RuntimeError): 159 | branca.decode(token) 160 | 161 | # Test vector 21 162 | def test_should_throw_with_modified_ciphertext(): 163 | key = unhexlify("73757065727365637265746b6579796f7573686f756c646e6f74636f6d6d6974") 164 | branca = Branca(key) 165 | 166 | token = "875GH23U0Dr6nHFA63DhOyd9LkYudBkX8RsCTOMz5xoYAMw9sMd5Qw6Jpo96myliI3hHD7VbKZBYh" 167 | 168 | with pytest.raises(RuntimeError): 169 | branca.decode(token) 170 | 171 | # Test vector 22 172 | def test_should_throw_with_modified_tag(): 173 | key = unhexlify("73757065727365637265746b6579796f7573686f756c646e6f74636f6d6d6974") 174 | branca = Branca(key) 175 | 176 | token = "875GH23U0Dr6nHFA63DhOyd9LkYudBkX8RsCTOMz5xoYAMw9sMd5QwcEqLDRnTDHPenOX7nP2trk0" 177 | 178 | with pytest.raises(RuntimeError): 179 | branca.decode(token) 180 | 181 | # Test vector 23 182 | def test_should_throw_with_wrong_key(): 183 | key = unhexlify("77726f6e677365637265746b6579796f7573686f756c646e6f74636f6d6d6974") 184 | branca = Branca(key) 185 | token = "870S4BYxgHw0KnP3W9fgVUHEhT5g86vJ17etaC5Kh5uIraWHCI1psNQGv298ZmjPwoYbjDQ9chy2z" 186 | 187 | with pytest.raises(RuntimeError): 188 | branca.decode(token) 189 | 190 | # Test vector 24 191 | def test_should_throw_with_invalid_key(): 192 | with pytest.raises(ValueError): 193 | key = unhexlify("746f6f73686f72746b6579") 194 | branca = Branca(key) 195 | 196 | # 197 | # Encoding vectors 198 | # 199 | 200 | # Test vector 0 201 | def test_encode_hello_world_with_zero_timestamp(): 202 | key = unhexlify("73757065727365637265746b6579796f7573686f756c646e6f74636f6d6d6974") 203 | branca = Branca(key) 204 | 205 | branca._nonce = unhexlify("beefbeefbeefbeefbeefbeefbeefbeefbeefbeefbeefbeef") 206 | token = branca.encode("Hello world!", timestamp=0) 207 | 208 | assert token == "870S4BYxgHw0KnP3W9fgVUHEhT5g86vJ17etaC5Kh5uIraWHCI1psNQGv298ZmjPwoYbjDQ9chy2z" 209 | 210 | # Test vector 1 211 | def test_encode_hello_world_with_max_timestamp(): 212 | key = unhexlify("73757065727365637265746b6579796f7573686f756c646e6f74636f6d6d6974") 213 | branca = Branca(key) 214 | 215 | branca._nonce = unhexlify("beefbeefbeefbeefbeefbeefbeefbeefbeefbeefbeefbeef") 216 | token = branca.encode("Hello world!", timestamp=4294967295) 217 | 218 | assert token == "89i7YCwu5tWAJNHUDdmIqhzOi5hVHOd4afjZcGMcVmM4enl4yeLiDyYv41eMkNmTX6IwYEFErCSqr" 219 | 220 | # Test vector 2 221 | def test_encode_hello_world_with_november_27_timestamp(): 222 | key = unhexlify("73757065727365637265746b6579796f7573686f756c646e6f74636f6d6d6974") 223 | branca = Branca(key) 224 | 225 | branca._nonce = unhexlify("beefbeefbeefbeefbeefbeefbeefbeefbeefbeefbeefbeef") 226 | token = branca.encode("Hello world!", timestamp=123206400) 227 | 228 | assert token == "875GH23U0Dr6nHFA63DhOyd9LkYudBkX8RsCTOMz5xoYAMw9sMd5QwcEqLDRnTDHPenOX7nP2trlT" 229 | 230 | # Test vector 3 231 | def test_encode_eight_nul_bytes_with_zero_timestamp(): 232 | key = unhexlify("73757065727365637265746b6579796f7573686f756c646e6f74636f6d6d6974") 233 | branca = Branca(key) 234 | 235 | branca._nonce = unhexlify("beefbeefbeefbeefbeefbeefbeefbeefbeefbeefbeefbeef") 236 | token = branca.encode(b"\x00\x00\x00\x00\x00\x00\x00\x00", timestamp=0) 237 | 238 | assert token == "1jIBheHbDdkCDFQmtgw4RUZeQoOJgGwTFJSpwOAk3XYpJJr52DEpILLmmwYl4tjdSbbNqcF1" 239 | 240 | # Test vector 4 241 | def test_encode_eight_nul_bytes_with_zero_timestamp(): 242 | key = unhexlify("73757065727365637265746b6579796f7573686f756c646e6f74636f6d6d6974") 243 | branca = Branca(key) 244 | 245 | branca._nonce = unhexlify("beefbeefbeefbeefbeefbeefbeefbeefbeefbeefbeefbeef") 246 | token = branca.encode(b"\x00\x00\x00\x00\x00\x00\x00\x00", timestamp=4294967295) 247 | 248 | assert token == "1jrx6DUu5q06oxykef2e2ZMyTcDRTQot9ZnwgifUtzAphGtjsxfbxXNhQyBEOGtpbkBgvIQx" 249 | 250 | # Test vector 5 251 | def test_encode_eight_nul_bytes_with_november_27_timestamp(): 252 | key = unhexlify("73757065727365637265746b6579796f7573686f756c646e6f74636f6d6d6974") 253 | branca = Branca(key) 254 | 255 | branca._nonce = unhexlify("beefbeefbeefbeefbeefbeefbeefbeefbeefbeefbeefbeef") 256 | token = branca.encode(b"\x00\x00\x00\x00\x00\x00\x00\x00", timestamp=123206400) 257 | 258 | assert token == "1jJDJOEjuwVb9Csz1Ypw1KBWSkr0YDpeBeJN6NzJWx1VgPLmcBhu2SbkpQ9JjZ3nfUf7Aytp" 259 | 260 | # Test vector 6 261 | def test_encode_empty_payload(): 262 | key = unhexlify("73757065727365637265746b6579796f7573686f756c646e6f74636f6d6d6974") 263 | branca = Branca(key) 264 | 265 | branca._nonce = unhexlify("beefbeefbeefbeefbeefbeefbeefbeefbeefbeefbeefbeef") 266 | token = branca.encode(b"", timestamp=0) 267 | 268 | assert token == "4sfD0vPFhIif8cy4nB3BQkHeJqkOkDvinI4zIhMjYX4YXZU5WIq9ycCVjGzB5" 269 | 270 | # Test vector 7 271 | def test_encode_non_utf8_payload(): 272 | key = unhexlify("73757065727365637265746b6579796f7573686f756c646e6f74636f6d6d6974") 273 | branca = Branca(key) 274 | 275 | branca._nonce = unhexlify("beefbeefbeefbeefbeefbeefbeefbeefbeefbeefbeefbeef") 276 | token = branca.encode(b"", timestamp=0) 277 | 278 | assert token == "4sfD0vPFhIif8cy4nB3BQkHeJqkOkDvinI4zIhMjYX4YXZU5WIq9ycCVjGzB5" 279 | 280 | # 281 | # Implementation specific tests 282 | # 283 | 284 | def test_should_throw_when_expired(): 285 | key = unhexlify("73757065727365637265746b6579796f7573686f756c646e6f74636f6d6d6974") 286 | branca = Branca(key) 287 | 288 | branca._nonce = unhexlify("0102030405060708090a0b0c0102030405060708090a0b0c") 289 | token = branca.encode(b"Hello world!", timestamp=123206400) 290 | 291 | with pytest.raises(RuntimeError): 292 | branca.decode(token, 3600) 293 | 294 | def test_should_get_timestamp(): 295 | key = unhexlify("73757065727365637265746b6579796f7573686f756c646e6f74636f6d6d6974") 296 | branca = Branca(key) 297 | 298 | token = "1jJDJOEeG2FutA8g7NAOHK4Mh5RIE8jtbXd63uYbrFDSR06dtQl9o2gZYhBa36nZHXVfiGFz" 299 | 300 | assert branca.timestamp(token) == 123206400 301 | 302 | def test_should_allow_bytes_key(): 303 | key = unhexlify("73757065727365637265746b6579796f7573686f756c646e6f74636f6d6d6974") 304 | branca = Branca(key) 305 | 306 | token = "875GH23U0Dr6nHFA63DhOyd9LkYudBkX8RsCTOMz5xoYAMw9sMd5QwcEqLDRnTDHPenOX7nP2trlT" 307 | 308 | assert branca.decode(token) == b"Hello world!" 309 | assert branca.timestamp(token) == 123206400 310 | 311 | @pytest.mark.skipif(sys.version_info < (3, 5), reason="Requires Python 3.5 or higher.") 312 | def test_should_allow_hex_string_key(): 313 | branca = Branca(key="73757065727365637265746b6579796f7573686f756c646e6f74636f6d6d6974") 314 | 315 | token = "875GH23U0Dr6nHFA63DhOyd9LkYudBkX8RsCTOMz5xoYAMw9sMd5QwcEqLDRnTDHPenOX7nP2trlT" 316 | 317 | assert branca.decode(token) == b"Hello world!" 318 | assert branca.timestamp(token) == 123206400 -------------------------------------------------------------------------------- /codecov.yml: -------------------------------------------------------------------------------- 1 | comment: false -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | pybase62>=0.3 2 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | description-file = README.md 3 | 4 | [bdist_wheel] 5 | universal=1 6 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup 2 | from codecs import open 3 | from os import path 4 | 5 | cwd = path.abspath(path.dirname(__file__)) 6 | 7 | # Get the long description from the README file 8 | with open(path.join(cwd, "README.md"), encoding="utf-8") as f: 9 | long_description = f.read() 10 | 11 | setup( 12 | name="pybranca", 13 | py_modules=["branca", "xchacha20poly1305"], 14 | version="0.5.0", 15 | description="Authenticated and encrypted API tokens using modern crypto", 16 | long_description=long_description, 17 | long_description_content_type="text/markdown", 18 | keywords="api, token, jwt, xchacha20, poly1305", 19 | url="https://github.com/tuupola/pybranca", 20 | author="Mika Tuupola", 21 | author_email="tuupola@appelsiini.net", 22 | maintainer="Mika Tuupola", 23 | maintainer_email="tuupola@appelsiini.net", 24 | license="MIT", 25 | install_requires=[ 26 | "pybase62>=0.3" 27 | ], 28 | classifiers=[ 29 | "Development Status :: 4 - Beta", 30 | "Programming Language :: Python", 31 | "Topic :: Security", 32 | "License :: OSI Approved :: MIT License", 33 | ], 34 | ) 35 | -------------------------------------------------------------------------------- /xchacha20poly1305.py: -------------------------------------------------------------------------------- 1 | # Wrapper for libsodium IETF XChaCha20-Poly1305 AEAD 2 | # 3 | # Copyright (c) 2013-2018, Marsiske Stefan. 4 | # Copyright (c) 2018-2021 Mika Tuupola. 5 | # 6 | # All rights reserved. 7 | # 8 | # Redistribution and use in source and binary forms, with or without 9 | # modification, are permitted provided that the following conditions are met: 10 | # 11 | # * Redistributions of source code must retain the above copyright notice, 12 | # this list of conditions and the following disclaimer. 13 | # 14 | # * Redistributions in binary form must reproduce the above copyright 15 | # notice, this list of conditions and the following disclaimer in the 16 | # documentation and/or other materials provided with the distribution. 17 | # 18 | # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 19 | # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 20 | # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE 21 | # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE 22 | # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR 23 | # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF 24 | # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS 25 | # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN 26 | # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) 27 | # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE 28 | # POSSIBILITY OF SUCH DAMAGE. 29 | 30 | """ 31 | IETF XChaCha20-Poly1305 AEAD 32 | 33 | Wrapper for libsodium IETF XChaCha20-Poly1305 AEAD functions. 34 | """ 35 | 36 | import ctypes 37 | import ctypes.util 38 | 39 | library_path = ctypes.util.find_library("sodium") or ctypes.util.find_library("libsodium") 40 | sodium = ctypes.cdll.LoadLibrary(library_path) 41 | 42 | if not sodium._name: 43 | raise RuntimeError("Unable to locate libsodium") 44 | 45 | CRYPTO_AEAD_XHCACHA20POLY1305_IETF_KEYBYTES = sodium.crypto_aead_xchacha20poly1305_ietf_keybytes() 46 | CRYPTO_AEAD_XHCACHA20POLY1305_IETF_NPUBBYTES = sodium.crypto_aead_xchacha20poly1305_ietf_npubbytes() 47 | CRYPTO_AEAD_XHCACHA20POLY1305_IETF_ABYTES = sodium.crypto_aead_xchacha20poly1305_ietf_abytes() 48 | 49 | # crypto_aead_xchacha20poly1305_ietf_encrypt(ciphertext, &ciphertext_len, 50 | # MESSAGE, MESSAGE_LEN, 51 | # ADDITIONAL_DATA, ADDITIONAL_DATA_LEN, 52 | # NULL, nonce, key); 53 | 54 | def crypto_aead_xchacha20poly1305_ietf_encrypt(message, ad, nonce, key): 55 | if len(nonce) is not CRYPTO_AEAD_XHCACHA20POLY1305_IETF_NPUBBYTES: 56 | raise ValueError("Invalid nonce") 57 | 58 | if len(key) is not CRYPTO_AEAD_XHCACHA20POLY1305_IETF_KEYBYTES: 59 | raise ValueError("Invalid key") 60 | 61 | message_len = ctypes.c_ulonglong(len(message)) 62 | 63 | if ad is None: 64 | ad_len = ctypes.c_ulonglong(0) 65 | else: 66 | ad_len = ctypes.c_ulonglong(len(ad)) 67 | 68 | ciphertext = ctypes.create_string_buffer( 69 | message_len.value + CRYPTO_AEAD_XHCACHA20POLY1305_IETF_ABYTES 70 | ) 71 | ciphertext_len = ctypes.c_ulonglong(0) 72 | 73 | retval = sodium.crypto_aead_xchacha20poly1305_ietf_encrypt( 74 | ciphertext, ctypes.byref(ciphertext_len), 75 | message, message_len, 76 | ad, ad_len, 77 | None, nonce, key 78 | ) 79 | 80 | if retval != 0: 81 | raise RuntimeError("Encrypting token failed") 82 | 83 | return ciphertext.raw 84 | 85 | # if (crypto_aead_xchacha20poly1305_ietf_decrypt(decrypted, &decrypted_len, 86 | # NULL, 87 | # ciphertext, ciphertext_len, 88 | # ADDITIONAL_DATA, 89 | # ADDITIONAL_DATA_LEN, 90 | # nonce, key) != 0) { 91 | 92 | def crypto_aead_xchacha20poly1305_ietf_decrypt(ciphertext, ad, nonce, key): 93 | if len(nonce) != CRYPTO_AEAD_XHCACHA20POLY1305_IETF_NPUBBYTES: 94 | raise ValueError("Invalid nonce") 95 | 96 | if len(key) != CRYPTO_AEAD_XHCACHA20POLY1305_IETF_KEYBYTES: 97 | raise ValueError("Invalid key") 98 | 99 | decrypted = ctypes.create_string_buffer( 100 | len(ciphertext) - CRYPTO_AEAD_XHCACHA20POLY1305_IETF_ABYTES 101 | ) 102 | decrypted_len = ctypes.c_ulonglong(0) 103 | ciphertext_len = ctypes.c_ulonglong(len(ciphertext)) 104 | 105 | if ad is None: 106 | ad_len = ctypes.c_ulonglong(0) 107 | else: 108 | ad_len = ctypes.c_ulonglong(len(ad)) 109 | 110 | retval = sodium.crypto_aead_xchacha20poly1305_ietf_decrypt( 111 | decrypted, ctypes.byref(decrypted_len), 112 | None, 113 | ciphertext, ciphertext_len, 114 | ad, ad_len, 115 | nonce, key 116 | ) 117 | 118 | if retval != 0: 119 | raise RuntimeError("Decrypting token failed") 120 | 121 | return decrypted.raw 122 | 123 | def generate_nonce(): 124 | buffer = ctypes.create_string_buffer(CRYPTO_AEAD_XHCACHA20POLY1305_IETF_NPUBBYTES) 125 | sodium.randombytes(buffer, ctypes.c_ulonglong(CRYPTO_AEAD_XHCACHA20POLY1305_IETF_NPUBBYTES)) 126 | return buffer.raw --------------------------------------------------------------------------------