blob: c0f42c55c655f382e41ce3a8c35249bfecf75051 [file] [log] [blame]
#!/usr/bin/env python3
# OTP generator for RFC 2289 / OPIE
import sys
import os
import os.path
import stat
import getpass
import configparser
import random
import argparse
import hashlib
import subprocess
import platform
import re
OTP_PWD_FILE = '.otp'
DEFAULT_ALGO = 'otp-md5' # if the PWD file doesn't specify
def otp_path():
"Compute the full path to the OTP password storage file."
return os.path.expanduser(os.path.join('~', OTP_PWD_FILE))
def load_passwords():
"Load password from the storage file."
path = otp_path()
s = os.lstat(path)
except OSError:
return { }
if not stat.S_ISREG(s.st_mode):
print('ERROR: not a regular file:', path)
# Only user read/write. Nobody else.
if (s.st_mode & (stat.S_IRWXU | stat.S_IRWXG | stat.S_IRWXO)) != 0o600:
print('ERROR: should be mode 0600:', path)
pwds = { }
for l in open(path).readlines():
m = RE_STORAGE.match(l)
if m:
algo =
pwds[] = (, algo)
return pwds
RE_STORAGE = re.compile(r'((otp-\w+)\s+)?(\w+)\s+(.*)')
def add_password(seed, algo):
"Construct a new password for a seed, and store/append it."
pwd = new_password()
path = otp_path()
fd =, os.O_RDWR | os.O_APPEND | os.O_CREAT, mode=0o600)
os.write(fd, algo.encode() + b' ' + seed + b' ' + pwd + b'\n')
return pwd
def new_password():
"Generate a password."
# Stick to printable characters.
return ''.join(chr(random.randint(33, 126)) for _ in range(PASSWORD_LEN)).encode()
def otp_md5(begin, seq):
"Compute a 64-bit value through repeated hashing/folding."
value = md5_iter_fold(begin)
for _ in range(seq):
value = md5_iter_fold(value)
return value
def md5_iter_fold(value):
"Iterate the hash, then fold into 64 bits."
value = hashlib.md5(value).digest() # 128 bit result
return bytes(value[i] ^ value[i+8] for i in range(8))
def to_words(value):
"Convert a 64-bit value into words, with a parity value."
assert len(value) == 8 # 64 bits, in an 8-byte VALUE
# Parity is the summation of each 2-bit pair. We will mask
# out the lowest 2 bits, so no worries about higher bits.
parity = sum((b >> 6) + (b >> 4) + (b >> 2) + b for b in value)
# collapse the bytes into a single 64-bit integer; fold in parity bits
i64 = int.from_bytes(value, byteorder='big')
i64 = (i64 << 2) | (parity & 0x03)
# and map that integer into words, using the spec's WORDS
return [WORDS[(i64 >> (i*11)) & 0x7FF] for i in (5, 4, 3, 2, 1, 0)]
class RunTests(argparse.Action):
"Run some basic tests on the code."
def __call__(self, parser, namespace, values, option_string=None):
# Test the to_words() function.
value = b'\x9E\x87\x61\x34\xD9\x04\x99\xDD'
words = ' '.join(to_words(value))
assert words == MD5_TESTS[0][3]
print(' ', ' '.join('%02x%02x' % (value[i*2], value[i*2+1]) for i in range(4)), '--', words)
# Run the tests from RFC 2289.
print('COLUMNS:','Password', 'Seed', 'Count')
for pwd, seed, seq, result in MD5_TESTS:
value = otp_md5(seed.lower().encode() + pwd.encode(), seq)
print('P:'+pwd, 'S:'+seed, 'C:'+str(seq))
words = ' '.join(to_words(value))
print(' ', ' '.join('%02x%02x' % (value[i*2], value[i*2+1]) for i in range(4)), '--', words)
assert words == result
'otp-md4': None,
'otp-md5': otp_md5,
'otp-sha1': None,
def main():
cmd = os.path.basename(sys.argv[0])
if cmd in ALGOS:
# switch to USAGE:
# omit the prompt, and use argv
algo = cmd
seq = int(sys.argv[1])
seed = sys.argv[2].lower().encode()
# ignore any other argv
parser = argparse.ArgumentParser(description='Compute OTP strings.')
parser.add_argument('--test', help='Run the test suite.', nargs=0,
parser.add_argument('args', nargs=argparse.REMAINDER)
# Note: this may exit, if tests are run.
parsed = parser.parse_args()
parts = parsed.args
if len(parts) < 2: # the [ALGO] SEQUENCE SEED are not on cmdline. ask.
line = input('Challenge? ')
parts = line.split()
if len(parts) == 2:
# presumably: SEQUENCE SEED
algo = None
seq = int(parts[0])
seed = parts[1].lower().encode()
elif len(parts) >= 3:
algo = parts[0]
seq = int(parts[1])
seed = parts[2].lower().encode()
# ignore anything else on line (eg. "ext")
print('ERROR: challenge must have: [ALGO] SEQUENCE SEED ...')
# Load a dictionary mapping seeds to passwords.
pwds = load_passwords()
if seed not in pwds:
print('Creating new password for:', seed.decode())
if not algo:
print('NOTE: using "%s" algorithm' % (algo,))
pwd = add_password(seed, algo)
old_algo = algo
pwd, algo = pwds[seed]
if old_algo and algo != old_algo:
print('NOTE: switched to "%s" algorithm' % (algo,))
processor = ALGOS.get(algo)
assert processor, 'Unknown/unsupported algorithm: "%s"' % (algo,)
value = processor(seed + pwd, seq)
response = ' '.join(to_words(value))
print('Response:', response)
osname = platform.system()
# Attempt to push this to the clipboard
if osname == 'Darwin':
result =['pbcopy', '-pboard', 'general'], input=response.encode())
result =['xclip', '-selection', 'clipboard'], input=response.encode())
if result.returncode == 0:
print('NOTE: copied to clipboard')
# From RFC 2289, Appendix D.
"A", "ABE", "ACE", "ACT", "AD", "ADA", "ADD",
"AGO", "AID", "AIM", "AIR", "ALL", "ALP", "AM", "AMY",
"AN", "ANA", "AND", "ANN", "ANT", "ANY", "APE", "APS",
"APT", "ARC", "ARE", "ARK", "ARM", "ART", "AS", "ASH",
"ASK", "AT", "ATE", "AUG", "AUK", "AVE", "AWE", "AWK",
"AWL", "AWN", "AX", "AYE", "BAD", "BAG", "BAH", "BAM",
"BAN", "BAR", "BAT", "BAY", "BE", "BED", "BEE", "BEG",
"BEN", "BET", "BEY", "BIB", "BID", "BIG", "BIN", "BIT",
"BOB", "BOG", "BON", "BOO", "BOP", "BOW", "BOY", "BUB",
"BUD", "BUG", "BUM", "BUN", "BUS", "BUT", "BUY", "BY",
"BYE", "CAB", "CAL", "CAM", "CAN", "CAP", "CAR", "CAT",
"CAW", "COD", "COG", "COL", "CON", "COO", "COP", "COT",
"COW", "COY", "CRY", "CUB", "CUE", "CUP", "CUR", "CUT",
"DAB", "DAD", "DAM", "DAN", "DAR", "DAY", "DEE", "DEL",
"DEN", "DES", "DEW", "DID", "DIE", "DIG", "DIN", "DIP",
"DO", "DOE", "DOG", "DON", "DOT", "DOW", "DRY", "DUB",
"DUD", "DUE", "DUG", "DUN", "EAR", "EAT", "ED", "EEL",
"EGG", "EGO", "ELI", "ELK", "ELM", "ELY", "EM", "END",
"EST", "ETC", "EVA", "EVE", "EWE", "EYE", "FAD", "FAN",
"FAR", "FAT", "FAY", "FED", "FEE", "FEW", "FIB", "FIG",
"FIN", "FIR", "FIT", "FLO", "FLY", "FOE", "FOG", "FOR",
"FRY", "FUM", "FUN", "FUR", "GAB", "GAD", "GAG", "GAL",
"GAM", "GAP", "GAS", "GAY", "GEE", "GEL", "GEM", "GET",
"GIG", "GIL", "GIN", "GO", "GOT", "GUM", "GUN", "GUS",
"GUT", "GUY", "GYM", "GYP", "HA", "HAD", "HAL", "HAM",
"HAN", "HAP", "HAS", "HAT", "HAW", "HAY", "HE", "HEM",
"HEN", "HER", "HEW", "HEY", "HI", "HID", "HIM", "HIP",
"HIS", "HIT", "HO", "HOB", "HOC", "HOE", "HOG", "HOP",
"HOT", "HOW", "HUB", "HUE", "HUG", "HUH", "HUM", "HUT",
"I", "ICY", "IDA", "IF", "IKE", "ILL", "INK", "INN",
"IO", "ION", "IQ", "IRA", "IRE", "IRK", "IS", "IT",
"ITS", "IVY", "JAB", "JAG", "JAM", "JAN", "JAR", "JAW",
"JAY", "JET", "JIG", "JIM", "JO", "JOB", "JOE", "JOG",
"JOT", "JOY", "JUG", "JUT", "KAY", "KEG", "KEN", "KEY",
"KID", "KIM", "KIN", "KIT", "LA", "LAB", "LAC", "LAD",
"LAG", "LAM", "LAP", "LAW", "LAY", "LEA", "LED", "LEE",
"LEG", "LEN", "LEO", "LET", "LEW", "LID", "LIE", "LIN",
"LIP", "LIT", "LO", "LOB", "LOG", "LOP", "LOS", "LOT",
"LOU", "LOW", "LOY", "LUG", "LYE", "MA", "MAC", "MAD",
"MAE", "MAN", "MAO", "MAP", "MAT", "MAW", "MAY", "ME",
"MEG", "MEL", "MEN", "MET", "MEW", "MID", "MIN", "MIT",
"MOB", "MOD", "MOE", "MOO", "MOP", "MOS", "MOT", "MOW",
"MUD", "MUG", "MUM", "MY", "NAB", "NAG", "NAN", "NAP",
"NAT", "NAY", "NE", "NED", "NEE", "NET", "NEW", "NIB",
"NIL", "NIP", "NIT", "NO", "NOB", "NOD", "NON", "NOR",
"NOT", "NOV", "NOW", "NU", "NUN", "NUT", "O", "OAF",
"OAK", "OAR", "OAT", "ODD", "ODE", "OF", "OFF", "OFT",
"OH", "OIL", "OK", "OLD", "ON", "ONE", "OR", "ORB",
"ORE", "ORR", "OS", "OTT", "OUR", "OUT", "OVA", "OW",
"OWE", "OWL", "OWN", "OX", "PA", "PAD", "PAL", "PAM",
"PAN", "PAP", "PAR", "PAT", "PAW", "PAY", "PEA", "PEG",
"PEN", "PEP", "PER", "PET", "PEW", "PHI", "PI", "PIE",
"PIN", "PIT", "PLY", "PO", "POD", "POE", "POP", "POT",
"POW", "PRO", "PRY", "PUB", "PUG", "PUN", "PUP", "PUT",
"QUO", "RAG", "RAM", "RAN", "RAP", "RAT", "RAW", "RAY",
"REB", "RED", "REP", "RET", "RIB", "RID", "RIG", "RIM",
"RIO", "RIP", "ROB", "ROD", "ROE", "RON", "ROT", "ROW",
"ROY", "RUB", "RUE", "RUG", "RUM", "RUN", "RYE", "SAC",
"SAD", "SAG", "SAL", "SAM", "SAN", "SAP", "SAT", "SAW",
"SAY", "SEA", "SEC", "SEE", "SEN", "SET", "SEW", "SHE",
"SHY", "SIN", "SIP", "SIR", "SIS", "SIT", "SKI", "SKY",
"SLY", "SO", "SOB", "SOD", "SON", "SOP", "SOW", "SOY",
"SPA", "SPY", "SUB", "SUD", "SUE", "SUM", "SUN", "SUP",
"TAB", "TAD", "TAG", "TAN", "TAP", "TAR", "TEA", "TED",
"TEE", "TEN", "THE", "THY", "TIC", "TIE", "TIM", "TIN",
"TIP", "TO", "TOE", "TOG", "TOM", "TON", "TOO", "TOP",
"TOW", "TOY", "TRY", "TUB", "TUG", "TUM", "TUN", "TWO",
"UN", "UP", "US", "USE", "VAN", "VAT", "VET", "VIE",
"WAD", "WAG", "WAR", "WAS", "WAY", "WE", "WEB", "WED",
"WEE", "WET", "WHO", "WHY", "WIN", "WIT", "WOK", "WON",
"WOO", "WOW", "WRY", "WU", "YAM", "YAP", "YAW", "YE",
"YEA", "YES", "YET", "YOU", "ABED", "ABEL", "ABET", "ABLE",
# From RFC 2289, Appendix C.
("This is a test.", "TeSt", 0, "INCH SEA ANNE LONG AHEM TOUR"),
("This is a test.", "TeSt", 1, "EASE OIL FUM CURE AWRY AVIS"),
("This is a test.", "TeSt", 99, "BAIL TUFT BITS GANG CHEF THY"),
("AbCdEfGhIjK", "alpha1", 0, "FULL PEW DOWN ONCE MORT ARC"),
("AbCdEfGhIjK", "alpha1", 1, "FACT HOOF AT FIST SITE KENT"),
("AbCdEfGhIjK", "alpha1", 99, "BODE HOP JAKE STOW JUT RAP"),
("OTP's are good", "correct", 0, "ULAN NEW ARMY FUSE SUIT EYED"),
("OTP's are good", "correct", 1, "SKIM CULT LOB SLAM POE HOWL"),
("OTP's are good", "correct", 99, "LONG IVY JULY AJAR BOND LEE"),
if __name__ == '__main__':