Chuyển đến nội dung chính
Database

Mã Hóa Dữ Liệu Healthcare

Làm thế nào để tìm kiếm trên dữ liệu đã mã hóa? Bài viết này trình bày 3 approaches và implementation thực tế với Spring Boot + PostgreSQL để bảo vệ 100,000+ patient records.

Mã Hóa Dữ Liệu Healthcare

Repo cho các bạn tham khảo code tại đây

Key Takeaways:

  • ❌ AES-GCM với random IV → Không search được
  • ✅ Searchable Hash Index → Search chính xác (exact match)
  • ✅ Tokenization + Hashing → Search từng phần (partial match)

1. Thách Thức: Encryption vs Searchability

1.1 Vấn đề

Healthcare applications cần lưu trữ PII (Personally Identifiable Information):

  • CMND/CCCD (National ID)
  • Số điện thoại
  • Họ tên
  • Địa chỉ

Đây là những thông tin nhạy cảm thuộc nhóm PHI (Protected Health Information) theo tiêu chuẩn HIPAA.

Yêu cầu mâu thuẫn:

  1. 🔒 Security: Data phải encrypted at-rest (HIPAA, GDPR compliance)
  2. 🔍 Usability: User cần search "Nguyễn Văn A", "Trần", etc.

1.2 Tại sao Standard Encryption không hoạt động?

// AES-256-GCM với random IV
encrypt("Nguyễn Văn A") → "xK9L2m..."  // Lần 1
encrypt("Nguyễn Văn A") → "pQ3N7r..."  // Lần 2 - KHÁC!

// SQL query không hoạt động WHERE encrypted_name = encrypt("Nguyễn Văn A") // ❌ Fail!

Random IV = high security nhưng impossible to search. Mỗi lần mã hóa cùng một giá trị sẽ cho kết quả khác nhau, đảm bảo semantic security nhưng không thể so sánh trực tiếp.


2. Kiến Trúc Bảo Mật Nhiều Lớp

Một hệ thống healthcare cần bảo vệ dữ liệu ở nhiều tầng:

Layer Công nghệ Mục đích
Client Layer TLS 1.3 (HTTPS) Mã hóa khi truyền tải
Application Layer AES-256-GCM, JPA Converter Mã hóa field-level
Database Layer pgcrypto, RLS Row Level Security
Storage Layer TDE, LUKS Full disk encryption
Kiến Trúc Bảo Mật Nhiều Lớp

3. Solution 1: Searchable Hash Index

3.1 Ý tưởng

  • Encrypt data với AES-256-GCM (secure với random IV)
  • Tạo deterministic hash cho search (HMAC-SHA256)
  • Lưu cả hai: encrypted value + search hash

3.2 Database Schema

CREATE TABLE patients (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    patient_code VARCHAR(20) UNIQUE NOT NULL,
-- PII (mã hóa ở Application Layer)
encrypted_national_id BYTEA NOT NULL,
encrypted_phone BYTEA,
encrypted_full_name BYTEA NOT NULL,

-- Hash để tìm kiếm (HMAC-SHA256)
national_id_hash VARCHAR(64) UNIQUE NOT NULL,
phone_hash VARCHAR(64),

created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()

);

CREATE INDEX idx_national_id_hash ON patients(national_id_hash);

3.3 Spring Boot Entity

@Entity
@Table(name = "patients")
public class Patient {
@Id
@GeneratedValue(strategy = GenerationType.UUID)
private UUID id;

@Convert(converter = EncryptedStringConverter.class)
@Column(name = "national_id")
private String nationalId;

// Search hash (deterministic)
@Column(name = "national_id_hash", unique = true)
private String nationalIdHash;

}

3.4 AES-256-GCM Encryption Service

@Service
public class AesEncryptionService {
private static final String ALGORITHM = "AES/GCM/NoPadding";
private static final int GCM_IV_LENGTH = 12;
private static final int GCM_TAG_LENGTH = 128;

private final SecretKey secretKey;

public AesEncryptionService(@Value("${app.encryption.key}") String key) {
    byte[] keyBytes = Base64.getDecoder().decode(key);
    this.secretKey = new SecretKeySpec(keyBytes, "AES");
}

public String encrypt(String plaintext) {
    try {
        byte[] iv = new byte[GCM_IV_LENGTH];
        new SecureRandom().nextBytes(iv);
        
        Cipher cipher = Cipher.getInstance(ALGORITHM);
        cipher.init(Cipher.ENCRYPT_MODE, secretKey, 
            new GCMParameterSpec(GCM_TAG_LENGTH, iv));
        
        byte[] encrypted = cipher.doFinal(
            plaintext.getBytes(StandardCharsets.UTF_8));
        byte[] combined = new byte[iv.length + encrypted.length];
        System.arraycopy(iv, 0, combined, 0, iv.length);
        System.arraycopy(encrypted, 0, combined, iv.length, 
            encrypted.length);
        
        return Base64.getEncoder().encodeToString(combined);
    } catch (Exception e) {
        throw new EncryptionException("Encryption failed", e);
    }
}

public String decrypt(String ciphertext) {
    try {
        byte[] combined = Base64.getDecoder().decode(ciphertext);
        byte[] iv = Arrays.copyOfRange(combined, 0, GCM_IV_LENGTH);
        byte[] encrypted = Arrays.copyOfRange(combined, 
            GCM_IV_LENGTH, combined.length);
        
        Cipher cipher = Cipher.getInstance(ALGORITHM);
        cipher.init(Cipher.DECRYPT_MODE, secretKey, 
            new GCMParameterSpec(GCM_TAG_LENGTH, iv));
        
        return new String(cipher.doFinal(encrypted), 
            StandardCharsets.UTF_8);
    } catch (Exception e) {
        throw new EncryptionException("Decryption failed", e);
    }
}

}

3.5 JPA AttributeConverter

@Converter
public class EncryptedStringConverter implements AttributeConverter<String, String> {

private static AesEncryptionService encryptionService;

@Autowired
public void setEncryptionService(AesEncryptionService service) {
    EncryptedStringConverter.encryptionService = service;
}

@Override
public String convertToDatabaseColumn(String attribute) {
    if (attribute == null) return null;
    return encryptionService.encrypt(attribute);
}

@Override
public String convertToEntityAttribute(String dbData) {
    if (dbData == null) return null;
    return encryptionService.decrypt(dbData);
}

}

3.6 Ưu và Nhược điểm

✅ Ưu điểm:

  • Very secure (encrypted + hashed)
  • Fast lookup (indexed hash)
  • Simple implementation

❌ Nhược điểm:

  • Chỉ exact match (không search "079*")
  • Cần separate hash column cho mỗi searchable field

4. Solution 2: Tokenization + Search Index

4.1 Vấn đề với Names

Không thể dùng exact hash cho họ tên vì user có thể search:

  • "Trần" (họ - partial)
  • "Van" (tên đệm)
  • "Nguyen Van A" (full name)

4.2 Giải pháp: Tokenized Hashing

Hash từng word riêng biệt và lưu vào PostgreSQL array:

Input: "Nguyễn Văn A"
↓ 1. Remove diacritics
"nguyen van a"
↓ 2. Tokenize
["nguyen", "van", "a", "nguyenvana"]
↓ 3. Hash each token
[hash("nguyen"), hash("van"), hash("a"), hash("nguyenvana")]
↓ 4. Store in PostgreSQL array
fullNameTokens: TEXT[]

4.3 Vietnamese Text Processing

public class VietnameseTextUtils {
public static String removeDiacritics(String text) {
String normalized = text.toLowerCase();

    // Replace đ/Đ
    normalized = normalized.replace('đ', 'd')
                           .replace('Đ', 'd');
    
    // Remove diacritical marks
    normalized = Normalizer.normalize(
        normalized, Normalizer.Form.NFD);
    normalized = normalized.replaceAll("\\p{M}", "");
    
    return normalized.trim();
}

}

4.4 Token Generation Service

@Service
public class SearchableEncryptionService {

private final String hmacKey;

public String[] generateSearchTokens(String text) {
    // 1. Normalize
    String normalized = VietnameseTextUtils.removeDiacritics(text);
    
    // 2. Split into words
    String[] words = normalized.split("\\s+");
    
    // 3. Create token set
    Set&lt;String&gt; tokens = new HashSet&lt;&gt;(Arrays.asList(words));
    
    // 4. Add full string (for exact match)
    tokens.add(normalized.replace(" ", ""));
    
    // 5. Hash each token
    return tokens.stream()
            .map(this::hmacSha256)
            .toArray(String[]::new);
}

private String hmacSha256(String data) {
    try {
        Mac mac = Mac.getInstance("HmacSHA256");
        SecretKeySpec keySpec = new SecretKeySpec(
            hmacKey.getBytes(StandardCharsets.UTF_8), "HmacSHA256");
        mac.init(keySpec);
        byte[] hash = mac.doFinal(
            data.getBytes(StandardCharsets.UTF_8));
        return Base64.getEncoder().encodeToString(hash);
    } catch (Exception e) {
        throw new RuntimeException("HMAC failed", e);
    }
}

}

4.5 Database Schema với GIN Index

CREATE TABLE patients (
id UUID PRIMARY KEY,
full_name TEXT,           -- AES-256-GCM encrypted
full_name_tokens TEXT[],  -- Hashed search tokens
...
);

-- GIN index for array search CREATE INDEX idx_full_name_tokens ON patients USING GIN(full_name_tokens);

4.6 Search Query

@Repository
public interface PatientRepository extends JpaRepository<Patient, UUID> {

@Query("SELECT p FROM Patient p WHERE :token = ANY(p.fullNameTokens)")
Page&lt;Patient&gt; findByFullNameTokensContaining(
    @Param("token") String hashedToken, 
    Pageable pageable
);

}

4.7 Search Service

@Service
public class PatientSearchService {

@Autowired
private SearchableEncryptionService searchableService;

@Autowired
private PatientRepository patientRepository;

public Page&lt;Patient&gt; searchPatients(String query, int page, int size) {
    // 1. Normalize search query
    String normalized = VietnameseTextUtils.removeDiacritics(query);
    
    // 2. Hash the normalized query
    String hashedToken = searchableService.hmacSha256(normalized);
    
    // 3. Search using hashed token
    return patientRepository.findByFullNameTokensContaining(
        hashedToken, 
        PageRequest.of(page, size)
    );
}

}


5. PostgreSQL 17/18 - Tính năng Mới

PostgreSQL 18 (phát hành 25/09/2025) mang đến nhiều cải tiến quan trọng về bảo mật:

5.1 Cải tiến pgcrypto

-- PostgreSQL 18 hỗ trợ SHA-2 cho password hashing
SELECT sha256crypt('password', gen_salt('sha256'));
SELECT sha512crypt('password', gen_salt('sha512'));

-- Hỗ trợ CFB mode cho AES SELECT encrypt('sensitive data'::bytea, 'key'::bytea, 'aes-cfb');

5.2 OAuth 2.0 Native Support

PostgreSQL 18 hỗ trợ OAuth 2.0 trong core, tích hợp với Keycloak, Okta, Azure AD:

# pg_hba.conf - PostgreSQL 18
host all all 0.0.0.0/0 oauth 
issuer="https://keycloak.example.com/realms/myrealm"
scope="openid"

5.3 MD5 Deprecation

⚠️ Cảnh báo: MD5 authentication đã bị deprecated trong PostgreSQL 18.

-- Chuyển sang SCRAM-SHA-256
ALTER ROLE myuser PASSWORD 'newpassword';

-- Password nên bắt đầu với 'SCRAM-SHA-256$'

# pg_hba.conf - Sử dụng SCRAM thay MD5
host all all 0.0.0.0/0 scram-sha-256

5.4 So sánh Phiên bản

Feature PG 15 PG 17 PG 18
pgcrypto SHA-2 ❌ ⚠️ ✅
OAuth 2.0 native ❌ ❌ ✅
FIPS mode function ❌ ⚠️ ✅
TLS 1.3 cipher config ❌ ❌ ✅
SCRAM for dblink ❌ ✅ ✅
Direct TLS ❌ ✅ ✅
Incremental backup ❌ ✅ ✅
MD5 deprecated ❌ ❌ ✅

6. Trade-offs Analysis

Approach Searchability Security Performance Complexity
Standard Encryption ❌ None ⭐⭐⭐⭐⭐ ⭐⭐⭐⭐⭐ ⭐ Simple
Hash Index ⚠️ Exact only ⭐⭐⭐⭐ ⭐⭐⭐⭐⭐ ⭐⭐ Easy
Tokenization ✅ Partial match ⭐⭐⭐ ⭐⭐⭐⭐ ⭐⭐⭐⭐ Complex

6.1 Khi nào dùng Hash Index?

  • National ID, SSN, Tax ID
  • Credit card numbers
  • Existing exact identifiers

6.2 Khi nào dùng Tokenization?

  • Names (full name, first/last)
  • Addresses
  • Free-text fields

7. Real-World Results

7.1 Test Setup

  • Dataset: 10,000 Vietnamese patients
  • Database: PostgreSQL 18
  • Encryption: AES-256-GCM
  • Backend: Spring Boot 3.4

7.2 Performance Metrics

Operation Time Notes
Create patient ~6ms Including encryption + tokenization
Search "Trần" ~300ms 6,092 matches from 10K records
Search "Văn" ~250ms ~7K matches
Exact ID lookup ~2ms Using hash index

7.3 Security Summary

Aspect Implementation Benefits
Encryption at Rest AES-256-GCM + random IV FIPS 140-2 compliant
Searchability HMAC-SHA256 hash index Fast O(1) lookup
Transparency JPA AttributeConverter Zero code change
Compliance HIPAA, GDPR ready Audit trail, crypto shredding

7.4 Performance Characteristics

  • Encryption overhead: ~2-5% CPU
  • Search speed: Same as plaintext (indexed hash)
  • Storage overhead: ~30% (Base64 encoding)
  • Throughput: 10K+ ops/sec

8. Production Checklist

  1. ☐ Key management (use KMS, not hardcoded)
  2. ☐ Separate encryption & hashing keys
  3. ☐ Audit logging for all PHI access
  4. ☐ Index performance monitoring
  5. ☐ Backup encryption keys securely
  6. ☐ Document search limitations for users
  7. ☐ Migrate MD5 → SCRAM-SHA-256 (PostgreSQL 18)
  8. ☐ Enable TLS 1.3 for database connections

8.1 Configuration Example

# application.yml
spring:
  datasource:
    url: jdbc:postgresql://localhost:5432/healthcare?sslmode=require
    hikari:
      ssl-mode: require

app: encryption: key: ${ENCRYPTION_KEY} # From KMS/Vault algorithm: AES/GCM/NoPadding hashing: key: ${HMAC_KEY} # Separate key for hashing

8.2 Key Management với AWS KMS

@Configuration
public class KmsConfig {
@Bean
public KmsClient kmsClient() {
return KmsClient.builder()
.region(Region.AP_SOUTHEAST_1)
.build();
}

@Bean
public SecretKey dataEncryptionKey(KmsClient kmsClient, 
        @Value("${aws.kms.key-id}") String keyId) {
    GenerateDataKeyRequest request = GenerateDataKeyRequest.builder()
        .keyId(keyId)
        .keySpec(DataKeySpec.AES_256)
        .build();
    
    GenerateDataKeyResponse response = kmsClient.generateDataKey(request);
    return new SecretKeySpec(
        response.plaintext().asByteArray(), "AES");
}

}


9. Kết Luận

Key Insights

  1. No silver bullet: Encryption vs search là fundamental tradeoff
  2. Hybrid approach works best:
    • High-risk fields → encrypted + hash
    • Names → tokenization
    • Metadata → plaintext with access control
  3. Complexity has cost: Only add if truly needed

Khi nào dùng tokenization?

  • ✅ Healthcare (patient names)
  • ✅ Finance (customer search)
  • ✅ E-commerce (user profiles)

Khi nào skip?

  • ❌ Internal tools (use access control)
  • ❌ Public data
  • ❌ Non-production environments

Best Practices

  1. ✅ Use hybrid approach cho searchable fields
  2. ✅ Index hash columns for performance
  3. ✅ Separate keys cho encryption vs hashing
  4. ✅ Rotate keys periodically
  5. ✅ Audit all decryption operations
  6. ✅ Never log decrypted PII/PHI

Tài liệu tham khảo

DUY TRAN
Tác giả

DUY TRAN

Pursuing an AI-first mindset and intelligent system architecture. I build solutions by combining technology, creativity, and the ability to see structure in chaos — the foundation for becoming a Solution Architect.

Bình luận