diff --git a/app/Controllers/DoctorDocument.php b/app/Controllers/DoctorDocument.php index 2a2a8c1..d1573f1 100644 --- a/app/Controllers/DoctorDocument.php +++ b/app/Controllers/DoctorDocument.php @@ -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; } diff --git a/app/Controllers/Medical.php b/app/Controllers/Medical.php index 8241554..d21c581 100644 --- a/app/Controllers/Medical.php +++ b/app/Controllers/Medical.php @@ -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; } diff --git a/app/Controllers/Patient.php b/app/Controllers/Patient.php index 5762f1f..4e51d4c 100644 --- a/app/Controllers/Patient.php +++ b/app/Controllers/Patient.php @@ -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, diff --git a/app/Database/Migrations/2026-08-15-000001_AddAadhaarVaultToPatientMaster.php b/app/Database/Migrations/2026-08-15-000001_AddAadhaarVaultToPatientMaster.php new file mode 100644 index 0000000..ccdb5b3 --- /dev/null +++ b/app/Database/Migrations/2026-08-15-000001_AddAadhaarVaultToPatientMaster.php @@ -0,0 +1,95 @@ +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; + } +} diff --git a/app/Libraries/AadhaarVaultService.php b/app/Libraries/AadhaarVaultService.php new file mode 100644 index 0000000..908e5b8 --- /dev/null +++ b/app/Libraries/AadhaarVaultService.php @@ -0,0 +1,151 @@ + + * + * 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), + ]; + } +} diff --git a/app/Views/billing/Person_Edit_V.php b/app/Views/billing/Person_Edit_V.php index fd30741..bcca1b1 100644 --- a/app/Views/billing/Person_Edit_V.php +++ b/app/Views/billing/Person_Edit_V.php @@ -95,9 +95,11 @@
+ udai_last4 ?? '')); ?> + Stored encrypted. Leave masked value to keep it unchanged.
diff --git a/app/Views/billing/Person_profile_V.php b/app/Views/billing/Person_profile_V.php index dd195e0..9b6f97e 100644 --- a/app/Views/billing/Person_profile_V.php +++ b/app/Views/billing/Person_profile_V.php @@ -140,7 +140,10 @@
Aadhar No.
-
udai?>
+
+ udai_last4 ?? '')); ?> + +
ABHA ID
@@ -253,7 +256,7 @@
- +
diff --git a/app/Views/billing/search_adv_data.php b/app/Views/billing/search_adv_data.php index a284da5..a378c3a 100644 --- a/app/Views/billing/search_adv_data.php +++ b/app/Views/billing/search_adv_data.php @@ -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) { diff --git a/app/Views/partials/abha_create_panel.php b/app/Views/partials/abha_create_panel.php index 0bb2c2a..d4933e5 100644 --- a/app/Views/partials/abha_create_panel.php +++ b/app/Views/partials/abha_create_panel.php @@ -13,7 +13,7 @@ $_pid = (int) ($data[0]->id ?? 0); $_abha = esc($patientAbhaId ?? ''); $_mob = esc($data[0]->mphone1 ?? ''); -$_aadh = esc($data[0]->udai ?? ''); +$_aadh = ''; ?>
ABHA Number Create and Verify