Store Aadhaar encrypted with masked display and hash-based search
This commit is contained in:
parent
00b949ad0a
commit
2d13ba567e
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
151
app/Libraries/AadhaarVaultService.php
Normal file
151
app/Libraries/AadhaarVaultService.php
Normal 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),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user