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

医療データの暗号化

Duy Tran15分
医療データの暗号化

参考までにレポ コードはここにあります

重要なポイント:

  • ❌ ランダム IV の AES-GCM → 検索できません
  • ✅ 検索可能なハッシュインデックス → 完全一致検索(完全一致)
  • ✅ トークン化 + ハッシュ化 → 部分一致

1. 課題: 暗号化と検索可能性

1.1 問題点

医療アプリケーションにはストレージが必要です PII (個人を特定できる情報):

  • IDカード/CCCD(国民ID)
  • 電話番号
  • フルネーム
  • 住所

これはグループの機密情報です 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. 解決策 1: 検索可能なハッシュ インデックス

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. ソリューション 2: トークン化 + 検索インデックス

4.1 名前の問題

ユーザーは検索できるため、フルネームに正確なハッシュを使用できません:

  • 「トラン」(姓の一部)
  • 「ヴァン」(ミドルネーム)
  • 「グエン・ヴァン・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 バージョン比較

特長 PG15 PG17 PG18
pgcrypto SHA-2 ❌ ⚠️ ✅
OAuth 2.0 ネイティブ ❌ ❌ ✅
FIPSモード機能 ❌ ⚠️ ✅
TLS 1.3暗号構成 ❌ ❌ ✅
dblink のスクラム ❌ ✅ ✅
ダイレクトTLS ❌ ✅ ✅
増分バックアップ ❌ ✅ ✅
MD5は非推奨になりました ❌ ❌ ✅

6. トレードオフ分析

アプローチ 検索性 セキュリティ パフォーマンス 複雑さ
標準暗号化 ❌ なし ⭐⭐⭐⭐⭐ ⭐⭐⭐⭐⭐ ⭐シンプル
ハッシュインデックス ⚠️ 正確のみ ⭐⭐⭐⭐ ⭐⭐⭐⭐⭐ ⭐⭐簡単
トークン化 ✅ 部分一致 ⭐⭐⭐ ⭐⭐⭐⭐ ⭐⭐⭐⭐ コンプレックス

6.1 ハッシュインデックスをいつ使用するか?

  • 国民ID、SSN、納税者ID
  • クレジットカード番号
  • 既存の正確な識別子

6.2 トークン化をいつ使用するか?

  • 名前(フルネーム、姓/名)
  • 住所
  • 自由記述フィールド

7. 実際の結果

7.1 テストのセットアップ

  • データセット: ベトナム人患者10,000人
  • データベース: PostgreSQL18
  • 暗号化: AES-256-GCM
  • バックエンド:スプリングブート3.4

7.2 パフォーマンス指標

操作 時間 注意事項
患者の作成 ~6ms 暗号化 + トークン化を含む
「トラン」で検索 ~300ms 10,000 レコードからの 6,092 件の一致
「文学」で検索 ~250ms ~7,000 件の一致
正確な ID 検索 ~2ms ハッシュインデックスの使用

7.3 セキュリティの概要

アスペクト 実装 利点
保存時の暗号化 AES-256-GCM + ランダム IV FIPS 140-2準拠
検索性 HMAC-SHA256 ハッシュ インデックス 高速 O(1) ルックアップ
透明性 JPA属性コンバータ コード変更なし
コンプライアンス HIPAA、GDPR対応 監査証跡、暗号シュレッディング

7.4 性能特性

  • 暗号化のオーバーヘッド: ~2-5% CPU
  • 検索速度: 平文と同じ (インデックス付きハッシュ)
  • ストレージのオーバーヘッド: ~30% (Base64 エンコーディング)
  • スループット: 10,000+ オペレーション/秒

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

参考文献