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

醫療保健資料加密

Duy Tran15 分鐘
醫療保健資料加密

回購供您參考 代碼在這裡

要點:

  • ❌ 具有隨機 IV 的 AES-GCM → 無法搜尋
  • ✅ 可搜尋的雜湊索引→精確搜尋(精確匹配)
  • ✅ 標記化+哈希→部分匹配

1. 挑戰:加密與可搜尋性

1.1 問題

醫療保健應用程式需要存儲 PII(個人識別資訊):

  • 身分證/CCCD(國民身分證)
  • 電話號碼
  • 全名
  • 地址

這是群組敏感訊息 PHI(受保護的健康資訊) 根據 HIPAA 標準。

衝突的要求:

  1. 🔒 安全性:資料必須靜態加密(HIPAA、GDPR 合規性)
  2. 🔍 可用性:用戶需要搜尋「Nguyen Van A」、「Tran」等。

1.2 為什麼標準加密不起作用?

// 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!

隨機IV = 高安全性但是 無法搜尋。每次編碼相同的值都會給出不同的結果,保證了語意安全,但不能直接比較。


2. 多層安全架構

醫療保健系統需要在多個層面上保護資料:

圖層 科技 目的
客戶端層 TLS 1.3 (HTTPS) 傳輸過程中加密
應用層 AES-256-GCM、JPA 轉換器 字段級加密
資料庫層 pgcrypto、RLS 行級安全性
儲存層 TDE、盧克斯 全碟加密
多層安全架構

3.方案一:可搜尋哈希索引

3.1 思路

  • 加密 使用 AES-256-GCM 的資料(透過隨機 IV 進行安全保護)
  • 創建 確定性哈希 用於搜尋(HMAC-SHA256)
  • 保存兩者:加密值+搜尋哈希

3.2 資料庫架構

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
@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 加密服務

@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屬性轉換器

@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 優點和缺點

✅ 優點:

  • 非常安全(加密+雜湊)
  • 快速尋找(索引哈希)
  • 實施簡單

❌缺點:

  • 僅精確匹配(不搜尋“079*”)
  • 每個可搜尋字段需要單獨的哈希列

4. 方案二:通證化+搜尋索引

4.1 名稱問題

無法對全名使用精確的雜湊值,因為使用者可以搜尋:

  • 「Tran」(姓 - 部分)
  • 「範」(中間名)
  • 「阮文A」(全名)

4.2 解決方案:代幣化哈希

分別對每個單字進行雜湊處理並將其儲存到 PostgreSQL 陣列中:

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 越南文處理

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 代幣生成服務

@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 帶有 GIN 索引的資料庫模式

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 搜尋查詢

@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 搜尋服務

@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 - 新功能

PostgreSQL 18(2025 年 9 月 25 日發布)帶來了許多重要的安全性改進:

5.1 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 原生支持

PostgreSQL 18 核心支援 OAuth 2.0,與 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 棄用

⚠️ 警告: MD5 驗證已在 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 版本對比

特點 PG 15 PG 17 PG 18
pgcrypto SHA-2 ❌ ⚠️ ✅
OAuth 2.0 本機 ❌ ❌ ✅
FIPS模式功能 ❌ ⚠️ ✅
TLS 1.3 密碼配置 ❌ ❌ ✅
dblink 的 SCRAM ❌ ✅ ✅
直接傳輸層安全 ❌ ✅ ✅
增量備份 ❌ ✅ ✅
MD5 已棄用 ❌ ❌ ✅

6. 權衡分析

方法 可搜尋性 安全性 效能 複雜性
標準加密 ❌無 ⭐⭐⭐⭐⭐ ⭐⭐⭐⭐⭐ ⭐ 簡單
哈希索引 ⚠️僅準確 ⭐⭐⭐⭐ ⭐⭐⭐⭐⭐ ⭐⭐ 簡單
代幣化 ✅ 部分匹配 ⭐⭐⭐ ⭐⭐⭐⭐ ⭐⭐⭐⭐ 綜合體

6.1 什麼時候使用哈希索引?

  • 國民身分證、社會安全號碼、稅號
  • 信用卡號碼
  • 現有的確切標識符

6.2 何時使用標記化?

  • 姓名(全名、名字/姓氏)
  • 地址
  • 自由文字字段

7. 實際結果

7.1 測試設置

  • 數據集: 10,000 名越南患者
  • 資料庫: PostgreSQL 18
  • 加密:AES-256-GCM
  • 後端:春季啟動3.4

7.2 性能指標

操作 時間 註解
創建病人 〜6毫秒 包括加密+標記化
搜尋“特蘭” 〜300毫秒 10K 筆記錄中的 6,092 筆匹配項
搜尋“文學” 〜250毫秒 ~7K 場比賽
精確ID查找 〜2毫秒 使用哈希索引

7.3 安全總結

方面 實施 好處
靜態加密 AES-256-GCM + 隨機 IV 符合 FIPS 140-2 標準
可搜尋性 HMAC-SHA256 哈希索引 快速 O(1) 查找
透明度 JPA 屬性轉換器 零程式碼更改
合規性 HIPAA、GDPR 就緒 審計追蹤、加密粉碎

7.4 性能特點

  • 加密開銷:~2-5% CPU
  • 搜尋速度:與明文相同(索引哈希)
  • 儲存開銷:~30%(Base64 編碼)
  • 吞吐量:10K+ 操作/秒

8. 生產清單

  1. 密鑰管理(使用KMS,而不是硬編碼)
  2. ☐ 單獨的加密和雜湊金鑰
  3. 所有 PHI 存取的審核日誌記錄
  4. ☐ 指數表現監控
  5. 安全備份加密金鑰
  6. ☐ 使用者的文件搜尋限制
  7. 遷移 MD5 → SCRAM-SHA-256 (PostgreSQL 18)
  8. 為資料庫連線啟用 TLS 1.3

8.1 設定範例

# 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 使用 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. 結論

主要見解

  1. 沒有銀彈:加密與搜尋是基本的權衡
  2. 混合方法效果最好:
    • 高風險欄位→加密+哈希
    • 名稱 → 標記化
    • 元資料→有存取控制的明文
  3. 複雜性是有代價的:僅在確實需要時添加

何時使用標記化?

  • ✅ 醫療保健(病人姓名)
  • ✅ 金融(客戶搜尋)
  • ✅ 電子商務(使用者資料)

什麼時候跳過?

  • ❌ 內部工具(使用存取控制)
  • ❌ 公開數據
  • ❌ 非生產環境

最佳實踐

  1. ✅ 使用混合方法 對於可搜尋字段
  2. ✅ 索引哈希列 為了表現
  3. ✅ 單獨的按鍵 加密與雜湊
  4. ✅ 輪換鑰匙 有時
  5. ✅ 審計 所有解密操作
  6. ✅ 從不記錄 解密的 PII/PHI

參考文獻