| """ |
| 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 |
|
|
| |
| _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 |
|
|