brndnmtthws/seed-otp

View on GitHub
seed_otp/generate.py

Summary

Maintainability
A
0 mins
Test Coverage
A
100%
import base64
import hashlib
import secrets
import struct

import click


def get_next_int():
    # BIP-0039 specifies 11 bits for representing words (i.e., 2048 possible
    # values per word)
    return secrets.randbelow(2048)


def generate_key(num_words):
    if num_words > 2**16:
        raise ValueError("Maximum number of possible words is 65536")
    ints = []
    for _ in range(0, num_words):
        ints.append(get_next_int())

    return encode_key(num_words, ints)


def encode_key(num_keys, keylist):
    # First 2 bytes are the number of words
    keystring = struct.pack('>H', num_keys)
    # Following groups of 2 bytes each are the word indexes
    for idx in keylist:
        keystring += struct.pack('>H', idx)

    # Last 4 bytes (checksum) is the first 4 bytes of the sha256 digest
    m = hashlib.sha256()
    m.update(keystring)
    keystring += m.digest()[0:4]

    return base64.urlsafe_b64encode(keystring).decode('ascii').rstrip('=')


class DecodingError(Exception):
    """Raised when there is a decoding error"""
    pass


def decode_key(keystring):
    # Add base64 padding if necessary
    missing_padding = len(keystring) % 4
    if missing_padding:
        keystring += '=' * (4 - missing_padding)

    buffer = base64.urlsafe_b64decode(keystring)

    # Extract checksum first, the last 4 bytes
    checksum = buffer[-4:]

    # Compute digest on the rest of the buffer
    m = hashlib.sha256()
    m.update(buffer[:-4])

    if checksum != m.digest()[0:4]:
        raise DecodingError("Checksums do not match")

    num_keys = struct.unpack('>H', buffer[0:2])[0]

    if num_keys != (len(buffer) - 6) / 2:
        raise DecodingError("Key length doesn't match expected value")

    keylist = []

    for i in range(0, num_keys):
        from_idx = 2 + i * 2
        to_idx = 2 + i * 2 + 2
        keylist.append(struct.unpack('>H', buffer[from_idx:to_idx])[0])

    return (num_keys, keylist)