mybook

データの暗号化 — 保存データを守る

「顧客の在庫データが漏洩した可能性がある」

あの夜からずっと、リョウの頭を離れなかった言葉だ。攻撃者がデータベースに直接アクセスできた場合、パスワードはBCryptでハッシュ化されていたので読めない。しかし、在庫数量、仕入れ価格、サプライヤー情報は平文で保存されていた。

「暗号化されていれば、たとえデータを取得されても読めなかった」

リョウはデータ暗号化の実装に取り掛かった。

データ分類の重要性

すべてのデータを同じように扱うのではなく、機密度に応じて分類することが重要だ。

Loading diagram...
分類データ例保護方法
公開商品名、カテゴリ特別な保護不要
内部在庫数量、注文履歴アクセス制限
機密仕入れ価格、利益率暗号化必須
最高機密個人情報、決済情報KMS + 暗号化

AWS KMSで鍵を管理する

AWS Key Management Service(KMS)は、暗号化キーの作成・管理・ローテーションを安全に行うサービスだ。

# KMSキーを作成
aws kms create-key \
  --description "StockFlow data encryption key" \
  --key-usage ENCRYPT_DECRYPT \
  --key-spec SYMMETRIC_DEFAULT \
  --tags TagKey=Project,TagValue=StockFlow
 
# キーの自動ローテーションを有効化(1年ごと)
aws kms enable-key-rotation \
  --key-id "arn:aws:kms:ap-northeast-1:123456789012:key/xxx"
 
# キーエイリアスを設定(人が読みやすい名前)
aws kms create-alias \
  --alias-name "alias/stockflow-data-key" \
  --target-key-id "arn:aws:kms:ap-northeast-1:123456789012:key/xxx"
# terraform/kms.tf
resource "aws_kms_key" "data_encryption" {
  description             = "StockFlow data encryption key"
  key_usage               = "ENCRYPT_DECRYPT"
  customer_master_key_spec = "SYMMETRIC_DEFAULT"
 
  # 自動ローテーション(年次)
  enable_key_rotation = true
 
  # 削除保護(誤削除防止)
  deletion_window_in_days = 30
 
  # キーポリシー
  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Sid    = "Enable IAM User Permissions"
        Effect = "Allow"
        Principal = { AWS = "arn:aws:iam::${data.aws_caller_identity.current.account_id}:root" }
        Action   = "kms:*"
        Resource = "*"
      },
      {
        Sid    = "Allow API Service"
        Effect = "Allow"
        Principal = { AWS = aws_iam_role.api_service.arn }
        Action = [
          "kms:Decrypt",
          "kms:Encrypt",
          "kms:GenerateDataKey",
          "kms:DescribeKey"
        ]
        Resource = "*"
      }
    ]
  })
}

attr_encryptedでカラム暗号化

attr_encrypted gemを使えば、モデルの特定のカラムを透過的に暗号化できる。

# Gemfile
gem 'attr_encrypted', '~> 4.0'
# db/migrate/20240115000004_add_encrypted_fields_to_inventory_items.rb
class AddEncryptedFieldsToInventoryItems < ActiveRecord::Migration[7.1]
  def change
    # 暗号化カラム(平文カラムを削除して暗号化版に移行)
    add_column :inventory_items, :encrypted_purchase_price, :string
    add_column :inventory_items, :encrypted_purchase_price_iv, :string
    add_column :inventory_items, :encrypted_supplier_code, :string
    add_column :inventory_items, :encrypted_supplier_code_iv, :string
    add_column :inventory_items, :encrypted_internal_notes, :text
    add_column :inventory_items, :encrypted_internal_notes_iv, :string
  end
end
# app/models/inventory_item.rb
class InventoryItem < ApplicationRecord
  # KMSを使ってデータ暗号化鍵(DEK)を管理する
  # エンベロープ暗号化パターン:
  # 1. AES-256でデータを暗号化(DEK)
  # 2. DEKをKMSのCMKで暗号化
  # 3. 暗号化されたDEKをDBに保存
 
  encrypts :purchase_price,
    key: :encryption_key,
    encode: true,
    type: :decimal
 
  encrypts :supplier_code,
    key: :encryption_key,
    encode: true
 
  encrypts :internal_notes,
    key: :encryption_key,
    encode: true
 
  private
 
  def encryption_key
    # KMSからデータ暗号化鍵を取得(Railsの暗号化機能ではなくattr_encryptedの場合)
    @encryption_key ||= fetch_encryption_key
  end
 
  def fetch_encryption_key
    Rails.cache.fetch("kms_data_key:#{company_id}", expires_in: 5.minutes) do
      kms = Aws::KMS::Client.new
      response = kms.generate_data_key(
        key_id: ENV['KMS_KEY_ARN'],
        key_spec: 'AES_256'
      )
      response.plaintext
    end
  end
end

Active RecordのEncrypts(Rails 7.1+)

Rails 7.1からはネイティブの暗号化機能が使える。

# config/application.rb
module Stockflow
  class Application < Rails::Application
    # 暗号化の設定
    config.active_record.encryption.primary_key = Rails.application.credentials.dig(:active_record_encryption, :primary_key)
    config.active_record.encryption.deterministic_key = Rails.application.credentials.dig(:active_record_encryption, :deterministic_key)
    config.active_record.encryption.key_derivation_salt = Rails.application.credentials.dig(:active_record_encryption, :key_derivation_salt)
  end
end
# 暗号化キーを生成してcredentialsに保存
rails db:encryption:init
# 出力例:
# active_record_encryption:
#   primary_key: abc123...
#   deterministic_key: def456...
#   key_derivation_salt: ghi789...
# app/models/customer.rb
class Customer < ApplicationRecord
  # 機密フィールドを暗号化
  encrypts :tax_number, deterministic: true  # 検索可能な暗号化
  encrypts :phone_number
  encrypts :address, downcase: false
 
  # 個人情報の特別な保護
  encrypts :national_id, key_provider: :customer_specific_key
 
  private
 
  def customer_specific_key
    # 顧客ごとに異なる鍵を使用(最高機密データ用)
    Aws::KMS::Client.new.generate_data_key(
      key_id: ENV['KMS_CUSTOMER_KEY_ARN'],
      key_spec: 'AES_256'
    ).plaintext
  end
end

INFO

deterministic: trueは同じ値が常に同じ暗号文になるため、WHERE句での検索が可能です。ただし頻度分析攻撃に弱くなるため、一意性が高い値(メールアドレス、電話番号等)に限定して使用します。

パスワードの適切なハッシュ化

パスワードは「暗号化」ではなく「ハッシュ化」する。違いは不可逆性だ。

# app/models/user.rb
class User < ApplicationRecord
  has_secure_password  # BCryptを使ったパスワードハッシュ化
 
  # BCryptのコスト(ラウンド数)を設定
  # コストが高いほど安全だが、ログイン時間も増加
  # cost: 12は約0.3秒(デフォルトは10)
  BCrypt::Engine.cost = 12
 
  # パスワード再ハッシュ(コスト変更時の移行)
  def authenticate_with_upgrade(password)
    return false unless authenticate(password)
 
    # コストが変わった場合、ログイン時に自動アップグレード
    if BCrypt::Password.new(password_digest).cost != BCrypt::Engine.cost
      update!(password: password)
    end
 
    true
  end
end

RDS暗号化

データベース自体も暗号化する。

# terraform/rds.tf
resource "aws_db_instance" "main" {
  identifier        = "stockflow-postgres"
  engine            = "postgres"
  engine_version    = "16.1"
  instance_class    = "db.t3.medium"
  allocated_storage = 100
 
  # 保存データの暗号化(必須)
  storage_encrypted = true
  kms_key_id        = aws_kms_key.rds.arn
 
  # 自動バックアップも暗号化される
  backup_retention_period = 7
  backup_window           = "03:00-04:00"
 
  # 削除保護
  deletion_protection = true
 
  # パブリックアクセス禁止
  publicly_accessible = false
  db_subnet_group_name = aws_db_subnet_group.main.name
  vpc_security_group_ids = [aws_security_group.rds.id]
}

S3バケットの暗号化

ファイルストレージも暗号化する。

# terraform/s3.tf
resource "aws_s3_bucket" "exports" {
  bucket = "stockflow-exports-${data.aws_caller_identity.current.account_id}"
}
 
# KMSによる暗号化(SSE-KMS)
resource "aws_s3_bucket_server_side_encryption_configuration" "exports" {
  bucket = aws_s3_bucket.exports.id
 
  rule {
    apply_server_side_encryption_by_default {
      sse_algorithm     = "aws:kms"
      kms_master_key_id = aws_kms_key.s3.arn
    }
    bucket_key_enabled = true  # コスト削減
  }
}
 
# パブリックアクセスのブロック
resource "aws_s3_bucket_public_access_block" "exports" {
  bucket = aws_s3_bucket.exports.id
 
  block_public_acls       = true
  block_public_policy     = true
  ignore_public_acls      = true
  restrict_public_buckets = true
}

データマスキング(センシティブデータの表示制限)

APIレスポンスでもデータをマスキングする。

# app/serializers/customer_serializer.rb
class CustomerSerializer < ActiveModel::Serializer
  attributes :id, :name, :email, :phone, :tax_number
 
  def email
    return object.email if scope&.admin?
    # 管理者以外はメールをマスキング
    mask_email(object.email)
  end
 
  def phone
    return object.phone if scope&.admin?
    # 電話番号の最後4桁のみ表示
    "***-***-#{object.phone&.last(4)}"
  end
 
  def tax_number
    return object.tax_number if scope&.admin?
    # 法人番号は非表示
    "[REDACTED]"
  end
 
  private
 
  def mask_email(email)
    return unless email
    local, domain = email.split('@')
    "#{local.first}***@#{domain}"
  end
end

暗号化データの検索

暗号化されたデータを検索するために、別途インデックス用のハッシュを保存する手法がある。

# app/models/concerns/searchable_encrypted.rb
module SearchableEncrypted
  extend ActiveSupport::Concern
 
  # 検索用に決定論的なハッシュを生成
  def self.search_hash(value)
    OpenSSL::HMAC.hexdigest(
      'SHA256',
      Rails.application.credentials.search_key!,
      value.to_s.downcase.strip
    )
  end
end
 
# app/models/customer.rb
class Customer < ApplicationRecord
  encrypts :email, deterministic: true  # Railsの暗号化で検索可能にする
 
  # または、検索ハッシュを別カラムに保存する方法
  before_save :compute_email_search_hash
 
  private
 
  def compute_email_search_hash
    self.email_search_hash = SearchableEncrypted.search_hash(email) if email_changed?
  end
end
 
# app/models/concerns/searchable_encrypted.rb に追加
scope :find_by_email, ->(email) {
  where(email_search_hash: SearchableEncrypted.search_hash(email))
}

鍵のローテーション手順

KMSキーをローテーションした後、既存の暗号化データを再暗号化する必要がある。

# app/jobs/reencrypt_data_job.rb
class ReencryptDataJob < ApplicationJob
  def perform(model_class, batch_size: 100)
    klass = model_class.constantize
 
    klass.find_in_batches(batch_size: batch_size) do |batch|
      batch.each do |record|
        # 古い鍵で復号化し、新しい鍵で再暗号化
        record.reencrypt!
      end
    end
 
    Rails.logger.info("Reencryption complete for #{model_class}")
  end
end

WARNING

KMSの自動ローテーションは新しいキーマテリアルを追加しますが、古いデータの再暗号化は行いません。AWS KMSは古いキーマテリアルを保持するため、古いデータも復号できます。手動でデータを再暗号化する場合は、移行期間を設けて段階的に行ってください。

チェックリスト

  • データを機密度別に分類し、暗号化ポリシーを定めている
  • パスワードはBCryptでハッシュ化している(cost: 12以上)
  • 機密フィールドはActive RecordのEncryptsまたはattr_encryptedで暗号化している
  • AWS KMSで暗号化キーを管理し、自動ローテーションを有効にしている
  • RDSの保存暗号化が有効になっている
  • S3バケットがSSE-KMSで暗号化されている
  • APIレスポンスで機密データをマスキングしている
  • 暗号化データの検索方法を適切に設計している
  • 鍵ローテーション手順が文書化されている