参考までにレポ コードはここにあります
重要なポイント:
- ❌ ランダム IV の AES-GCM → 検索できません
- ✅ 検索可能なハッシュインデックス → 完全一致検索(完全一致)
- ✅ トークン化 + ハッシュ化 → 部分一致
1. 課題: 暗号化と検索可能性
1.1 問題点
医療アプリケーションにはストレージが必要です PII (個人を特定できる情報):
- IDカード/CCCD(国民ID)
- 電話番号
- フルネーム
- 住所
これはグループの機密情報です PHI (保護された健康情報) HIPAA 規格に従って。
矛盾する要件:
- 🔒 セキュリティ: データは保存時に暗号化する必要があります (HIPAA、GDPR 準拠)
- 🔍 使いやすさ: ユーザーは「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<String> tokens = new HashSet<>(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<Patient> findByFullNameTokensContaining( @Param("token") String hashedToken, Pageable pageable );
}
4.7 検索サービス
@Service public class PatientSearchService {@Autowired private SearchableEncryptionService searchableService; @Autowired private PatientRepository patientRepository; public Page<Patient> 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. 制作チェックリスト
- ☐ キー管理 (ハードコーディングではなく KMS を使用)
- ☐ 個別の暗号化キーとハッシュキー
- ☐ すべての PHI アクセスの監査ログ
- ☐ インデックスパフォーマンスの監視
- ☐ 暗号化キーを安全にバックアップする
- ☐ ユーザーに対する文書検索の制限
- ☐ MD5 → SCRAM-SHA-256 (PostgreSQL 18) への移行
- ☐ データベース接続に対して 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. 結論
重要な洞察
- 特効薬はない: 暗号化と検索は基本的なトレードオフです
- ハイブリッドアプローチが最も効果的:
- 高リスクフィールド → 暗号化 + ハッシュ
- 名前 → トークン化
- メタデータ → アクセス制御付き平文
- 複雑さにはコストがかかる: 本当に必要な場合にのみ追加します
トークン化をいつ使用するか?
- ✅ 医療(患者名)
- ✅ 財務(顧客検索)
- ✅ 電子商取引 (ユーザープロファイル)
いつスキップするか?
- ❌ 内部ツール (アクセス制御を使用)
- ❌ 公開データ
- ❌ 非実稼働環境
ベストプラクティス
- ✅ ハイブリッドアプローチを使用する 検索可能なフィールドの場合
- ✅ インデックスハッシュ列 パフォーマンスのために
- ✅ 個別のキー 暗号化とハッシュの比較
- ✅ キーを回転する 時々
- ✅ 監査 すべての復号化操作
- ✅ 決してログに記録しないでください 復号化された PII/PHI
