How to encrypt files with Ruby and Active Support

In this tutorial, we will create a Ruby binstub that allows you to encrypt and decrypt files using Active Support’s EncryptedFile module. This script is particularly useful for those who need to protect sensitive information within their files.

Use Cases

  • Encrypt text files in open source projects so that they can be committed to your repository without exposing sensitive information.
  • Storing credentials in an encrypted file, similar to Rails Custom Credentials.
  • Taking secure personal notes.

Required Libraries

The script starts with the following lines, which load the required libraries and set up the command line argument handling.

#!/usr/bin/env ruby
require "active_support/encrypted_file"
require "securerandom"

These lines import the necessary Ruby modules: active_support/encrypted_file for encryption and decryption, and securerandom for key generation

Parsing Arguments

The script expects one of three commands as the first argument: setup, write, or read. If no command is provided, the script will print an error message and exit.

command = ARGV.shift

if command.nil?
  puts "Please pass 'setup', 'write [FILE]', or 'read [FILE]'"
  exit 1
end

Helper Methods

The following build_encrypted_file method creates an instance of ActiveSupport::EncryptedFile with the necessary configuration options.

def build_encrypted_file(file)
  ActiveSupport::EncryptedFile.new(
    content_path: file,
    key_path: "invisible_ink.key",
    env_key: "INVISIBLE_INK_KEY",
    raise_if_missing_key: true
  )
end

Next, we have two helper methods: handle_missing_key and handle_missing_file_argument. These methods print error messages and exit the script if a key or a file is missing, respectively.

def handle_missing_key(error)
  puts "ERROR: #{error}"
  puts ""
  puts "Did you run 'setup'?"
  exit 1
end

def handle_missing_file_argument
  puts "Please pass a file"
  exit 1
end

Executing the Commands

The case statement is the main part of the script, handling the three possible commands: write, read, and setup.

Encrypting a File

For the write command, the script ensures that a file argument is provided and that a system editor is available to open the file. It then creates a new encrypted file if it does not exist and opens the file in the system editor for editing. When the editor is closed, the encrypted file is saved with the new content.

when "write"
  file = ARGV.shift
  handle_missing_file_argument if file.nil?
  if ENV["EDITOR"].to_s.empty?
    puts "No $EDITOR to open file in"
    exit 1
  end
  begin
    encrypted_file = build_encrypted_file(file)
    encrypted_file.write(nil) unless File.exist?(file)
    encrypted_file.change do |tmp_path|
      system(ENV["EDITOR"], tmp_path.to_s)
    rescue Interrupt
      puts "File not saved"
    end
  rescue ActiveSupport::EncryptedFile::MissingKeyError => error
    handle_missing_key(error)
  end

Decrypting a File

For the read command, the script ensures that a file argument is provided and then attempts to read the encrypted file, printing the decrypted contents to the standard output.

when "read"
  begin
    file = ARGV.shift
    handle_missing_file_argument if file.nil?
    encrypted_file = build_encrypted_file(file)
    puts encrypted_file.read
  rescue ActiveSupport::EncryptedFile::MissingKeyError => error
    handle_missing_key(error)
  end

The Setup Script

Finally, the setup command generates a new encryption key and saves it to a file named invisible_ink.key. It also adds the key file to the .gitignore file to prevent it from being accidentally committed to a version control system.

when "setup"
  if File.exist?("invisible_ink.key")
    puts "ERROR: invisible_ink.key already exists"
    exit 1
  else
    File.open(".gitignore", "a") { |file| file.puts("invisible_ink.key") }
    key = ActiveSupport::EncryptedFile.generate_key
    File.write("invisible_ink.key", key)
    puts "invisible_ink.key generated"
  end

The Final Script

By using this script, you can protect sensitive information from unauthorized access and maintain the privacy of your data. With the setup, write, and read commands, you can easily manage the encryption and decryption process, making it a useful tool for various applications.

As an alternative, you can also use the invisible_ink gem, which provides the same functionality as this script, with the added benefit of being easily integrated into any Ruby project. This gem offers a more streamlined and maintainable approach to file encryption and decryption in your Ruby applications, without the need to implement the entire script yourself.

#!/usr/bin/env ruby
require "active_support/encrypted_file"
require "securerandom"

# ℹ️ Set the command from the first argument
command = ARGV.shift

# ℹ️ Return early if no command was passed
if command.nil?
  puts "Please pass 'setup', 'write [FILE]', or 'read [FILE]'"
  exit 1
end

# ℹ️ Create an encrypted file instance.
def build_encrypted_file(file)
  ActiveSupport::EncryptedFile.new(
    content_path: file,
    # ℹ️ The key will be generated during the 'setup' script
    key_path: "invisible_ink.key",
    # ℹ️ Alternatively use an environment variable to store the key
    env_key: "INVISIBLE_INK_KEY",
    raise_if_missing_key: true
  )
end

# ℹ️ Handle case where ActiveSupport::EncryptedFile::MissingKeyError is raised
def handle_missing_key(error)
  puts "ERROR: #{error}"
  puts ""
  puts "Did you run 'setup'?"
  exit 1
end

# ℹ️ Handle case where a file was not passed as an argument
def handle_missing_file_argument
  puts "Please pass a file"
  exit 1
end

case command
when "write"
  # ℹ️ Set the file from the second argument
  file = ARGV.shift
  # ℹ️ Ensure the file was passed as an argument
  handle_missing_file_argument if file.nil?
  # ℹ️ Return early if there is no system editor to edit the file
  if ENV["EDITOR"].to_s.empty?
    puts "No $EDITOR to open file in"
    exit 1
  end
  begin
    encrypted_file = build_encrypted_file(file)
    # ℹ️ Writing a blank file creates the file in cases where it does not yet
    # exist. We need a file before we can write to it.
    encrypted_file.write(nil) unless File.exist?(file)
    # ℹ️ Open the file in the system $EDITOR using a temporary path
    encrypted_file.change do |tmp_path|
      system(ENV["EDITOR"], tmp_path.to_s)
    # ℹ️ Handle case where the $EDITOR is closed before the file was saved
    rescue Interrupt
      puts "File not saved"
    end
  # ℹ️ Print message to user if the key is missing
  rescue ActiveSupport::EncryptedFile::MissingKeyError => error
    handle_missing_key(error)
  end
when "read"
  begin
    # ℹ️ Set the file from the second argument
    file = ARGV.shift
    # ℹ️ Ensure the file was passed as an argument
    handle_missing_file_argument if file.nil?
    encrypted_file = build_encrypted_file(file)
    # ℹ️ Print decrypted file contents to $STDOUT
    puts encrypted_file.read
  # ℹ️ Print message to user if the key is missing
  rescue ActiveSupport::EncryptedFile::MissingKeyError => error
    handle_missing_key(error)
  end
when "setup"
  # ℹ️ Return early if there is already a key
  if File.exist?("invisible_ink.key")
    puts "ERROR: invisible_ink.key already exists"
    exit 1
  else
    # ℹ️ Prevent key from being saved to version control
    File.open(".gitignore", "a") { |file| file.puts("invisible_ink.key") }
    # ℹ️ Generate key
    key = ActiveSupport::EncryptedFile.generate_key
    # ℹ️ Write the key to a file
    File.write("invisible_ink.key", key)
    puts "invisible_ink.key generated"
  end
end