proofyx / core_models /id_validators.py
abrar6024's picture
Add government ID proof-of-concept: Aadhaar/PAN/Voter ID number validation
01b17ea
Raw
History Blame Contribute Delete
5.81 kB
"""
Format/checksum validators for common Indian government ID numbers
(Aadhaar, PAN, Voter ID / EPIC).
Not OCR - the user types (or leaves blank) the ID number printed on the
document; this validates whether that string is well-formed for the
selected ID type. Two different levels of rigor depending on what's
actually publicly documented, and the module is explicit about which is
which rather than implying more confidence than exists:
- Aadhaar: UIDAI publishes that the 12th digit is a Verhoeff checksum
over the first 11 digits. The Verhoeff algorithm itself is a fixed,
public 1969 construction (not something that changes over time,
unlike the legal citations elsewhere in this project) - implemented
here in full, so an Aadhaar number that fails it is a genuine,
verifiable defect, not a heuristic guess.
- PAN / Voter ID (EPIC): the format (length, character classes) is
published, but neither the Income Tax Department (PAN) nor the
Election Commission (EPIC) publicly documents a check-digit
algorithm. Only format is verified for these two - the validators
say so explicitly in their `reason` text rather than implying a
checksum that doesn't exist.
This is one signal among several in a document analysis, not a
standalone authenticity determination - a well-formed, checksum-valid
number does not by itself prove the document is genuine (a fabricated
number can be constructed to pass), and a malformed one does not by
itself prove it's fake (a typo in manual entry is far more likely than
forgery). Combine with the forensic/AI-generation signals in
core/pipeline.py::analyze_document.
"""
from __future__ import annotations
import re
from typing import Optional
# ---- Verhoeff algorithm tables (fixed, public; RFC/ISO-style constants) ----
_D = [
[0, 1, 2, 3, 4, 5, 6, 7, 8, 9],
[1, 2, 3, 4, 0, 6, 7, 8, 9, 5],
[2, 3, 4, 0, 1, 7, 8, 9, 5, 6],
[3, 4, 0, 1, 2, 8, 9, 5, 6, 7],
[4, 0, 1, 2, 3, 9, 5, 6, 7, 8],
[5, 9, 8, 7, 6, 0, 4, 3, 2, 1],
[6, 5, 9, 8, 7, 1, 0, 4, 3, 2],
[7, 6, 5, 9, 8, 2, 1, 0, 4, 3],
[8, 7, 6, 5, 9, 3, 2, 1, 0, 4],
[9, 8, 7, 6, 5, 4, 3, 2, 1, 0],
]
_P = [
[0, 1, 2, 3, 4, 5, 6, 7, 8, 9],
[1, 5, 7, 6, 2, 8, 3, 0, 9, 4],
[5, 8, 0, 3, 7, 9, 6, 1, 4, 2],
[8, 9, 1, 6, 0, 4, 3, 5, 2, 7],
[9, 4, 5, 3, 1, 2, 6, 8, 7, 0],
[4, 2, 8, 6, 5, 7, 3, 9, 0, 1],
[2, 7, 9, 3, 8, 0, 6, 4, 1, 5],
[7, 0, 4, 6, 9, 1, 3, 2, 5, 8],
]
_INV = [0, 4, 3, 2, 1, 5, 6, 7, 8, 9]
def verhoeff_checksum_valid(number: str) -> bool:
"""Standard Verhoeff validation: True if the last digit is a valid
checksum for the digits preceding it."""
c = 0
for i, digit in enumerate(reversed(number)):
c = _D[c][_P[i % 8][int(digit)]]
return c == 0
def generate_verhoeff_checksum(number: str) -> str:
"""Compute the Verhoeff check digit for `number`. Used only by this
module's own tests to construct valid synthetic examples - never
called from the analysis pipeline."""
c = 0
for i, digit in enumerate(reversed(number)):
c = _D[c][_P[(i + 1) % 8][int(digit)]]
return str(_INV[c])
def validate_aadhaar(number: str) -> dict:
cleaned = re.sub(r"[\s-]", "", number or "")
if not re.fullmatch(r"[2-9][0-9]{11}", cleaned):
return {
"valid": False,
"reason": "Not a 12-digit Aadhaar number (must be 12 digits and not start with 0 or 1).",
}
if not verhoeff_checksum_valid(cleaned):
return {
"valid": False,
"reason": "12 digits, but fails the Verhoeff checksum UIDAI uses for Aadhaar numbers.",
}
return {
"valid": True,
"reason": "Well-formed 12-digit number that passes the Verhoeff checksum.",
}
def validate_pan(number: str) -> dict:
cleaned = (number or "").strip().upper()
if not re.fullmatch(r"[A-Z]{5}[0-9]{4}[A-Z]", cleaned):
return {
"valid": False,
"reason": "Not in the standard PAN format (5 letters, 4 digits, 1 letter).",
}
return {
"valid": True,
"reason": "Matches the standard PAN format. The check letter's generation algorithm is "
"not publicly documented by the Income Tax Department, so only format is "
"verified here — not a checksum.",
}
def validate_voter_id(number: str) -> dict:
cleaned = (number or "").strip().upper()
if not re.fullmatch(r"[A-Z]{3}[0-9]{7}", cleaned):
return {
"valid": False,
"reason": "Not in the standard EPIC/Voter ID format (3 letters, 7 digits).",
}
return {
"valid": True,
"reason": "Matches the standard EPIC format. Exact formats vary by state and issuance "
"era, and there is no publicly documented checksum — only format is verified here.",
}
VALIDATORS = {
"aadhaar": validate_aadhaar,
"pan": validate_pan,
"voter_id": validate_voter_id,
}
ID_LABELS = {
"aadhaar": "Aadhaar",
"pan": "PAN",
"voter_id": "Voter ID (EPIC)",
}
def validate_id_number(id_type: Optional[str], number: Optional[str]) -> Optional[dict]:
"""Returns None when there's nothing to validate (no type selected,
or no number entered) - the caller should skip this signal entirely
rather than render a false "valid": null case, since "not attempted"
and "attempted and inconclusive" are different things."""
if not id_type or id_type == "other" or not number or not number.strip():
return None
validator = VALIDATORS.get(id_type)
if validator is None:
return None
result = validator(number)
result["id_type"] = id_type
result["id_label"] = ID_LABELS.get(id_type, id_type)
return result