Source code for securid.stoken

#!/usr/bin/env python

import os
import os.path
import math
from datetime import date
from typing import Union, Optional
from .token import SERIAL_LENGTH, Token, AbstractTokenFile
from .utils import AES_KEY_SIZE, Bytes, aes_ecb_encrypt, aes_ecb_decrypt, Bytearray
from .exceptions import (
    ParseException,
    InvalidToken,
    InvalidSignature,
    InvalidSeed,
    InvalidSerial,
)

__all__ = ['DEFAULT_STOKEN_FILENAME', 'StokenFile']

DEFAULT_STOKEN_FILENAME = '~/.stokenrc'
FL_128BIT = 1 << 14
FL_PASSPROT = 1 << 13
FL_SNPROT = 1 << 12
FL_APPSEEDS = 1 << 11
FL_FEAT4 = 1 << 10
FL_TIMESEEDS = 1 << 9
FLD_DIGIT_SHIFT = 6
FLD_DIGIT_MASK = 0x07 << FLD_DIGIT_SHIFT
FLD_PINMODE_SHIFT = 3
FLD_PINMODE_MASK = 0x03 << FLD_PINMODE_SHIFT
FLD_NUMSECONDS_SHIFT = 0
FLD_NUMSECONDS_MASK = 0x03 << FLD_NUMSECONDS_SHIFT
TOKEN_BITS_PER_CHAR = 3
STOKEN_MAGIC = bytes([0xD8, 0xF5, 0x32, 0x53, 0x82, 0x89])
VER_LENGTH = 1
CHECKSUM_BITS = 15
CHECKSUM_LENGTH = int(CHECKSUM_BITS / TOKEN_BITS_PER_CHAR)
BINENC_BITS = 189
BINENC_OFS = VER_LENGTH + SERIAL_LENGTH
MIN_TOKEN_BITS = 189
MAX_TOKEN_BITS = 255
MAX_TOKEN_LENGTH = int(MAX_TOKEN_BITS / TOKEN_BITS_PER_CHAR)
MIN_TOKEN_LENGTH = int(
    (MIN_TOKEN_BITS / TOKEN_BITS_PER_CHAR)
    + SERIAL_LENGTH
    + VER_LENGTH
    + CHECKSUM_LENGTH
)
SECURID_EPOCH = 730120  # 2000/01/01 proleptic Gregorian ordinal


[docs]class StokenFile(AbstractTokenFile): """ Handler for stokenrc file format """ filename: Optional[str] data: Optional[bytes] token: Token def __init__( self, filename: Optional[str] = DEFAULT_STOKEN_FILENAME, data: Union[Optional[bytes], Optional[bytearray], Optional[str]] = None, token: Optional[Token] = None, ) -> None: """ :param filename: stokenrc file path :param data: token as string in stokenrc format :param token: Token instance """ if token is not None: self.filename = None self.token = token elif data is not None: if isinstance(data, str): data = bytes(data, 'ascii') self.filename = None self.data = data self.token = self.v2_decode_token(self.data) elif filename is not None: self.filename = os.path.expanduser(filename) self.data = self.parse_file(self.filename) self.token = self.v2_decode_token(self.data) self.token.pin = self.parse_file_pin(self.filename)
[docs] @classmethod def parse_file(cls, filename: str) -> bytes: """ Parse stokenrc file, return token as string :param filename: stokenrc file path """ with open(filename, 'rb') as f: for line in f.readlines(): line = line.strip() if b' ' in line: k, v = line.split(b' ', 1) if k == b'token': return v raise ParseException('Error parsing {}'.format(filename))
[docs] @classmethod def parse_file_pin(cls, filename: str) -> int: """ Parse stokenrc file, return pin as int or 0 if not found :param filename: stokenrc file path """ with open(filename, 'rb') as f: for line in f.readlines(): line = line.strip() if b' ' in line: k, v = line.split(b' ', 1) if k == b'pin': return int(v) return 0
@classmethod def v2_decode_token(cls, data: Bytes) -> Token: if len(data) < MIN_TOKEN_LENGTH or len(data) > MAX_TOKEN_LENGTH: raise InvalidToken('Invalid token length') cls._verify_checksum(data) # version = data[0] - ord('0') d = cls._numinput_to_bits(data, BINENC_BITS, offset=BINENC_OFS) enc_seed = d[0:AES_KEY_SIZE] flags = cls._get_bits(d, 128, 16) seed_hash = cls._get_bits(d, 159, 15) exp_date = date.fromordinal(cls._get_bits(d, 144, 14) + SECURID_EPOCH) serial = str(data[VER_LENGTH : VER_LENGTH + SERIAL_LENGTH], 'ascii') interval = 60 if ((flags & FLD_NUMSECONDS_MASK) >> FLD_NUMSECONDS_SHIFT) else 30 digits = ((flags & FLD_DIGIT_MASK) >> FLD_DIGIT_SHIFT) + 1 seed = cls.v2_decrypt_seed(enc_seed, seed_hash) return Token( serial=serial, seed=seed, interval=interval, digits=digits, exp_date=exp_date, ) def v2_encode_token(self) -> None: if not self.token.seed: raise InvalidSeed('Missing seed') if not self.token.serial: raise InvalidSerial('Missing serial') if len(self.token.serial) != SERIAL_LENGTH: raise InvalidSerial('Serial length != {}'.format(SERIAL_LENGTH)) flags = FL_TIMESEEDS | FL_128BIT flags |= (self.token.digits - 1 << FLD_DIGIT_SHIFT) & FLD_DIGIT_MASK flags |= (1 << FLD_NUMSECONDS_SHIFT) if self.token.interval == 60 else 0 key_hash = self._securid_mac(STOKEN_MAGIC) enc_seed = aes_ecb_encrypt(key_hash, self.token.seed) seed_hash = self._short_hash(self._securid_mac(self.token.seed)) d = Bytearray(int(MAX_TOKEN_BITS / 8 + 2)) d.arraycpy(enc_seed, n=AES_KEY_SIZE, dest_offset=0) self._set_bits(d, 128, 16, flags) self._set_bits(d, 159, 15, seed_hash) self._set_bits( d, 144, 14, (self.token.exp_date.toordinal() - SECURID_EPOCH) if self.token.exp_date else 0, ) data = Bytearray(81) data.arraycpy(b'2', dest_offset=0) # version data.arraycpy(self._bits_to_numoutput(d, BINENC_BITS), dest_offset=BINENC_OFS) data.arraycpy(self.token.serial, n=SERIAL_LENGTH, dest_offset=VER_LENGTH) d = Bytearray(int(MAX_TOKEN_BITS / 8 + 2)) computed_mac = self._securid_shortmac(data[: len(data) - CHECKSUM_LENGTH]) self._set_bits(d, 0, 15, computed_mac) t = self._bits_to_numoutput(d, 15) data.arraycpy(t, dest_offset=len(data) - CHECKSUM_LENGTH) self._verify_checksum(data) self.data = bytes(data) @classmethod def _verify_checksum(cls, data: Bytes) -> None: d = cls._numinput_to_bits(data, 15, offset=len(data) - CHECKSUM_LENGTH) token_mac = cls._get_bits(d, 0, 15) computed_mac = cls._securid_shortmac(data[: len(data) - CHECKSUM_LENGTH]) if token_mac != computed_mac: raise InvalidSignature('Invalid checksum') @classmethod def _numinput_to_bits(cls, data: Bytes, n_bits: int, offset: int = 0) -> bytes: bitpos = 13 out = bytearray(int(MAX_TOKEN_BITS / 8 + 2)) pos = 0 for t in data[offset:]: decoded = (t - ord('0')) & 0x07 decoded = decoded << bitpos out[0 + pos] = out[0 + pos] | decoded >> 8 out[1 + pos] = out[1 + pos] | decoded & 0xFF bitpos = bitpos - TOKEN_BITS_PER_CHAR if bitpos < 0: bitpos = bitpos + 8 pos = pos + 1 n_bits = n_bits - TOKEN_BITS_PER_CHAR if not n_bits: break return bytes(out) @classmethod def _bits_to_numoutput(cls, data: Bytes, n_bits: int) -> bytes: bitpos = 13 out = bytearray() pos = 0 for i in range(n_bits, 0, -TOKEN_BITS_PER_CHAR): binary = (data[pos] << 8) | data[pos + 1] out.append(((binary >> bitpos) & 0x07) + ord('0')) bitpos -= TOKEN_BITS_PER_CHAR if bitpos < 0: bitpos = bitpos + 8 pos = pos + 1 return bytes(out) @classmethod def _get_bits(cls, data: Bytes, start: int, n_bits: int) -> int: pos = int(math.floor(start / 8)) start = start % 8 val = 0 for i in range(n_bits, 0, -1): val = val << 1 if (data[pos] << start) & 0x80: val = val | 0x01 start = start + 1 if start == 8: start = 0 pos = pos + 1 return val @classmethod def _set_bits(cls, out: bytearray, start: int, n_bits: int, val: int) -> None: pos = int(math.floor(start / 8)) start = start % 8 val = val << (32 - n_bits) for i in range(n_bits, 0, -1): if val & (1 << 31): out[pos] = out[pos] | (1 << (7 - start)) else: out[pos] = out[pos] & ~(1 << (7 - start)) val = val << 1 start = start + 1 if start == 8: start = 0 pos = pos + 1 @classmethod def _encrypt_then_xor(cls, key: Bytes, work: bytes) -> bytes: out = aes_ecb_encrypt(key, work) return bytes([a ^ b for a, b in zip(work, out)]) @classmethod def _securid_mac(cls, data: Bytes) -> bytes: # padding pad = bytearray(AES_KEY_SIZE) p = AES_KEY_SIZE - 1 i = len(data) * 8 while i > 0: pad[p] = i % 256 p = p - 1 i = i >> 8 # handle the bulk of the input data here odd = False t = data work = bytes([0xFF] * AES_KEY_SIZE) while len(t) > AES_KEY_SIZE: work = cls._encrypt_then_xor(t[:AES_KEY_SIZE], work) t = t[AES_KEY_SIZE:] odd = not odd # final 0-16 bytes of input data work = cls._encrypt_then_xor(t + bytes(AES_KEY_SIZE - len(t)), work) # hash an extra block of zeroes, for certain input lengths if odd: zero = bytearray(AES_KEY_SIZE) work = cls._encrypt_then_xor(zero, work) # always hash the padding work = cls._encrypt_then_xor(pad, work) # run hash over current hash value, then return return cls._encrypt_then_xor(work, work) @classmethod def _short_hash(cls, hash_: Bytes) -> int: return (hash_[0] << 7) | (hash_[1] >> 1) @classmethod def _securid_shortmac(cls, data: Bytes) -> int: return cls._short_hash(cls._securid_mac(data)) @classmethod def v2_decrypt_seed(cls, enc_seed: Bytes, seed_hash: int) -> bytes: key_hash = cls._securid_mac(STOKEN_MAGIC) seed = aes_ecb_decrypt(key_hash, enc_seed) calc_seed_hash = cls._short_hash(cls._securid_mac(seed)) if calc_seed_hash != seed_hash: raise InvalidSignature('Seed decryption failed') return seed
[docs] def get_token(self, password: Optional[str] = None) -> Token: """ Return the Token instance """ return self.token