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

Healthcare Data Encryption

Duy Tran15 min
Healthcare Data Encryption

Repo for your reference code here

Key Takeaways:

  • ❌ AES-GCM with random IV → Unable to search
  • ✅ Searchable Hash Index → Exact search (exact match)
  • ✅ Tokenization + Hashing → Partial match

1. Challenge: Encryption vs Searchability

1.1 Problem

Healthcare applications need storage PII (Personally Identifiable Information):

  • ID card/CCCD (National ID)
  • Phone number
  • Full name
  • Address

This is group sensitive information PHI (Protected Health Information) according to HIPAA standards.

Conflicting requirements:

  1. 🔒 Security: Data must be encrypted at-rest (HIPAA, GDPR compliance)
  2. 🔍 Usability: User needs to search "Nguyen Van A", "Tran", etc.

1.2 Why doesn't Standard Encryption work?

// 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 but impossible to search. Each time encoding the same value will give a different result, ensuring semantic security but not directly comparable.


2. Multi-layer Security Architecture

A healthcare system needs to protect data at many levels:

Layer Technology Purpose
Client Layer TLS 1.3 (HTTPS) Encryption during transmission
Application Layer AES-256-GCM, JPA Converter Field-level encryption
Database Layer pgcrypto, RLS Row Level Security
Storage Layer TDE, LUKS Full disk encryption
Multi-layer Security Architecture

3. Solution 1: Searchable Hash Index

3.1 Ideas

  • Encrypt data with AES-256-GCM (secure with random IV)
  • Create deterministic hash for search (HMAC-SHA256)
  • Save both: 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 Entities

@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 Advantages and Disadvantages

✅ Advantages:

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

❌ Disadvantages:

  • Exact match only (don't search "079*")
  • Need separate hash column for each searchable field

4. Solution 2: Tokenization + Search Index

4.1 Problems with Names

Cannot use exact hash for full name because user can search:

  • "Tran" (family name - partial)
  • "Van" (middle name)
  • "Nguyen Van A" (full name)

4.2 Solution: Tokenized Hashing

Hash each word separately and save it to 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 with 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 - New Features

PostgreSQL 18 (released September 25, 2025) brings many important security improvements:

5.1 Improvements to 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 supports OAuth 2.0 in core, integrating with 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

⚠️ Warning: MD5 authentication has been deprecated in 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 Version Comparison

Features 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
HashIndex ⚠️ Exact only ⭐⭐⭐⭐ ⭐⭐⭐⭐⭐ ⭐⭐ Easy
Tokenization ✅ Partial match ⭐⭐⭐ ⭐⭐⭐⭐ ⭐⭐⭐⭐ Complex

6.1 When to use Hash Index?

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

6.2 When to use 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 "Tran" ~300ms 6,092 matches from 10K records
Search "Literature" ~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 changes
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 with 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. Conclusion

Key Insights

  1. No silver bullet: Encryption vs search is the 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

When to use tokenization?

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

When to skip?

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

Best Practices

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

References