Store Aadhaar encrypted with masked display and hash-based search

This commit is contained in:
DevSoftTech Server 2026-08-15 03:16:40 +05:30
parent 00b949ad0a
commit 2d13ba567e
9 changed files with 292 additions and 15 deletions

View File

@ -314,7 +314,13 @@ class DoctorDocument extends BaseController
$likeToken = $this->db->escapeLikeString($token);
if (is_numeric($token)) {
$whereParts[] = "(p.p_code LIKE '%{$likeToken}' ESCAPE '!' OR p.mphone1 = {$escaped} OR p.udai = {$escaped})";
$clause = "(p.p_code LIKE '%{$likeToken}' ESCAPE '!' OR p.mphone1 = {$escaped}";
// Aadhaar is stored encrypted; match on its deterministic hash instead.
$aadhaarHash = (new \App\Libraries\AadhaarVaultService())->hash($token);
if ($aadhaarHash !== '') {
$clause .= ' OR p.udai_hash = ' . $this->db->escape($aadhaarHash);
}
$whereParts[] = $clause . ')';
continue;
}

View File

@ -491,7 +491,7 @@ class Medical extends BaseController
if ($search !== '') {
$tokens = preg_split('/\s+/', $search) ?: [];
$searchableFields = [];
foreach (['p_code', 'p_fname', 'mphone1', 'udai', 'email1'] as $field) {
foreach (['p_code', 'p_fname', 'mphone1', 'email1'] as $field) {
if (in_array($field, $fields, true)) {
$searchableFields[] = $field;
}

View File

@ -144,9 +144,9 @@ class Patient extends BaseController
'p_relative' => $this->request->getPost('cbo_relation'),
'p_rname' => strtoupper((string) $this->request->getPost('input_relative_name')),
'blood_group' => strtoupper($bloodGroup),
'udai' => strtoupper((string) $this->request->getPost('input_udai')),
'estimate_dob' => $estimate_dob,
];
$data += (new \App\Libraries\AadhaarVaultService())->buildColumns((string) $this->request->getPost('input_udai'));
$this->applyPatientReferbyField($data, $referByName);
$this->applyPatientAbhaFieldValues($data, $abhaId, $abhaAddress);
@ -234,7 +234,7 @@ class Patient extends BaseController
$abhaField = $this->resolvePatientAbhaIdField();
$builder = $this->db->table('patient_master');
$builder->select("id,p_fname,p_relative,p_rname,gender,add1,city,district,state,zip,mphone1,udai,last_visit,dob,age,age_in_month,estimate_dob");
$builder->select("id,p_fname,p_relative,p_rname,gender,add1,city,district,state,zip,mphone1,udai,udai_last4,last_visit,dob,age,age_in_month,estimate_dob");
if ($this->db->fieldExists('referby', 'patient_master')) {
$builder->select('referby');
}
@ -252,7 +252,8 @@ class Patient extends BaseController
}
if ($inputAadhar !== '') {
$builder->orWhere('udai', $inputAadhar);
// Aadhaar is stored encrypted, so match on its deterministic hash.
$builder->orWhere('udai_hash', (new \App\Libraries\AadhaarVaultService())->hash($inputAadhar));
$hasCondition = true;
}
@ -528,9 +529,14 @@ class Patient extends BaseController
$clauses = [
'p.p_code like ' . $this->db->escape('%' . $rowData),
'p.mphone1 = ' . $escapedValue,
'p.udai = ' . $escapedValue,
];
// Aadhaar lives in the vault, so a typed number matches via its hash.
$aadhaarHash = (new \App\Libraries\AadhaarVaultService())->hash($rowData);
if ($aadhaarHash !== '') {
$clauses[] = 'p.udai_hash = ' . $this->db->escape($aadhaarHash);
}
if ($abhaField !== null && $abhaField !== '') {
$clauses[] = 'p.' . $abhaField . ' = ' . $escapedValue;
}
@ -995,10 +1001,14 @@ class Patient extends BaseController
'p_relative' => strtoupper((string) $this->request->getPost('cbo_relation')),
'p_rname' => strtoupper((string) $this->request->getPost('input_relative_name')),
'email1' => strtoupper((string) $this->request->getPost('input_email')),
'udai' => strtoupper((string) $this->request->getPost('input_Aadhar')),
'estimate_dob' => $estimate_dob,
'blood_group' => $this->request->getPost('input_blood_group'),
];
$aadhaarVault = new \App\Libraries\AadhaarVaultService();
$aadhaarInput = (string) $this->request->getPost('input_Aadhar');
if (! $aadhaarVault->isMasked($aadhaarInput)) {
$data += $aadhaarVault->buildColumns($aadhaarInput);
}
$this->applyPatientReferbyField($data, $referByName);
if ($isAbhaVerifiedLocked) {
@ -1101,8 +1111,16 @@ class Patient extends BaseController
$pid = (int) $this->request->getPost('p_id');
$udai = (string) $this->request->getPost('udai');
$vault = new \App\Libraries\AadhaarVaultService();
if ($vault->isMasked($udai)) {
return $this->response->setJSON(['update' => 1, 'showcontent' => 'Aadhaar unchanged']);
}
if (trim($udai) !== '' && $vault->normalize($udai) === '') {
return $this->response->setJSON(['update' => 0, 'error_text' => 'Enter a valid 12-digit Aadhaar number']);
}
$patientModel = new PatientModel();
$patientModel->updatePatient(['udai' => $udai], $pid);
$patientModel->updatePatient($vault->buildColumns($udai), $pid);
return $this->response->setJSON([
'update' => 1,

View File

@ -0,0 +1,95 @@
<?php
namespace App\Database\Migrations;
use App\Libraries\AadhaarVaultService;
use CodeIgniter\Database\Migration;
class AddAadhaarVaultToPatientMaster extends Migration
{
public function up(): void
{
if (! $this->db->tableExists('patient_master')) {
return;
}
$fields = $this->db->getFieldNames('patient_master') ?? [];
$add = [];
if (! in_array('udai_enc', $fields, true)) {
$add['udai_enc'] = ['type' => 'TEXT', 'null' => true, 'comment' => 'AES-256-GCM encrypted Aadhaar'];
}
if (! in_array('udai_hash', $fields, true)) {
$add['udai_hash'] = ['type' => 'CHAR', 'constraint' => 64, 'null' => true, 'comment' => 'HMAC-SHA256 of Aadhaar for exact-match search'];
}
if (! in_array('udai_last4', $fields, true)) {
$add['udai_last4'] = ['type' => 'VARCHAR', 'constraint' => 4, 'null' => true, 'comment' => 'Last 4 digits for display'];
}
if ($add !== []) {
$this->forge->addColumn('patient_master', $add);
}
if (! $this->indexExists('patient_master', 'idx_patient_udai_hash')) {
$this->db->query('CREATE INDEX idx_patient_udai_hash ON patient_master (udai_hash)');
}
$this->migrateExistingAadhaar();
}
public function down(): void
{
if (! $this->db->tableExists('patient_master')) {
return;
}
if ($this->indexExists('patient_master', 'idx_patient_udai_hash')) {
$this->db->query('DROP INDEX idx_patient_udai_hash ON patient_master');
}
$fields = $this->db->getFieldNames('patient_master') ?? [];
foreach (['udai_enc', 'udai_hash', 'udai_last4'] as $column) {
if (in_array($column, $fields, true)) {
$this->forge->dropColumn('patient_master', $column);
}
}
}
/**
* Move any plaintext Aadhaar already in udai into the vault and mask the column.
*/
private function migrateExistingAadhaar(): void
{
$vault = new AadhaarVaultService();
$rows = $this->db->table('patient_master')
->select('id, udai')
->where('udai IS NOT NULL', null, false)
->where("TRIM(udai) <> ''", null, false)
->where('udai_enc IS NULL', null, false)
->get()
->getResultArray();
foreach ($rows as $row) {
$digits = $vault->normalize((string) ($row['udai'] ?? ''));
if ($digits === '') {
continue;
}
$this->db->table('patient_master')
->where('id', (int) $row['id'])
->update($vault->buildColumns($digits));
}
}
private function indexExists(string $table, string $indexName): bool
{
foreach ($this->db->query('SHOW INDEX FROM ' . $table)->getResultArray() as $row) {
if (($row['Key_name'] ?? '') === $indexName) {
return true;
}
}
return false;
}
}

View File

@ -0,0 +1,151 @@
<?php
namespace App\Libraries;
/**
* Aadhaar Vault
*
* The Aadhaar Act forbids storing the full Aadhaar number in readable form, so
* patient_master keeps only a masked value while the number itself is held
* encrypted (AES-256-GCM) alongside a deterministic HMAC used for lookups.
*
* Key configuration — add to .env:
* AADHAAR_ENCRYPTION_KEY = <64-char lowercase hex string — 32 random bytes>
*
* Generate with:
* php -r "echo bin2hex(random_bytes(32)) . PHP_EOL;"
*/
class AadhaarVaultService
{
private const CIPHER = 'aes-256-gcm';
private const IV_LEN = 12;
private const TAG_LEN = 16;
private string $key;
public function __construct()
{
$keyHex = trim((string) getenv('AADHAAR_ENCRYPTION_KEY'));
if ($keyHex === '') {
$keyHex = trim((string) env('AADHAAR_ENCRYPTION_KEY', ''));
}
if ($keyHex === '') {
$keyHex = trim((string) getenv('ABDM_FHIR_ENCRYPTION_KEY'));
}
if ($keyHex !== '' && ctype_xdigit($keyHex) && strlen($keyHex) >= 64) {
$this->key = (string) hex2bin(substr($keyHex, 0, 64));
} else {
$appKey = trim((string) env('encryption.key', (string) env('app.key', '')));
$this->key = hash_hmac('sha256', 'hms-aadhaar-vault-v1', $appKey, true);
}
}
/**
* Strip formatting and return the 12 digits, or '' when not a valid Aadhaar.
*/
public function normalize(?string $raw): string
{
$digits = preg_replace('/\D/', '', (string) $raw) ?? '';
return strlen($digits) === 12 ? $digits : '';
}
public function last4(?string $raw): string
{
$digits = $this->normalize($raw);
return $digits === '' ? '' : substr($digits, -4);
}
/**
* Display form kept in patient_master.udai so legacy screens cannot leak the number.
*/
public function mask(?string $raw): string
{
$last4 = $this->last4($raw);
return $last4 === '' ? '' : 'XXXXXXXX' . $last4;
}
/**
* Deterministic keyed digest so an exact-match search stays possible.
*/
public function hash(?string $raw): string
{
$digits = $this->normalize($raw);
return $digits === '' ? '' : hash_hmac('sha256', $digits, $this->key);
}
public function encrypt(?string $raw): string
{
$digits = $this->normalize($raw);
if ($digits === '') {
return '';
}
$iv = random_bytes(self::IV_LEN);
$tag = '';
$ciphertext = openssl_encrypt($digits, self::CIPHER, $this->key, OPENSSL_RAW_DATA, $iv, $tag, '', self::TAG_LEN);
if ($ciphertext === false) {
throw new \RuntimeException('Aadhaar encryption failed.');
}
return base64_encode($iv . $tag . $ciphertext);
}
/**
* Returns '' when the payload is empty or cannot be authenticated.
*/
public function decrypt(?string $payload): string
{
$payload = trim((string) $payload);
if ($payload === '') {
return '';
}
$binary = base64_decode($payload, true);
if ($binary === false || strlen($binary) <= self::IV_LEN + self::TAG_LEN) {
return '';
}
$iv = substr($binary, 0, self::IV_LEN);
$tag = substr($binary, self::IV_LEN, self::TAG_LEN);
$ciphertext = substr($binary, self::IV_LEN + self::TAG_LEN);
$plain = openssl_decrypt($ciphertext, self::CIPHER, $this->key, OPENSSL_RAW_DATA, $iv, $tag);
return $plain === false ? '' : $plain;
}
/**
* True when the value is the masked form we render back into edit forms,
* which must never be treated as a new Aadhaar number.
*/
public function isMasked(?string $value): bool
{
return preg_match('/^X{8}\d{4}$/i', trim((string) $value)) === 1;
}
/**
* Column values for a patient_master insert/update.
*
* @return array{udai:string,udai_enc:string,udai_hash:string,udai_last4:string}
*/
public function buildColumns(?string $raw): array
{
$digits = $this->normalize($raw);
if ($digits === '') {
return ['udai' => '', 'udai_enc' => '', 'udai_hash' => '', 'udai_last4' => ''];
}
return [
'udai' => $this->mask($digits),
'udai_enc' => $this->encrypt($digits),
'udai_hash' => $this->hash($digits),
'udai_last4' => $this->last4($digits),
];
}
}

View File

@ -95,9 +95,11 @@
<div class="col-md-3">
<div class="form-group">
<label>Aadhar No.</label>
<?php $aadhaarLast4 = trim((string) ($data[0]->udai_last4 ?? '')); ?>
<input class="form-control input-sm" name="input_Aadhar" id="input_Aadhar"
value="<?=$data[0]->udai ?>" placeholder="Aadhar Number" type="text"
value="<?= $aadhaarLast4 !== '' ? 'XXXXXXXX' . esc($aadhaarLast4) : '' ?>" placeholder="Aadhar Number" type="text"
autocomplete="off">
<small class="text-muted">Stored encrypted. Leave masked value to keep it unchanged.</small>
</div>
</div>
<div class="col-md-3">

View File

@ -140,7 +140,10 @@
</div>
<div class="row">
<div class="col-lg-3 col-md-4 label">Aadhar No.</div>
<div class="col-lg-9 col-md-8"><?=$data[0]->udai?></div>
<div class="col-lg-9 col-md-8">
<?php $aadhaarLast4 = trim((string) ($data[0]->udai_last4 ?? '')); ?>
<?= $aadhaarLast4 !== '' ? 'XXXX-XXXX-' . esc($aadhaarLast4) : 'Not available' ?>
</div>
</div>
<div class="row">
<div class="col-lg-3 col-md-4 label">ABHA ID</div>
@ -253,7 +256,7 @@
<div class="col-lg-6">
<label class="form-label">Aadhar No.</label>
<div class="input-group input-group-sm">
<input class="form-control" type="text" name="input_Aadhar" id="input_Aadhar" value="<?=$data[0]->udai ?>">
<input class="form-control" type="text" name="input_Aadhar" id="input_Aadhar" value="<?= trim((string) ($data[0]->udai_last4 ?? '')) !== '' ? 'XXXXXXXX' . esc((string) $data[0]->udai_last4) : '' ?>">
<button type="button" id="btn_update_aadhar" class="btn btn-info">Update</button>
</div>
</div>

View File

@ -51,18 +51,20 @@ $normalize = static function ($value): string {
$filters = $filters ?? [];
$filterPhone = trim((string) ($filters['input_mphone1'] ?? ''));
$filterAadhaar = $normalize($filters['input_udai'] ?? '');
// Aadhaar is stored masked/encrypted, so rows can only be compared on the last 4 digits.
$filterAadhaarLast4 = substr(preg_replace('/\D/', '', (string) ($filters['input_udai'] ?? '')) ?? '', -4);
$filterAbha = trim((string) ($filters['input_abha_id'] ?? ''));
$filterName = $normalize($filters['input_name'] ?? '');
$filterRelativeName = $normalize($filters['input_relative_name'] ?? '');
$getMatchScore = static function ($row) use ($normalize, $filterPhone, $filterAadhaar, $filterAbha, $filterName, $filterRelativeName): int {
$getMatchScore = static function ($row) use ($normalize, $filterPhone, $filterAadhaarLast4, $filterAbha, $filterName, $filterRelativeName): int {
$score = 0;
if ($filterPhone !== '' && trim((string) ($row->mphone1 ?? '')) === $filterPhone) {
$score++;
}
if ($filterAadhaar !== '' && $normalize($row->udai ?? '') === $filterAadhaar) {
if ($filterAadhaarLast4 !== '' && trim((string) ($row->udai_last4 ?? '')) === $filterAadhaarLast4) {
$score++;
}
@ -133,7 +135,7 @@ if (! empty($search_result) && is_array($search_result)) {
if ($filterPhone !== '' && trim((string) ($row->mphone1 ?? '')) === $filterPhone) {
$matchReasons[] = 'Phone';
}
if ($filterAadhaar !== '' && $normalize($row->udai ?? '') === $filterAadhaar) {
if ($filterAadhaarLast4 !== '' && trim((string) ($row->udai_last4 ?? '')) === $filterAadhaarLast4) {
$matchReasons[] = 'Aadhaar';
}
if ($filterAbha !== '' && trim((string) ($row->abha_id ?? '')) === $filterAbha) {

View File

@ -13,7 +13,7 @@
$_pid = (int) ($data[0]->id ?? 0);
$_abha = esc($patientAbhaId ?? '');
$_mob = esc($data[0]->mphone1 ?? '');
$_aadh = esc($data[0]->udai ?? '');
$_aadh = '';
?>
<h5 class="card-title mb-0">ABHA Number Create and Verify</h5>