Module: ActiveCipherStorage::KeyRotation

Extended by:
KeyRotation
Included in:
KeyRotation
Defined in:
lib/active_cipher_storage/key_rotation.rb

Overview

Re-wraps the per-file Data Encryption Key (DEK) stored in encrypted file headers without decrypting or re-encrypting the file body.

Why this matters ─────────────────Every encrypted file stores its DEK in the header, wrapped by the KMS master key. When you rotate the master key, only the wrapped DEK in the header needs to change — the AES-256-GCM ciphertext body stays untouched. This makes rotation O(n blobs) in API calls but O(header size) in data transferred per file, not O(file size).

AWS KMS optimisation ─────────────────────When both providers are AwsKmsProvider, KeyRotation uses KMS ReEncrypt. The plaintext DEK never leaves KMS — it is re-wrapped entirely server-side. Cross-provider rotations (e.g. EnvProvider → AwsKmsProvider) must briefly hold the plaintext DEK in process memory, zeroed immediately after use.

Usage ─────

old_kms = ActiveCipherStorage::Providers::AwsKmsProvider.new(key_id: old_arn)
new_kms = ActiveCipherStorage::Providers::AwsKmsProvider.new(key_id: new_arn)

ActiveCipherStorage::KeyRotation.rotate(
  old_provider: old_kms,
  new_provider: new_kms,
  service:      MyEncryptedStorageService.new
) do |blob, result|
  Rails.logger.info "rotated #{blob.key}: #{result[:status]}"
end

Instance Method Summary collapse

Instance Method Details

#rewrite_dek(encrypted_data, old_provider:, new_provider:) ⇒ Object

Rewrites the encrypted DEK inside an encrypted payload’s header. The IV, ciphertext, and auth tag(s) are copied byte-for-byte unchanged.



74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
# File 'lib/active_cipher_storage/key_rotation.rb', line 74

def rewrite_dek(encrypted_data, old_provider:, new_provider:)
  io     = StringIO.new(encrypted_data.b)
  header = Format.read_header(io)
  body_offset = io.pos

  new_encrypted_dek = re_wrap_dek(
    header.encrypted_dek,
    old_provider: old_provider,
    new_provider: new_provider
  )

  out = StringIO.new("".b)
  Format.write_header(out, Format::Header.new(
    version:       header.version,
    algorithm:     header.algorithm,
    chunked:       header.chunked,
    chunk_size:    header.chunk_size,
    provider_id:   new_provider.provider_id,
    encrypted_dek: new_encrypted_dek
  ))
  out.write(encrypted_data.b[body_offset..])
  out.string
end

#rotate(old_provider:, new_provider:, service:, dry_run: false) ⇒ Object

Rotates every blob associated with old_provider. Yields (blob, result_hash) for each blob processed so callers can log progress and handle per-blob failures without aborting the batch.

Options:

dry_run: true  — parse headers and validate, but skip the upload step.


42
43
44
45
46
47
48
49
50
# File 'lib/active_cipher_storage/key_rotation.rb', line 42

def rotate(old_provider:, new_provider:, service:, dry_run: false)
  BlobMetadata.blobs_for(old_provider) do |blob|
    result = rotate_blob(blob, old_provider: old_provider,
                               new_provider: new_provider,
                               service:      service,
                               dry_run:      dry_run)
    yield blob, result if block_given?
  end
end

#rotate_blob(blob, old_provider:, new_provider:, service:, dry_run: false) ⇒ Object

Rotates a single blob. Returns { status: :rotated | :skipped | :failed, … }.



53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
# File 'lib/active_cipher_storage/key_rotation.rb', line 53

def rotate_blob(blob, old_provider:, new_provider:, service:, dry_run: false)
  encrypted = service.download_raw(blob.key)

  unless Format::MAGIC == encrypted.b[0, 4]
    return { status: :skipped, reason: "not an encrypted blob" }
  end

  new_payload = rewrite_dek(encrypted, old_provider: old_provider, new_provider: new_provider)

  unless dry_run
    service.upload_raw(blob.key, StringIO.new(new_payload))
    BlobMetadata.update_after_rotation(blob.key, new_provider)
  end

  { status: dry_run ? :validated : :rotated }
rescue => e
  { status: :failed, error: e.message }
end