File size: 4,406 Bytes
0fe8382
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
"""Rule-based field validators for document fraud detection.

Pure standard library. Every validator returns (ok: bool, reason: str).
Rules encode publicly documented validity constraints:
  - SSN structure per SSA randomization (area 001-899 excluding 666,
    group 01-99, serial 0001-9999; 000/666/9xx areas, 00 groups and
    0000 serials have never been issued)
  - ICAO Doc 9303 TD3 (passport) MRZ check digits, weights 7-3-1
"""
import datetime as _dt
import re

_SSN_RE = re.compile(r"^(\d{3})-(\d{2})-(\d{4})$")


def validate_ssn(value):
    if value is None:
        return False, "missing"
    m = _SSN_RE.match(value.strip())
    if not m:
        return False, "malformed"
    area, group, serial = (int(x) for x in m.groups())
    if area == 0 or area == 666 or 900 <= area <= 999:
        return False, "invalid_area"
    if group == 0:
        return False, "invalid_group"
    if serial == 0:
        return False, "invalid_serial"
    return True, "ok"


_DATE_FORMATS = ("%m/%d/%Y", "%Y-%m-%d", "%d.%m.%Y", "%Y%m%d")


def parse_date(value):
    for fmt in _DATE_FORMATS:
        try:
            return _dt.datetime.strptime(value.strip(), fmt).date()
        except (ValueError, AttributeError, TypeError):
            continue
    return None


def validate_date(value, must_be_past=False, reference=None):
    d = parse_date(value) if isinstance(value, str) else None
    if d is None:
        return False, "unparseable"
    ref = reference or _dt.date.today()
    if must_be_past and d > ref:
        return False, "future_date"
    return True, "ok"


_NAME_RE = re.compile(r"^[A-Z][A-Z' -]{0,63}$", re.IGNORECASE)


def validate_name(value):
    if not value or not value.strip():
        return False, "missing"
    if not _NAME_RE.match(value.strip()):
        return False, "bad_characters"
    return True, "ok"


_MRZ_ALPHABET = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ"


def mrz_check_digit(field):
    total = 0
    for i, ch in enumerate(field):
        v = 0 if ch == "<" else _MRZ_ALPHABET.index(ch)
        total += v * (7, 3, 1)[i % 3]
    return str(total % 10)


def validate_mrz_line2(line):
    line = (line or "").strip()
    if len(line) != 44:
        return False, "bad_length"
    if not re.match(r"^[A-Z0-9<]{44}$", line):
        return False, "bad_characters"
    sex = line[20]
    if not (sex == "<" or sex in "MF"):
        return False, "bad_sex"
    for part, label in ((line[13:19], "dob"), (line[21:27], "exp")):
        if not re.match(r"^\d{6}$", part):
            return False, "bad_" + label + "_format"
        mm, dd = int(part[2:4]), int(part[4:6])
        if not (1 <= mm <= 12 and 1 <= dd <= 31):
            return False, "bad_" + label + "_value"
    composite = line[0:10] + line[13:20] + line[21:43]
    checks = (
        (9, line[0:9], "pno"),
        (19, line[13:19], "dob"),
        (27, line[21:27], "exp"),
        (42, line[28:42], "personal"),
        (43, composite, "composite"),
    )
    for pos, material, name in checks:
        if line[pos] != mrz_check_digit(material):
            return False, "check_digit_" + name
    return True, "ok"


def parse_date_any(value):
    return parse_date(value)


def validate_document(fields, reference=None):
    findings = []
    if "document_number" in fields:
        ok, why = validate_ssn(fields["document_number"])
        if not ok:
            findings.append("document_number:" + why)
    if "name" in fields:
        ok, why = validate_name(fields["name"])
        if not ok:
            findings.append("name:" + why)
    for key in ("date_of_birth", "issue_date", "expiry_date"):
        if key in fields:
            ok, why = validate_date(fields[key], reference=reference)
            if not ok:
                findings.append(key + ":" + why)
    ref = reference or _dt.date.today()
    dob = parse_date(fields.get("date_of_birth", ""))
    if dob and dob > ref:
        findings.append("dob_in_future")
    if dob and (ref - dob).days > 150 * 365:
        findings.append("implausible_age")
    issued = parse_date(fields.get("issue_date", ""))
    expiry = parse_date(fields.get("expiry_date", ""))
    if issued and expiry and expiry <= issued:
        findings.append("expiry_before_issue")
    if "mrz_line2" in fields:
        ok, why = validate_mrz_line2(fields["mrz_line2"])
        if not ok:
            findings.append("mrz:" + why)
    return findings