# -*- coding: utf-8 -*-
#------------------------------------------------------------------------------
# file: $Id$
# auth: Philip J Grabner <[email protected]>
# date: 2013/10/26
# copy: (C) Copyright 2013 Cadit Health Inc., All Rights Reserved.
#------------------------------------------------------------------------------
import re
import asset
import pkg_resources
from .i18n import _
#------------------------------------------------------------------------------
DEFAULT_INFLECT = 0.75
DEFAULT_WEIGHT = 1.0
DEFAULT_CLIPMIN = 0
DEFAULT_CLIPMAX = 1.3
DEFAULT_SKEW = 0
DEFAULT_SPREAD = 1.0
DEFAULT_FACTORS = 'length,charmix,variety,casemix,notword,phrase'
#------------------------------------------------------------------------------
common10k = frozenset(
asset.load('passwordmeter:res/common.txt').read().decode().split('\n'))
#------------------------------------------------------------------------------
def asym(value, target, switch=DEFAULT_INFLECT):
if value >= target:
return 1 - ( ( 1 - switch ) * target / value )
return switch * value / target
#------------------------------------------------------------------------------
def curve(value, offset=0.05):
if value < 0:
value = 0
return 1.0 / ( offset + value )
#------------------------------------------------------------------------------
def curveavg(values):
score = 0
weight = 0
for v in values:
w = curve(v)
score += v * w
weight += w
return score / weight
#------------------------------------------------------------------------------
class Factor(object):
def __init__(self, weight=DEFAULT_WEIGHT,
clipmin=DEFAULT_CLIPMIN, clipmax=DEFAULT_CLIPMAX,
skew=DEFAULT_SKEW, spread=DEFAULT_SPREAD,
category=None,
):
self.weight = float(weight)
self.clipmin = float(clipmin)
self.clipmax = float(clipmax)
self.skew = float(skew)
self.spread = float(spread)
if category is not None:
self.category = category
def test(self, value, extra):
raise NotImplementedError()
def adjust(self, value):
value = self.skew + self.spread * value
if value < self.clipmin:
value = self.clipmin
if value > self.clipmax:
value = self.clipmax
return value, self.weight
#------------------------------------------------------------------------------
class LengthFactor(Factor):
category = 'length'
def __init__(self, target=8, *args, **kw):
super(LengthFactor, self).__init__(*args, **kw)
self.target = int(target)
def test(self, value, extra):
value = len(value)
return (asym(value, self.target), _('Increase the length of the password'))
#------------------------------------------------------------------------------
class CharmixFactor(Factor):
category = 'charmix'
matchers = (
re.compile('[a-zA-Z]'), # alpha / TODO: what about non-ascii letters?
re.compile('[0-9]'), # numeric
re.compile('[^a-zA-Z0-9]'), # symbols
)
message = 'Use a good mix of numbers, letters, and symbols'
def test(self, value, extra):
if not self.matchers:
return 1.0
scores = [len(matcher.findall(value)) for matcher in self.matchers]
target = max(1, 0.25 * sum(scores) / len(scores))
scores = [asym(score, target) * 1 / DEFAULT_INFLECT for score in scores]
return (curveavg(scores), _(self.message))
#------------------------------------------------------------------------------
class CasemixFactor(CharmixFactor):
category = 'casemix'
matchers = (
re.compile('[a-z]'),
re.compile('[A-Z]'),
)
message = 'Use a good mix of UPPER case and lower case letters'
#------------------------------------------------------------------------------
class VarietyFactor(Factor):
category = 'variety'
def test(self, value, extra):
diff = 1.0
same = 0.0
for idx in range(1, len(value)):
if value[idx] == value[idx - 1]:
same += 1
else:
diff += 1
score = curveavg([
len(set(value)) / max(1, float(len(value))),
diff / (diff + same ** 1.5)])
return (score, _('Minimize character duplicates and repetitions'))
#------------------------------------------------------------------------------
class NotWordFactor(Factor):
category = 'notword'
def test(self, value, extra):
if value.lower() in common10k:
return (0, _('Avoid using one of the ten thousand most common passwords'))
# TODO: check against dictionary words too... maybe use ispell?
# http://code.activestate.com/recipes/117221-spell-checking/
# todo: check for 'l33t-speak'...
return (1.0, None)
#------------------------------------------------------------------------------
class PhraseFactor(Factor):
category = 'phrase'
base = 0.65
message = 'Passphrases (e.g. an obfuscated sentence) are better than passwords'
def test(self, value, extra):
# todo: this might be improved... for example, i should:
# - value words of different lengths (large distribution) -- check stddev
# - check the likelihood that the elements are in fact words
wlens = [len(s) for s in value.split()]
if not wlens:
return (0, _(self.message))
score = asym(len(wlens), 4, switch=self.base)
spread = max(wlens) - min(wlens)
if spread <= 0:
score = self.base
else:
score *= asym(spread, 4, switch=self.base)
score = self.base * ( 1 + score )
return (score, _(self.message))
#------------------------------------------------------------------------------
class Meter(object):
#----------------------------------------------------------------------------
def __init__(self, settings=None):
if settings is None:
settings = dict()
self.threshold = float(settings.get('threshold', DEFAULT_INFLECT))
factors = settings.get('factors') or DEFAULT_FACTORS
if asset.isstr(factors):
factors = [s.strip() for s in factors.split(',')]
for key in settings.keys():
key = key.split('.')
if len(key) >= 3 \
and key[0] == 'factor' and key[2] == 'class' \
and key[1] not in factors:
factors.append(key[1])
self.factors = [self._load(factor, settings) for factor in factors]
self.logger = asset.symbol(settings.get('logger'))
self.pessimism = 1.0 / float(settings.get('pessimism', 10))
#----------------------------------------------------------------------------
def _load(self, factor, settings):
predef = {
'length' : LengthFactor,
'charmix' : CharmixFactor,
'casemix' : CasemixFactor,
'variety' : VarietyFactor,
'notword' : NotWordFactor,
'phrase' : PhraseFactor,
}
params = dict()
if asset.isstr(factor):
for key, value in settings.items():
if key.startswith('factor.' + factor + '.'):
params[key[len(factor) + 8:]] = value
factor = params.pop('class', factor)
if factor in predef:
return predef[factor](**params)
return asset.symbol(factor)(**params)
#----------------------------------------------------------------------------
def test(self, value, extra=None):
'''
Tests the supplied `value` for general characteristics of a good
password. Returns a tuple of (float, dict), where float is a
quotient in the range [0, 1] representing very weak to very strong
passwords. dict, which may be None, is a list of known ways that
the password can be improved, where the key is a fixed value (from
the list below or from the settings in the constructor) and the
value is a human-readable (potentially internationalized) string.
The optional parameter `extra` can be any kind of object that is
passed through unaltered to all factors contributing to this
meter. This is primarily for custom :class:`Factor`
implementations that need extra information -- it is not used by
any of the standard factors.
'''
score = 0
weight = 0
morelist = []
for factor in self.factors:
result = factor.test(value, extra)
scr, wgt = factor.adjust(result[0])
crv = curve(scr, offset=self.pessimism)
if self.logger:
self.logger.debug(
'factor %s: score=%f, base-weight=%f, curve-weight=%f',
factor.category, scr, wgt, crv)
wgt *= crv
score += scr * wgt
weight += wgt
if result[1]:
morelist.append((scr, factor, result[1]))
score = max(0, min(1, score / weight))
more = dict()
if score < 1.0:
# todo: this should be a slightly more intelligent selection process
for scur, factor, reason in morelist:
if scur < self.threshold:
more[factor.category] = reason
return (score, more)
#------------------------------------------------------------------------------
def test(value, extra=None):
return Meter().test(value, extra=extra)
#------------------------------------------------------------------------------
# end of $Id$
#------------------------------------------------------------------------------