Symmetric Encryption for Ruby, ActiveRecord, and Mongoid. Encrypt passwords in configuration files. Encrypt entire files at rest.
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 | {{content}}
46 |
47 |
48 |
49 |
50 |
--------------------------------------------------------------------------------
/lib/symmetric_encryption/railtie.rb:
--------------------------------------------------------------------------------
1 | module SymmetricEncryption # :nodoc:
2 | class Railtie < Rails::Railtie # :nodoc:
3 | # Exposes Symmetric Encryption's configuration to the Rails application configuration.
4 | #
5 | # @example Set up configuration in the Rails app.
6 | # module MyApplication
7 | # class Application < Rails::Application
8 | # config.symmetric_encryption.cipher = SymmetricEncryption::Cipher.new(
9 | # key: '1234567890ABCDEF',
10 | # iv: '1234567890ABCDEF',
11 | # cipher_name: 'aes-128-cbc'
12 | # )
13 | # end
14 | # end
15 | config.symmetric_encryption = ::SymmetricEncryption
16 |
17 | # Initialize Symmetric Encryption. This will look for a symmetric-encryption.yml in the config
18 | # directory and configure Symmetric Encryption appropriately.
19 | #
20 | # @example symmetric-encryption.yml
21 | #
22 | # development:
23 | # cipher_name: aes-128-cbc
24 | # key: 1234567890ABCDEF
25 | # iv: 1234567890ABCDEF
26 | #
27 | # Loaded before Active Record initializes since database.yml can have encrypted
28 | # passwords in it
29 | config.before_configuration do
30 | # Check if already configured
31 | unless ::SymmetricEncryption.cipher?
32 | parent_method = Module.method_defined?(:module_parent) ? "module_parent" : "parent"
33 | app_name = Rails::Application.subclasses.first.send(parent_method).to_s.underscore
34 | env_var = ENV.fetch("SYMMETRIC_ENCRYPTION_CONFIG", nil)
35 | config_file =
36 | if env_var
37 | Pathname.new(File.expand_path(env_var))
38 | else
39 | Rails.root.join("config", "symmetric-encryption.yml")
40 | end
41 |
42 | if config_file.file?
43 | begin
44 | ::SymmetricEncryption::Config.load!(file_name: config_file, env: ENV["SYMMETRIC_ENCRYPTION_ENV"] || Rails.env)
45 | rescue ArgumentError => e
46 | puts "\nSymmetric Encryption not able to read keys."
47 | puts "#{e.class.name} #{e.message}"
48 | puts "To generate a new config file and key files: symmetric-encryption --generate --app-name #{app_name}\n\n"
49 | raise(e)
50 | end
51 | end
52 |
53 | end
54 | end
55 | end
56 | end
57 |
--------------------------------------------------------------------------------
/test/encoder_test.rb:
--------------------------------------------------------------------------------
1 | require_relative "test_helper"
2 |
3 | # Unit Test for SymmetricEncryption
4 | #
5 | class EncoderTest < Minitest::Test
6 | describe SymmetricEncryption::Encoder do
7 | %i[none base64 base64strict base64urlsafe base16].each do |encoding|
8 | describe "encoding: #{encoding}" do
9 | before do
10 | @data = "987654321ts?>>>"
11 | @data_encoded =
12 | case encoding
13 | when :base64
14 | "OTg3NjU0MzIxdHM/Pj4+\n"
15 | when :base64strict
16 | "OTg3NjU0MzIxdHM/Pj4+"
17 | when :base64urlsafe
18 | "OTg3NjU0MzIxdHM_Pj4-"
19 | when :base16
20 | "39383736353433323174733f3e3e3e"
21 | when :none
22 | @data
23 | end
24 | @encoder = SymmetricEncryption::Encoder[encoding]
25 | @non_utf8 = "\xc2".force_encoding("binary")
26 | end
27 |
28 | it "correctly encodes" do
29 | assert_equal @data_encoded, @encoder.encode(@data)
30 | assert_equal Encoding.find("UTF-8"), @data_encoded.encoding
31 | end
32 |
33 | it "return BINARY encoding for non-UTF-8 data" do
34 | assert_equal Encoding.find("binary"), @non_utf8.encoding
35 | assert @non_utf8.valid_encoding?
36 | assert encoded = @encoder.encode(@non_utf8)
37 | assert decoded = @encoder.decode(encoded)
38 | assert decoded.valid_encoding?
39 | assert_equal Encoding.find("binary"), decoded.encoding, decoded
40 | assert_equal @non_utf8, decoded
41 | end
42 |
43 | it "return nil when encoding nil" do
44 | assert_nil @encoder.encode(nil)
45 | end
46 |
47 | it "return '' when encoding ''" do
48 | assert_equal "", @encoder.encode("")
49 | end
50 |
51 | it "return a new object when encoding" do
52 | assert !@data.equal?(@encoder.encode(@data))
53 | end
54 |
55 | it "return nil when decoding nil" do
56 | assert_nil @encoder.decode(nil)
57 | end
58 |
59 | it "return '' when decoding ''" do
60 | assert_equal "", @encoder.decode("")
61 | end
62 |
63 | it "return a new object when decoding" do
64 | assert !@data_encoded.equal?(@encoder.decode(@data_encoded))
65 | end
66 | end
67 | end
68 | end
69 | end
70 |
--------------------------------------------------------------------------------
/test/utils/aws_test.rb:
--------------------------------------------------------------------------------
1 | require_relative "../test_helper"
2 | require "stringio"
3 |
4 | module SymmetricEncryption
5 | module Utils
6 | class AwsTest < Minitest::Test
7 | describe SymmetricEncryption::Utils::Aws do
8 | before do
9 | unless (ENV.fetch("AWS_ACCESS_KEY_ID", nil) && ENV.fetch("AWS_SECRET_ACCESS_KEY", nil)) || ENV["AWS_CONFIG_FILE"]
10 | # For example: export AWS_CONFIG_FILE=~/.aws/credentials
11 | skip "Set AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY, or AWS_CONFIG_FILE to run AWS KMS tests"
12 | end
13 | end
14 |
15 | let :region do
16 | "us-east-1"
17 | end
18 |
19 | let :master_key_alias do
20 | "alias/symmetric-encryption/test"
21 | end
22 |
23 | let :aws do
24 | SymmetricEncryption::Utils::Aws.new(region: region, master_key_alias: master_key_alias)
25 | end
26 |
27 | describe "#key_spec" do
28 | it "converts aes-256-cbc" do
29 | assert_equal "AES_256", aws.key_spec("aes-256-cbc")
30 | end
31 |
32 | it "converts aes-128-cbc" do
33 | assert_equal "AES_128", aws.key_spec("aes-128-cbc")
34 | end
35 | end
36 |
37 | describe "#create_master_key" do
38 | it "creates a new master key" do
39 | skip "Only run if really needed, gets tested once as part of the CMK auto-create"
40 | aws.delete_master_key(retention_days: 7)
41 | aws.create_master_key
42 | end
43 | end
44 |
45 | describe "#generate_data_key" do
46 | it "creates a new data key" do
47 | assert aws.generate_data_key("aes-128-cbc")
48 | end
49 | end
50 |
51 | describe "#generate_encrypted_data_key" do
52 | it "creates a new data key" do
53 | assert aws.generate_encrypted_data_key("aes-128-cbc")
54 | end
55 | end
56 |
57 | describe "#encrypt" do
58 | it "encrypts a block of data" do
59 | assert aws.encrypt("hello")
60 | end
61 | end
62 |
63 | describe "#decrypt" do
64 | it "decrypts a previously encrypted block of data" do
65 | message = "hello world this is a top secret message"
66 | encrypted = aws.encrypt(message)
67 | decrypted = aws.decrypt(encrypted)
68 | assert_equal message, decrypted
69 | end
70 | end
71 | end
72 | end
73 | end
74 | end
75 |
--------------------------------------------------------------------------------
/test/keystore/gcp_test.rb:
--------------------------------------------------------------------------------
1 | require_relative "../test_helper"
2 |
3 | module SymmetricEncryption
4 | module Keystore
5 | class GcpTest < Minitest::Test
6 | describe "SymmetricEncryption::Keystore::Gcp" do
7 | before do
8 | skip "Set GOOGLE_CLOUD_KEYFILE to run Google Cloud Platform KMS tests" unless ENV["GOOGLE_CLOUD_KEYFILE"]
9 | end
10 |
11 | let(:the_test_path) do
12 | path = "tmp/keystore/gcp_test"
13 | FileUtils.makedirs(path) unless ::File.exist?(path)
14 | path
15 | end
16 |
17 | describe ".generate_data_key" do
18 | after do
19 | # Cleanup generated encryption key files.
20 | `rm #{the_test_path}/* 2> /dev/null`
21 | end
22 |
23 | let :version do
24 | 10
25 | end
26 |
27 | let :key_config do
28 | SymmetricEncryption::Keystore::Gcp.generate_data_key(
29 | key_path: the_test_path,
30 | cipher_name: "aes-256-cbc",
31 | app_name: "tester",
32 | environment: "test",
33 | version: version
34 | )
35 | end
36 |
37 | # TODO: reuse versioning tests from aws_test.rb
38 |
39 | it "creates encrypted key file" do
40 | assert key_path = key_config[:crypto_key]
41 | assert file_name = key_config[:key_file]
42 | expected_file_name = "#{the_test_path}/tester_test_v11.encrypted_key"
43 |
44 | assert_equal expected_file_name, file_name
45 | assert ::File.exist?(file_name)
46 |
47 | assert encoded_data_key = ::File.read(file_name)
48 | encrypted_data_key = Base64.strict_decode64(encoded_data_key)
49 | assert SymmetricEncryption::Keystore::Gcp::KMS::KeyManagementServiceClient.new.decrypt(key_path, encrypted_data_key)
50 | end
51 |
52 | it "is readable by Keystore.read_key" do
53 | assert SymmetricEncryption::Keystore.read_key(key_config)
54 | end
55 | end
56 |
57 | describe "#write, #read" do
58 | let(:keystore) do
59 | SymmetricEncryption::Keystore::Gcp.new(
60 | key_file: "#{the_test_path}/file_1",
61 | app_name: "tester",
62 | environment: "test"
63 | )
64 | end
65 |
66 | it "stores the key" do
67 | keystore.write("TEST")
68 | assert_equal "TEST", keystore.read
69 | end
70 | end
71 | end
72 | end
73 | end
74 | end
75 |
--------------------------------------------------------------------------------
/lib/symmetric_encryption/generator.rb:
--------------------------------------------------------------------------------
1 | module SymmetricEncryption
2 | module Generator
3 | # Common internal method for generating accessors for decrypted accessors
4 | # Primarily used by extensions
5 | def self.generate_decrypted_accessors(model, decrypted_name, encrypted_name, options)
6 | options = options.dup
7 | random_iv = options.delete(:random_iv) || false
8 | compress = options.delete(:compress) || false
9 | type = options.delete(:type) || :string
10 |
11 | unless options.empty?
12 | raise(ArgumentError, "SymmetricEncryption Invalid options #{options.inspect} when encrypting '#{decrypted_name}'")
13 | end
14 | unless SymmetricEncryption::COERCION_TYPES.include?(type)
15 | raise(ArgumentError, "Invalid type: #{type.inspect}. Valid types: #{SymmetricEncryption::COERCION_TYPES.inspect}")
16 | end
17 |
18 | if model.const_defined?(:EncryptedAttributes, _search_ancestors = false)
19 | mod = model.const_get(:EncryptedAttributes)
20 | else
21 | mod = model.const_set(:EncryptedAttributes, Module.new)
22 | model.send(:include, mod)
23 | end
24 |
25 | # Generate getter and setter methods
26 | mod.module_eval(<<~ACCESSORS, __FILE__, __LINE__ + 1)
27 | # Set the un-encrypted field
28 | # Also updates the encrypted field with the encrypted value
29 | # Freeze the decrypted field value so that it is not modified directly
30 | def #{decrypted_name}=(value)
31 | v = SymmetricEncryption::Coerce.coerce(value, :#{type}).freeze
32 | return if (@#{decrypted_name} == v) && !v.nil? && !(v == '')
33 | self.#{encrypted_name} = @stored_#{encrypted_name} = ::SymmetricEncryption.encrypt(v, random_iv: #{random_iv}, compress: #{compress}, type: :#{type}).freeze
34 | @#{decrypted_name} = v
35 | end
36 |
37 | # Returns the decrypted value for the encrypted field
38 | # The decrypted value is cached and is only decrypted if the encrypted value has changed
39 | # If this method is not called, then the encrypted value is never decrypted
40 | def #{decrypted_name}
41 | if !defined?(@stored_#{encrypted_name}) || (@stored_#{encrypted_name} != self.#{encrypted_name})
42 | @#{decrypted_name} = ::SymmetricEncryption.decrypt(self.#{encrypted_name}.freeze, type: :#{type}).freeze
43 | @stored_#{encrypted_name} = self.#{encrypted_name}
44 | end
45 | @#{decrypted_name}
46 | end
47 |
48 | # Map changes to encrypted value to unencrypted equivalent
49 | def #{decrypted_name}_changed?
50 | #{encrypted_name}_changed?
51 | end
52 | ACCESSORS
53 | end
54 | end
55 | end
56 |
--------------------------------------------------------------------------------
/lib/symmetric_encryption/keystore/environment.rb:
--------------------------------------------------------------------------------
1 | module SymmetricEncryption
2 | module Keystore
3 | # Store the encrypted encryption key in an environment variable
4 | class Environment < Memory
5 | attr_accessor :key_env_var, :encoding
6 |
7 | # Returns [Hash] a new keystore configuration after generating the data key.
8 | #
9 | # Increments the supplied version number by 1.
10 | def self.generate_data_key(cipher_name:, app_name:, environment:, version: 0, dek: nil, **_args)
11 | version >= 255 ? (version = 1) : (version += 1)
12 |
13 | kek = SymmetricEncryption::Key.new(cipher_name: cipher_name)
14 | dek ||= SymmetricEncryption::Key.new(cipher_name: cipher_name)
15 |
16 | key_env_var = "#{app_name}_#{environment}_v#{version}".upcase.tr("-", "_")
17 | new(key_env_var: key_env_var, key_encrypting_key: kek).write(dek.key)
18 |
19 | {
20 | keystore: :environment,
21 | cipher_name: dek.cipher_name,
22 | version: version,
23 | key_env_var: key_env_var,
24 | iv: dek.iv,
25 | key_encrypting_key: {
26 | key: kek.key,
27 | iv: kek.iv
28 | }
29 | }
30 | end
31 |
32 | # Stores the Encryption key in an environment var.
33 | # Secures the Encryption key by encrypting it with a key encryption key.
34 | def initialize(key_encrypting_key:, key_env_var:, encoding: :base64strict)
35 | @key_env_var = key_env_var
36 | @key_encrypting_key = key_encrypting_key
37 | @encoding = encoding
38 | end
39 |
40 | # Returns the Encryption key in the clear.
41 | def read
42 | encrypted = ENV.fetch(key_env_var, nil)
43 | raise "The Environment Variable #{key_env_var} must be set with the encrypted encryption key." unless encrypted
44 |
45 | binary = encoder.decode(encrypted)
46 | key_encrypting_key.decrypt(binary)
47 | end
48 |
49 | # Write the encrypted Encryption key to `encrypted_key` attribute.
50 | def write(key)
51 | encrypted_key = key_encrypting_key.encrypt(key)
52 | puts "\n\n********************************************************************************"
53 | puts "Set the environment variable as follows:"
54 | puts " export #{key_env_var}=\"#{encoder.encode(encrypted_key)}\""
55 | puts "********************************************************************************"
56 | end
57 |
58 | private
59 |
60 | # Returns [SymmetricEncryption::Encoder] the encoder to use for the current encoding.
61 | def encoder
62 | @encoder ||= SymmetricEncryption::Encoder[encoding]
63 | end
64 | end
65 | end
66 | end
67 |
--------------------------------------------------------------------------------
/.rubocop.yml:
--------------------------------------------------------------------------------
1 | AllCops:
2 | TargetRubyVersion: 2.5
3 | SuggestExtensions: false
4 | NewCops: enable
5 | Exclude:
6 | - ".git/**/*"
7 | - "docs/**/*"
8 | - "gemfiles/*"
9 | #
10 | # RuboCop built-in settings.
11 | # For documentation on all settings see: https://docs.rubocop.org/en/stable
12 | #
13 |
14 | # Trailing periods.
15 | Layout/DotPosition:
16 | EnforcedStyle: trailing
17 |
18 | # Turn on auto-correction of equals alignment.
19 | Layout/EndAlignment:
20 | AutoCorrect: true
21 |
22 | # Prevent accidental windows line endings
23 | Layout/EndOfLine:
24 | EnforcedStyle: lf
25 |
26 | # Use a table layout for hashes
27 | Layout/HashAlignment:
28 | EnforcedHashRocketStyle: table
29 | EnforcedColonStyle: table
30 |
31 | # Soften limits
32 | Layout/LineLength:
33 | Max: 128
34 | Exclude:
35 | - "**/test/**/*"
36 |
37 | # Match existing layout
38 | Layout/SpaceInsideHashLiteralBraces:
39 | EnforcedStyle: no_space
40 |
41 | # TODO: Soften Limits for phase 1 only
42 | Metrics/AbcSize:
43 | Max: 40
44 |
45 | # Support long block lengths for tests
46 | Metrics/BlockLength:
47 | Exclude:
48 | - "test/**/*"
49 | - "**/*/cli.rb"
50 | AllowedMethods:
51 | - "aasm"
52 | - "included"
53 |
54 | # Soften limits
55 | Metrics/ClassLength:
56 | Max: 250
57 | Exclude:
58 | - "test/**/*"
59 |
60 | # TODO: Soften Limits for phase 1 only
61 | Metrics/CyclomaticComplexity:
62 | Max: 15
63 |
64 | # Soften limits
65 | Metrics/MethodLength:
66 | Max: 50
67 |
68 | # Soften limits
69 | Metrics/ModuleLength:
70 | Max: 250
71 |
72 | Metrics/ParameterLists:
73 | CountKeywordArgs: false
74 |
75 | # TODO: Soften Limits for phase 1 only
76 | Metrics/PerceivedComplexity:
77 | Max: 21
78 |
79 | # Initialization Vector abbreviation
80 | Naming/MethodParameterName:
81 | AllowedNames: ['iv', '_', 'io', 'ap']
82 |
83 | # Does not allow Symbols to load
84 | Security/YAMLLoad:
85 | AutoCorrect: false
86 |
87 | # Needed for testing DateTime
88 | Style/DateTime:
89 | Exclude: ["test/**/*"]
90 |
91 | # TODO: Soften Limits for phase 1 only
92 | Style/Documentation:
93 | Enabled: false
94 |
95 | # One line methods
96 | Style/EmptyMethod:
97 | EnforcedStyle: expanded
98 |
99 | # Ruby 3 compatibility feature
100 | Style/FrozenStringLiteralComment:
101 | Enabled: false
102 |
103 | Style/NumericPredicate:
104 | AutoCorrect: true
105 |
106 | # Incorrectly changes job.fail to job.raise
107 | Style/SignalException:
108 | Enabled: false
109 |
110 | # Since English may not be loaded, cannot force its use.
111 | Style/SpecialGlobalVars:
112 | Enabled: false
113 |
114 | # Make it easier for developers to move between Elixir and Ruby.
115 | Style/StringLiterals:
116 | EnforcedStyle: double_quotes
117 |
--------------------------------------------------------------------------------
/lib/symmetric_encryption/keystore/gcp.rb:
--------------------------------------------------------------------------------
1 | require "google/cloud/kms/v1"
2 |
3 | module SymmetricEncryption
4 | module Keystore
5 | class Gcp
6 | include Utils::Files
7 |
8 | def self.generate_data_key(cipher_name:, app_name:, environment:, key_path:, version: 0)
9 | version >= 255 ? (version = 1) : (version += 1)
10 |
11 | dek = SymmetricEncryption::Key.new(cipher_name: cipher_name)
12 | file_name = "#{key_path}/#{app_name}_#{environment}_v#{version}.encrypted_key"
13 | keystore = new(
14 | key_file: file_name,
15 | app_name: app_name,
16 | environment: environment
17 | )
18 | keystore.write(dek.key)
19 |
20 | {
21 | keystore: :gcp,
22 | cipher_name: dek.cipher_name,
23 | version: version,
24 | key_file: file_name,
25 | iv: dek.iv,
26 | crypto_key: keystore.crypto_key
27 | }
28 | end
29 |
30 | def initialize(key_file:, app_name: nil, environment: nil, key_encrypting_key: nil, crypto_key: nil, project_id: nil,
31 | credentials: nil, location_id: nil)
32 | @crypto_key = crypto_key
33 | @app_name = app_name
34 | @environment = environment
35 | @file_name = key_file
36 | @project_id = project_id
37 | @credentials = credentials
38 | @location_id = location_id
39 | end
40 |
41 | def read
42 | decrypt(read_file_and_decode(file_name))
43 | end
44 |
45 | def write(data_key)
46 | write_encoded_to_file(file_name, encrypt(data_key))
47 | end
48 |
49 | def crypto_key
50 | @crypto_key ||= self.class::KMS::KeyManagementServiceClient.crypto_key_path(project_id, location_id, app_name,
51 | environment.to_s)
52 | end
53 |
54 | private
55 |
56 | KMS = Google::Cloud::Kms::V1
57 |
58 | attr_reader :app_name, :environment
59 |
60 | def encrypt(plaintext)
61 | client.encrypt(crypto_key, plaintext).ciphertext
62 | end
63 |
64 | def decrypt(ciphertext)
65 | client.decrypt(crypto_key, ciphertext).plaintext
66 | end
67 |
68 | def client
69 | self.class::KMS::KeyManagementServiceClient.new(timeout: 2, credentials: credentials)
70 | end
71 |
72 | def project_id
73 | @project_id ||= ENV.fetch("GOOGLE_CLOUD_PROJECT", nil)
74 | raise "GOOGLE_CLOUD_PROJECT must be set" if @project_id.nil?
75 |
76 | @project_id
77 | end
78 |
79 | def credentials
80 | @credentials ||= ENV.fetch("GOOGLE_CLOUD_KEYFILE", nil)
81 | raise "GOOGLE_CLOUD_KEYFILE must be set" if @credentials.nil?
82 |
83 | @credentials
84 | end
85 |
86 | def location_id
87 | @location_id ||= ENV["GOOGLE_CLOUD_LOCATION"] || "global"
88 | end
89 | end
90 | end
91 | end
92 |
--------------------------------------------------------------------------------
/test/writer_test.rb:
--------------------------------------------------------------------------------
1 | require_relative "test_helper"
2 | require "stringio"
3 |
4 | # Unit Test for Symmetric::EncryptedStream
5 | #
6 | class WriterTest < Minitest::Test
7 | describe SymmetricEncryption::Writer do
8 | before do
9 | @data = [
10 | "Hello World\n",
11 | "Keep this secret\n",
12 | "And keep going even further and further..."
13 | ]
14 | @data_str = @data.inject("") { |sum, str| sum << str }
15 | @data_len = @data_str.length
16 | @file_name = "._test"
17 | @source_file_name = "._source_test"
18 | end
19 |
20 | after do
21 | FileUtils.rm_f(@file_name)
22 | FileUtils.rm_f(@source_file_name)
23 | end
24 |
25 | [true, false, nil].each do |compress|
26 | describe "compress: #{compress.inspect}" do
27 | describe ".open" do
28 | it "encrypt to stream" do
29 | written_len = 0
30 | stream = StringIO.new
31 | SymmetricEncryption::Writer.open(stream, compress: compress) do |file|
32 | written_len = @data.inject(0) { |sum, str| sum + file.write(str) }
33 | end
34 | size = stream.string.size
35 | if compress == false
36 | assert @data_len, size
37 | else
38 | # With small files the compressed file is larger
39 | assert size >= @data_len
40 | end
41 | assert_equal @data_len, written_len
42 | end
43 |
44 | it "encrypt to file" do
45 | written_len = SymmetricEncryption::Writer.open(@file_name, compress: compress) do |file|
46 | @data.inject(0) { |sum, str| sum + file.write(str) }
47 | end
48 | assert_equal @data_len, written_len
49 | size = File.size(@file_name)
50 | if compress == false
51 | assert @data_len, size
52 | else
53 | # With small files the compressed file is larger
54 | assert size >= @data_len
55 | end
56 | assert_equal @data_str, SymmetricEncryption::Reader.read(@file_name)
57 | end
58 | end
59 |
60 | describe ".encrypt" do
61 | it "stream" do
62 | target_stream = StringIO.new
63 | source_stream = StringIO.new(@data_str)
64 | source_bytes = SymmetricEncryption::Writer.encrypt(source: source_stream, target: target_stream, compress: compress)
65 | assert_equal @data_len, source_bytes
66 | assert_equal @data_str, SymmetricEncryption::Reader.read(StringIO.new(target_stream.string))
67 | end
68 |
69 | it "file" do
70 | File.binwrite(@source_file_name, @data_str)
71 | source_bytes = SymmetricEncryption::Writer.encrypt(source: @source_file_name, target: @file_name, compress: compress)
72 | assert_equal @data_len, source_bytes
73 | assert_equal @data_str, SymmetricEncryption::Reader.read(@file_name)
74 | end
75 | end
76 | end
77 | end
78 | end
79 | end
80 |
--------------------------------------------------------------------------------
/test/keystore/file_test.rb:
--------------------------------------------------------------------------------
1 | require_relative "../test_helper"
2 | require "stringio"
3 | require "fileutils"
4 |
5 | module SymmetricEncryption
6 | class FileTest < Minitest::Test
7 | describe SymmetricEncryption::Keystore::File do
8 | let :the_test_path do
9 | path = "tmp/keystore/file_test"
10 | FileUtils.makedirs(path) unless ::File.exist?(path)
11 | path
12 | end
13 |
14 | after do
15 | # Cleanup generated encryption key files.
16 | `rm #{the_test_path}/* 2> /dev/null`
17 | end
18 |
19 | describe ".generate_data_key" do
20 | let :version do
21 | 10
22 | end
23 |
24 | let :key_config do
25 | SymmetricEncryption::Keystore::File.generate_data_key(
26 | key_path: the_test_path,
27 | cipher_name: "aes-256-cbc",
28 | app_name: "tester",
29 | environment: "test",
30 | version: version
31 | )
32 | end
33 |
34 | it "increments the version" do
35 | assert_equal 11, key_config[:version]
36 | end
37 |
38 | describe "with 255 version" do
39 | let :version do
40 | 255
41 | end
42 |
43 | it "handles version wrap" do
44 | assert_equal 1, key_config[:version]
45 | end
46 | end
47 |
48 | describe "with 0 version" do
49 | let :version do
50 | 0
51 | end
52 |
53 | it "increments version" do
54 | assert_equal 1, key_config[:version]
55 | end
56 | end
57 |
58 | it "creates the encrypted key file with the correct permissions" do
59 | file_name = "#{the_test_path}/tester_test_v11.encrypted_key"
60 | assert_equal file_name, key_config[:key_filename]
61 | assert File.exist?(file_name)
62 | assert_equal File.stat(file_name).mode.to_s(8), "100600"
63 | end
64 |
65 | it "retains cipher_name" do
66 | assert_equal "aes-256-cbc", key_config[:cipher_name]
67 | end
68 |
69 | it "is readable by Key.from_config" do
70 | key_config.delete(:version)
71 | assert SymmetricEncryption::Keystore.read_key(**key_config)
72 | end
73 | end
74 |
75 | describe "#write, #read" do
76 | let :keystore do
77 | SymmetricEncryption::Keystore::File.new(key_filename: "#{the_test_path}/tester.key", key_encrypting_key: SymmetricEncryption::Key.new)
78 | end
79 |
80 | after do
81 | FileUtils.chmod 0o600, Dir.glob("#{the_test_path}/*")
82 | end
83 |
84 | it "stores the key" do
85 | keystore.write("TEST")
86 | assert_equal "TEST", keystore.read
87 | end
88 |
89 | it "raises an exception when the file can be read/written by others" do
90 | keystore.write("TEST")
91 | FileUtils.chmod 0o666, Dir.glob("#{the_test_path}/*")
92 | assert_raises { keystore.read }
93 | end
94 | end
95 | end
96 | end
97 | end
98 |
--------------------------------------------------------------------------------
/docs/cli.md:
--------------------------------------------------------------------------------
1 | ---
2 | layout: default
3 | ---
4 |
5 | ## Command Line Interface
6 |
7 | If running Symmetric Encryption v3, see [Rake Tasks](rake_tasks.html)
8 |
9 | Symmetric Encryption v4 now uses a standalone command line interface to:
10 | * Encrypt files
11 | * Decrypt files
12 | * Generate new passwords
13 | * Generate a new configuration file
14 | * Perform Key rotation
15 |
16 | If running Symmetric Encryption v3 or earlier, instead use: [Rake Tasks](rake_tasks.html)
17 |
18 | For the complete list of commands run:
19 |
20 | symmetric-encryption --help
21 |
22 | Since each environment has its own encryption keys it is necessary to run the these commands in the corresponding
23 | environment. However, this does not apply to generating the configuration file and to key rotation which can be
24 | run once in one environment and then moved/copied to the relevant environments.
25 |
26 | #### Examples
27 |
28 | Encrypt a file:
29 |
30 | symmetric-encryption --encrypt large_file.csv --output large_file.csv.enc
31 |
32 | Encrypt and compress a file (_Recommended_):
33 |
34 | symmetric-encryption --encrypt large_file.csv --output large_file.csv.enc --compress
35 |
36 | Decrypt a file:
37 |
38 | symmetric-encryption --decrypt large_file.csv.enc --output large_file.csv
39 |
40 | Count the lines in an encrypted file, without creating an unencrypted copy on disk:
41 |
42 | symmetric-encryption --decrypt large_file.csv.enc | wc -l
43 |
44 | Search for lines in an encrypted file, without creating an unencrypted copy on disk:
45 |
46 | symmetric-encryption --decrypt large_file.csv.enc | grep "Hello"
47 |
48 | Display the first few lines in an encrypted file, without creating an unencrypted copy on disk:
49 |
50 | symmetric-encryption --decrypt large_file.csv.enc | head
51 |
52 | Display the last few lines in an encrypted file, without creating an unencrypted copy on disk:
53 |
54 | symmetric-encryption --decrypt large_file.csv.enc | tail
55 |
56 | Generate a random password and display its encrypted form for use in config files, etc.:
57 |
58 | symmetric-encryption --new-password
59 |
60 | Prompt to enter a masked string and then encrypt it:
61 |
62 | symmetric-encryption --encrypt --prompt
63 |
64 | Prompt to enter an encrypted string and then decrypt it:
65 |
66 | symmetric-encryption --decrypt --prompt
67 |
68 | #### Notes
69 |
70 | ##### Highline
71 |
72 | For the `--prompt` option above to work, the `highline` gem must be added to `Gemfile` first and
73 | then installed by running `bundle.
74 |
75 | ~~~ruby
76 | gem install 'highline'
77 | ~~~
78 |
79 | ##### Specify configuration file location
80 |
81 | If the Symmetric Encryption configuration file has a different name or is stored in a directory other than
82 | the standard `config/symmetric-encryption.yml`, then it can be set using the environment variable
83 | `SYMMETRIC_ENCRYPTION_CONFIG`.
84 |
85 | For example set the location of the Symmetric Encryption config file:
86 | ~~~shell
87 | # Specify Symmetric Encryption config file so that it does not need to be specified at the command line every time.
88 | export SYMMETRIC_ENCRYPTION_CONFIG="~/application/common/config/symmetric-encryption.yml"
89 | ~~~
90 |
91 | ### Next => [Key Rotation](key_rotation.html)
92 |
--------------------------------------------------------------------------------
/docs/stylesheets/github-light.css:
--------------------------------------------------------------------------------
1 | /*
2 | Copyright 2014 GitHub Inc.
3 |
4 | Licensed under the Apache License, Version 2.0 (the "License");
5 | you may not use this file except in compliance with the License.
6 | You may obtain a copy of the License at
7 |
8 | http://www.apache.org/licenses/LICENSE-2.0
9 |
10 | Unless required by applicable law or agreed to in writing, software
11 | distributed under the License is distributed on an "AS IS" BASIS,
12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | See the License for the specific language governing permissions and
14 | limitations under the License.
15 |
16 | */
17 |
18 | .pl-c /* comment */ {
19 | color: #969896;
20 | }
21 |
22 | .pl-c1 /* constant, markup.raw, meta.diff.header, meta.module-reference, meta.property-name, support, support.constant, support.variable, variable.other.constant */,
23 | .pl-s .pl-v /* string variable */ {
24 | color: #0086b3;
25 | }
26 |
27 | .pl-e /* entity */,
28 | .pl-en /* entity.name */ {
29 | color: #795da3;
30 | }
31 |
32 | .pl-s .pl-s1 /* string source */,
33 | .pl-smi /* storage.modifier.import, storage.modifier.package, storage.type.java, variable.other, variable.parameter.function */ {
34 | color: #333;
35 | }
36 |
37 | .pl-ent /* entity.name.tag */ {
38 | color: #63a35c;
39 | }
40 |
41 | .pl-k /* keyword, storage, storage.type */ {
42 | color: #a71d5d;
43 | }
44 |
45 | .pl-pds /* punctuation.definition.string, string.regexp.character-class */,
46 | .pl-s /* string */,
47 | .pl-s .pl-pse .pl-s1 /* string punctuation.section.embedded source */,
48 | .pl-sr /* string.regexp */,
49 | .pl-sr .pl-cce /* string.regexp constant.character.escape */,
50 | .pl-sr .pl-sra /* string.regexp string.regexp.arbitrary-repitition */,
51 | .pl-sr .pl-sre /* string.regexp source.ruby.embedded */ {
52 | color: #183691;
53 | }
54 |
55 | .pl-v /* variable */ {
56 | color: #ed6a43;
57 | }
58 |
59 | .pl-id /* invalid.deprecated */ {
60 | color: #b52a1d;
61 | }
62 |
63 | .pl-ii /* invalid.illegal */ {
64 | background-color: #b52a1d;
65 | color: #f8f8f8;
66 | }
67 |
68 | .pl-sr .pl-cce /* string.regexp constant.character.escape */ {
69 | color: #63a35c;
70 | font-weight: bold;
71 | }
72 |
73 | .pl-ml /* markup.list */ {
74 | color: #693a17;
75 | }
76 |
77 | .pl-mh /* markup.heading */,
78 | .pl-mh .pl-en /* markup.heading entity.name */,
79 | .pl-ms /* meta.separator */ {
80 | color: #1d3e81;
81 | font-weight: bold;
82 | }
83 |
84 | .pl-mq /* markup.quote */ {
85 | color: #008080;
86 | }
87 |
88 | .pl-mi /* markup.italic */ {
89 | color: #333;
90 | font-style: italic;
91 | }
92 |
93 | .pl-mb /* markup.bold */ {
94 | color: #333;
95 | font-weight: bold;
96 | }
97 |
98 | .pl-md /* markup.deleted, meta.diff.header.from-file */ {
99 | background-color: #ffecec;
100 | color: #bd2c00;
101 | }
102 |
103 | .pl-mi1 /* markup.inserted, meta.diff.header.to-file */ {
104 | background-color: #eaffea;
105 | color: #55a532;
106 | }
107 |
108 | .pl-mdr /* meta.diff.range */ {
109 | color: #795da3;
110 | font-weight: bold;
111 | }
112 |
113 | .pl-mo /* meta.output */ {
114 | color: #1d3e81;
115 | }
116 |
117 |
--------------------------------------------------------------------------------
/lib/symmetric_encryption/encoder.rb:
--------------------------------------------------------------------------------
1 | module SymmetricEncryption
2 | module Encoder
3 | def self.[](encoding)
4 | case encoding
5 | when :base64
6 | Base64.new
7 | when :base64strict
8 | Base64Strict.new
9 | when :base64urlsafe
10 | Base64UrlSafe.new
11 | when :base16
12 | Base16.new
13 | when :none
14 | None.new
15 | else
16 | raise(ArgumentError, "Unknown encoder: #{encoding.inspect}")
17 | end
18 | end
19 |
20 | def self.encode(binary_string, encoding)
21 | encoder(encoding).encode(binary_string)
22 | end
23 |
24 | def self.decode(encoded_string, encoding)
25 | encoder(encoding).decode(encoded_string)
26 | end
27 |
28 | class None
29 | def encode(binary_string)
30 | binary_string&.dup
31 | end
32 |
33 | def decode(encoded_string)
34 | encoded_string&.dup
35 | end
36 | end
37 |
38 | class Base64
39 | def encode(binary_string)
40 | return binary_string if binary_string.nil? || (binary_string == "")
41 |
42 | encoded_string = ::Base64.encode64(binary_string)
43 | encoded_string.force_encoding(SymmetricEncryption::UTF8_ENCODING)
44 | end
45 |
46 | def decode(encoded_string)
47 | return encoded_string if encoded_string.nil? || (encoded_string == "")
48 |
49 | decoded_string = ::Base64.decode64(encoded_string)
50 | decoded_string.force_encoding(SymmetricEncryption::BINARY_ENCODING)
51 | end
52 | end
53 |
54 | class Base64Strict
55 | def encode(binary_string)
56 | return binary_string if binary_string.nil? || (binary_string == "")
57 |
58 | encoded_string = ::Base64.strict_encode64(binary_string)
59 | encoded_string.force_encoding(SymmetricEncryption::UTF8_ENCODING)
60 | end
61 |
62 | def decode(encoded_string)
63 | return encoded_string if encoded_string.nil? || (encoded_string == "")
64 |
65 | decoded_string = ::Base64.decode64(encoded_string)
66 | decoded_string.force_encoding(SymmetricEncryption::BINARY_ENCODING)
67 | end
68 | end
69 |
70 | class Base64UrlSafe
71 | def encode(binary_string)
72 | return binary_string if binary_string.nil? || (binary_string == "")
73 |
74 | encoded_string = ::Base64.urlsafe_encode64(binary_string)
75 | encoded_string.force_encoding(SymmetricEncryption::UTF8_ENCODING)
76 | end
77 |
78 | def decode(encoded_string)
79 | return encoded_string if encoded_string.nil? || (encoded_string == "")
80 |
81 | decoded_string = ::Base64.urlsafe_decode64(encoded_string)
82 | decoded_string.force_encoding(SymmetricEncryption::BINARY_ENCODING)
83 | end
84 | end
85 |
86 | class Base16
87 | def encode(binary_string)
88 | return binary_string if binary_string.nil? || (binary_string == "")
89 |
90 | encoded_string = binary_string.to_s.unpack1("H*")
91 | encoded_string.force_encoding(SymmetricEncryption::UTF8_ENCODING)
92 | end
93 |
94 | def decode(encoded_string)
95 | return encoded_string if encoded_string.nil? || (encoded_string == "")
96 |
97 | decoded_string = [encoded_string].pack("H*")
98 | decoded_string.force_encoding(SymmetricEncryption::BINARY_ENCODING)
99 | end
100 | end
101 | end
102 | end
103 |
--------------------------------------------------------------------------------
/lib/symmetric_encryption/keystore/file.rb:
--------------------------------------------------------------------------------
1 | module SymmetricEncryption
2 | module Keystore
3 | class File
4 | include Utils::Files
5 | ALLOWED_PERMISSIONS = %w[100600 100400].freeze
6 |
7 | attr_accessor :file_name, :key_encrypting_key
8 |
9 | # Returns [Hash] a new keystore configuration after generating the data key.
10 | #
11 | # Increments the supplied version number by 1.
12 | def self.generate_data_key(key_path:, cipher_name:, app_name:, environment:, version: 0, dek: nil, **_args)
13 | version >= 255 ? (version = 1) : (version += 1)
14 |
15 | dek ||= SymmetricEncryption::Key.new(cipher_name: cipher_name)
16 | kek = SymmetricEncryption::Key.new(cipher_name: cipher_name)
17 | kekek = SymmetricEncryption::Key.new(cipher_name: cipher_name)
18 |
19 | dek_file_name = ::File.join(key_path, "#{app_name}_#{environment}_v#{version}.encrypted_key")
20 | new(key_filename: dek_file_name, key_encrypting_key: kek).write(dek.key)
21 |
22 | kekek_file_name = ::File.join(key_path, "#{app_name}_#{environment}_v#{version}.kekek")
23 | new(key_filename: kekek_file_name).write(kekek.key)
24 |
25 | {
26 | keystore: :file,
27 | cipher_name: dek.cipher_name,
28 | version: version,
29 | key_filename: dek_file_name,
30 | iv: dek.iv,
31 | key_encrypting_key: {
32 | encrypted_key: kekek.encrypt(kek.key),
33 | iv: kek.iv,
34 | key_encrypting_key: {
35 | key_filename: kekek_file_name,
36 | iv: kekek.iv
37 | }
38 | }
39 | }
40 | end
41 |
42 | # Stores the Encryption key in a file.
43 | # Secures the Encryption key by encrypting it with a key encryption key.
44 | def initialize(key_filename:, key_encrypting_key: nil)
45 | @file_name = key_filename
46 | @key_encrypting_key = key_encrypting_key
47 | end
48 |
49 | # Returns the Encryption key in the clear.
50 | def read
51 | unless ::File.exist?(file_name)
52 | raise(SymmetricEncryption::ConfigError,
53 | "Symmetric Encryption key file: '#{file_name}' not found")
54 | end
55 | unless correct_permissions?
56 | raise(SymmetricEncryption::ConfigError,
57 | "Symmetric Encryption key file '#{file_name}' has the wrong " \
58 | "permissions: #{::File.stat(file_name).mode.to_s(8)}. Expected 100600 or 100400.")
59 | end
60 | unless owned?
61 | raise(SymmetricEncryption::ConfigError,
62 | "Symmetric Encryption key file '#{file_name}' has the wrong " \
63 | "owner (#{stat.uid}) or group (#{stat.gid}). " \
64 | "Expected it to be owned by current user " \
65 | "#{ENV['USER'] || ENV.fetch('USERNAME', nil)}.")
66 | end
67 |
68 | data = read_from_file(file_name)
69 | key_encrypting_key ? key_encrypting_key.decrypt(data) : data
70 | end
71 |
72 | # Encrypt and write the key to file.
73 | def write(key)
74 | data = key_encrypting_key ? key_encrypting_key.encrypt(key) : key
75 | write_to_file(file_name, data)
76 | end
77 |
78 | private
79 |
80 | # Returns true if the file is owned by the user running this code and it
81 | # has the correct mode - readable and writable by its owner and no one
82 | # else, much like the keys one has in ~/.ssh
83 | def correct_permissions?
84 | ALLOWED_PERMISSIONS.include?(stat.mode.to_s(8))
85 | end
86 |
87 | def owned?
88 | stat.owned?
89 | end
90 |
91 | def stat
92 | ::File.stat(file_name)
93 | end
94 | end
95 | end
96 | end
97 |
--------------------------------------------------------------------------------
/docs/stylesheets/pygment_trac.css:
--------------------------------------------------------------------------------
1 | .highlight { background: #ffffff; }
2 | .highlight .c { color: #999988; font-style: italic } /* Comment */
3 | .highlight .err { color: #a61717; background-color: #e3d2d2 } /* Error */
4 | .highlight .k { font-weight: bold } /* Keyword */
5 | .highlight .o { font-weight: bold } /* Operator */
6 | .highlight .cm { color: #999988; font-style: italic } /* Comment.Multiline */
7 | .highlight .cp { color: #999999; font-weight: bold } /* Comment.Preproc */
8 | .highlight .c1 { color: #999988; font-style: italic } /* Comment.Single */
9 | .highlight .cs { color: #999999; font-weight: bold; font-style: italic } /* Comment.Special */
10 | .highlight .gd { color: #000000; background-color: #ffdddd } /* Generic.Deleted */
11 | .highlight .gd .x { color: #000000; background-color: #ffaaaa } /* Generic.Deleted.Specific */
12 | .highlight .ge { font-style: italic } /* Generic.Emph */
13 | .highlight .gr { color: #aa0000 } /* Generic.Error */
14 | .highlight .gh { color: #999999 } /* Generic.Heading */
15 | .highlight .gi { color: #000000; background-color: #ddffdd } /* Generic.Inserted */
16 | .highlight .gi .x { color: #000000; background-color: #aaffaa } /* Generic.Inserted.Specific */
17 | .highlight .go { color: #888888 } /* Generic.Output */
18 | .highlight .gp { color: #555555 } /* Generic.Prompt */
19 | .highlight .gs { font-weight: bold } /* Generic.Strong */
20 | .highlight .gu { color: #800080; font-weight: bold; } /* Generic.Subheading */
21 | .highlight .gt { color: #aa0000 } /* Generic.Traceback */
22 | .highlight .kc { font-weight: bold } /* Keyword.Constant */
23 | .highlight .kd { font-weight: bold } /* Keyword.Declaration */
24 | .highlight .kn { font-weight: bold } /* Keyword.Namespace */
25 | .highlight .kp { font-weight: bold } /* Keyword.Pseudo */
26 | .highlight .kr { font-weight: bold } /* Keyword.Reserved */
27 | .highlight .kt { color: #445588; font-weight: bold } /* Keyword.Type */
28 | .highlight .m { color: #009999 } /* Literal.Number */
29 | .highlight .s { color: #d14 } /* Literal.String */
30 | .highlight .na { color: #008080 } /* Name.Attribute */
31 | .highlight .nb { color: #0086B3 } /* Name.Builtin */
32 | .highlight .nc { color: #445588; font-weight: bold } /* Name.Class */
33 | .highlight .no { color: #008080 } /* Name.Constant */
34 | .highlight .ni { color: #800080 } /* Name.Entity */
35 | .highlight .ne { color: #990000; font-weight: bold } /* Name.Exception */
36 | .highlight .nf { color: #990000; font-weight: bold } /* Name.Function */
37 | .highlight .nn { color: #555555 } /* Name.Namespace */
38 | .highlight .nt { color: #000080 } /* Name.Tag */
39 | .highlight .nv { color: #008080 } /* Name.Variable */
40 | .highlight .ow { font-weight: bold } /* Operator.Word */
41 | .highlight .w { color: #bbbbbb } /* Text.Whitespace */
42 | .highlight .mf { color: #009999 } /* Literal.Number.Float */
43 | .highlight .mh { color: #009999 } /* Literal.Number.Hex */
44 | .highlight .mi { color: #009999 } /* Literal.Number.Integer */
45 | .highlight .mo { color: #009999 } /* Literal.Number.Oct */
46 | .highlight .sb { color: #d14 } /* Literal.String.Backtick */
47 | .highlight .sc { color: #d14 } /* Literal.String.Char */
48 | .highlight .sd { color: #d14 } /* Literal.String.Doc */
49 | .highlight .s2 { color: #d14 } /* Literal.String.Double */
50 | .highlight .se { color: #d14 } /* Literal.String.Escape */
51 | .highlight .sh { color: #d14 } /* Literal.String.Heredoc */
52 | .highlight .si { color: #d14 } /* Literal.String.Interpol */
53 | .highlight .sx { color: #d14 } /* Literal.String.Other */
54 | .highlight .sr { color: #009926 } /* Literal.String.Regex */
55 | .highlight .s1 { color: #d14 } /* Literal.String.Single */
56 | .highlight .ss { color: #990073 } /* Literal.String.Symbol */
57 | .highlight .bp { color: #999999 } /* Name.Builtin.Pseudo */
58 | .highlight .vc { color: #008080 } /* Name.Variable.Class */
59 | .highlight .vg { color: #008080 } /* Name.Variable.Global */
60 | .highlight .vi { color: #008080 } /* Name.Variable.Instance */
61 | .highlight .il { color: #009999 } /* Literal.Number.Integer.Long */
62 |
63 | .type-csharp .highlight .k { color: #0000FF }
64 | .type-csharp .highlight .kt { color: #0000FF }
65 | .type-csharp .highlight .nf { color: #000000; font-weight: normal }
66 | .type-csharp .highlight .nc { color: #2B91AF }
67 | .type-csharp .highlight .nn { color: #000000 }
68 | .type-csharp .highlight .s { color: #A31515 }
69 | .type-csharp .highlight .sc { color: #A31515 }
70 |
--------------------------------------------------------------------------------
/docs/stylesheets/print.css:
--------------------------------------------------------------------------------
1 | html, body, div, span, applet, object, iframe,
2 | h1, h2, h3, h4, h5, h6, p, blockquote, pre,
3 | a, abbr, acronym, address, big, cite, code,
4 | del, dfn, em, img, ins, kbd, q, s, samp,
5 | small, strike, strong, sub, sup, tt, var,
6 | b, u, i, center,
7 | dl, dt, dd, ol, ul, li,
8 | fieldset, form, label, legend,
9 | table, caption, tbody, tfoot, thead, tr, th, td,
10 | article, aside, canvas, details, embed,
11 | figure, figcaption, footer, header, hgroup,
12 | menu, nav, output, ruby, section, summary,
13 | time, mark, audio, video {
14 | margin: 0;
15 | padding: 0;
16 | border: 0;
17 | font-size: 100%;
18 | font: inherit;
19 | vertical-align: baseline;
20 | }
21 | /* HTML5 display-role reset for older browsers */
22 | article, aside, details, figcaption, figure,
23 | footer, header, hgroup, menu, nav, section {
24 | display: block;
25 | }
26 | body {
27 | line-height: 1;
28 | }
29 | ol, ul {
30 | list-style: none;
31 | }
32 | blockquote, q {
33 | quotes: none;
34 | }
35 | blockquote:before, blockquote:after,
36 | q:before, q:after {
37 | content: '';
38 | content: none;
39 | }
40 | table {
41 | border-collapse: collapse;
42 | border-spacing: 0;
43 | }
44 | body {
45 | font-size: 13px;
46 | line-height: 1.5;
47 | font-family: 'Helvetica Neue', Helvetica, Arial, serif;
48 | color: #000;
49 | }
50 |
51 | a {
52 | color: #d5000d;
53 | font-weight: bold;
54 | }
55 |
56 | header {
57 | padding-top: 35px;
58 | padding-bottom: 10px;
59 | }
60 |
61 | header h1 {
62 | font-weight: bold;
63 | letter-spacing: -1px;
64 | font-size: 48px;
65 | color: #303030;
66 | line-height: 1.2;
67 | }
68 |
69 | header h2 {
70 | letter-spacing: -1px;
71 | font-size: 24px;
72 | color: #aaa;
73 | font-weight: normal;
74 | line-height: 1.3;
75 | }
76 | #downloads {
77 | display: none;
78 | }
79 | #main_content {
80 | padding-top: 20px;
81 | }
82 |
83 | code, pre {
84 | font-family: Monaco, "Bitstream Vera Sans Mono", "Lucida Console", Terminal;
85 | color: #222;
86 | margin-bottom: 30px;
87 | font-size: 12px;
88 | }
89 |
90 | code {
91 | padding: 0 3px;
92 | }
93 |
94 | pre {
95 | border: solid 1px #ddd;
96 | padding: 20px;
97 | overflow: auto;
98 | }
99 | pre code {
100 | padding: 0;
101 | }
102 |
103 | ul, ol, dl {
104 | margin-bottom: 20px;
105 | }
106 |
107 |
108 | /* COMMON STYLES */
109 |
110 | table {
111 | width: 100%;
112 | border: 1px solid #ebebeb;
113 | }
114 |
115 | th {
116 | font-weight: 500;
117 | }
118 |
119 | td {
120 | border: 1px solid #ebebeb;
121 | text-align: center;
122 | font-weight: 300;
123 | }
124 |
125 | form {
126 | background: #f2f2f2;
127 | padding: 20px;
128 |
129 | }
130 |
131 |
132 | /* GENERAL ELEMENT TYPE STYLES */
133 |
134 | h1 {
135 | font-size: 2.8em;
136 | }
137 |
138 | h2 {
139 | font-size: 22px;
140 | font-weight: bold;
141 | color: #303030;
142 | margin-bottom: 8px;
143 | }
144 |
145 | h3 {
146 | color: #d5000d;
147 | font-size: 18px;
148 | font-weight: bold;
149 | margin-bottom: 8px;
150 | }
151 |
152 | h4 {
153 | font-size: 16px;
154 | color: #303030;
155 | font-weight: bold;
156 | }
157 |
158 | h5 {
159 | font-size: 1em;
160 | color: #303030;
161 | }
162 |
163 | h6 {
164 | font-size: .8em;
165 | color: #303030;
166 | }
167 |
168 | p {
169 | font-weight: 300;
170 | margin-bottom: 20px;
171 | }
172 |
173 | a {
174 | text-decoration: none;
175 | }
176 |
177 | p a {
178 | font-weight: 400;
179 | }
180 |
181 | blockquote {
182 | font-size: 1.6em;
183 | border-left: 10px solid #e9e9e9;
184 | margin-bottom: 20px;
185 | padding: 0 0 0 30px;
186 | }
187 |
188 | ul li {
189 | list-style: disc inside;
190 | padding-left: 20px;
191 | }
192 |
193 | ol li {
194 | list-style: decimal inside;
195 | padding-left: 3px;
196 | }
197 |
198 | dl dd {
199 | font-style: italic;
200 | font-weight: 100;
201 | }
202 |
203 | footer {
204 | margin-top: 40px;
205 | padding-top: 20px;
206 | padding-bottom: 30px;
207 | font-size: 13px;
208 | color: #aaa;
209 | }
210 |
211 | footer a {
212 | color: #666;
213 | }
214 |
215 | /* MISC */
216 | .clearfix:after {
217 | clear: both;
218 | content: '.';
219 | display: block;
220 | visibility: hidden;
221 | height: 0;
222 | }
223 |
224 | .clearfix {display: inline-block;}
225 | * html .clearfix {height: 1%;}
226 | .clearfix {display: block;}
--------------------------------------------------------------------------------
/docs/api.md:
--------------------------------------------------------------------------------
1 | ---
2 | layout: default
3 | ---
4 |
5 | ## SymmetricEncryption API
6 |
7 | ### Quick Test
8 |
9 | Before configuration or generating keys SymmetricEncryption can be used in a
10 | standalone test scenario:
11 |
12 | ~~~ruby
13 | # Use test encryption keys
14 | SymmetricEncryption.cipher = SymmetricEncryption::Cipher.new(
15 | key: '1234567890ABCDEF',
16 | iv: '1234567890ABCDEF',
17 | cipher_name: 'aes-128-cbc'
18 | )
19 |
20 | encrypted = SymmetricEncryption.encrypt('hello world')
21 |
22 | puts SymmetricEncryption.decrypt(encrypted)
23 | ~~~
24 |
25 | ### SymmetricEncryption.encrypt
26 |
27 | Encrypt the supplied string using Symmetric Encryption.
28 |
29 | ~~~ruby
30 | SymmetricEncryption.encrypt(str, random_iv: true, compress: false, type: :string)
31 | ~~~
32 |
33 | - Returns a Base64 encoded string.
34 | - Returns nil if the supplied `str` is nil.
35 | - Returns "" if it is a string and it is empty.
36 |
37 | Parameters
38 |
39 | - `value` `[Object]`
40 | - String to be encrypted. If `str` is not a string, #to_s will be called on it
41 | to convert it to a string.
42 |
43 | - `random_iv` `[true|false]`
44 | - Whether the encrypted value should use a random IV every time the field is encrypted.
45 | - It is recommended to set this to true where feasible. If the encrypted
46 | value could be used as part of a SQL where clause, or as part
47 | of any lookup, then it must be false.
48 | - Setting random_iv to true will result in a different encrypted output for
49 | the same input string.
50 | - Note: Only set to true if the field will never be used as part of
51 | the where clause in an SQL query.
52 | - Note: When random_iv is true it will add a 8 byte header, plus the bytes
53 | to store the random IV in every returned encrypted string, prior to the
54 | encoding if any.
55 | - Default: false
56 | - Highly Recommended where feasible: true
57 |
58 | - `compress` `[true|false]`
59 | - Whether to compress `str` before encryption.
60 | - Should only be used for large strings since compression overhead and
61 | the overhead of adding the 'magic' header may exceed any benefits of
62 | compression.
63 | - Note: Adds a 6 byte header prior to encoding, only if :random_iv is false
64 | Default: false
65 |
66 | - `type` `[:string|:integer|:float|:decimal|:datetime|:time|:date|:boolean]`
67 | - Expected data type of the value to encrypt.
68 | - Uses the coercible gem to coerce non-string values into string values.
69 | - When type is set to :string (the default), uses #to_s to convert
70 | non-string values to string values.
71 | - Note: If type is set to something other than :string, it's expected that
72 | the coercible gem is available in the path.
73 | - Default: :string
74 |
75 | ### SymmetricEncryption.decrypt
76 |
77 | Decrypt string previously encrypted with Symmetric Encryption.
78 |
79 | ~~~ruby
80 | SymmetricEncryption.decrypt(encrypted_and_encoded_string, version=nil, type=:string)
81 | ~~~
82 |
83 | - Returns decrypted value
84 | - On decryption an attempt is made to encode the data as UTF-8, if it fails it
85 | will be returned as BINARY encoded.
86 | - Returns nil if the supplied value is nil.
87 | - Returns "" if it is a string and it is empty.
88 | - Raises OpenSSL::Cipher::CipherError when `str` was not encrypted using
89 | the primary key and iv.
90 |
91 | Parameters:
92 |
93 | - `str` `[String]`
94 | - Encrypted string to decrypt
95 |
96 | - `version` `[Integer]`
97 | - Specify which cipher version to use if no header is present on the encrypted string
98 |
99 | - `type` `[:string|:integer|:float|:decimal|:datetime|:time|:date|:boolean]`
100 | - If value is set to something other than `:string`, then the coercible gem
101 | will be use to coerce the unencrypted string value into the specified
102 | type. This assumes that the value was stored using the same type.
103 | - Note: If type is set to something other than `:string`, it's expected
104 | that the coercible gem is available in the path.
105 | - Default: :string
106 |
107 | If the supplied string has an encryption header then the cipher matching
108 | the version number in the header will be used to decrypt the string.
109 |
110 | When no header is present in the encrypted data, a custom Block/Proc can
111 | be supplied to determine which cipher to use to decrypt the data.
112 | see `#cipher_selector=`.
113 |
114 |
--------------------------------------------------------------------------------
/docs/standalone.md:
--------------------------------------------------------------------------------
1 | ---
2 | layout: default
3 | ---
4 |
5 | ## Configuring a standalone Symmetric Encryption v3 installation
6 |
7 | SymmetricEncryption can also be used standalone and in non-Rails environments.
8 |
9 | Install SymmetricEncryption
10 |
11 | gem install symmetric-encryption
12 |
13 | ### Give it a try
14 |
15 | To tryout Symmetric Encryption standalone and without generating any configuration files or
16 | keys yet:
17 |
18 | Open an `irb` console and run the following code:
19 |
20 | ~~~ruby
21 | require 'symmetric-encryption'
22 | # Test cipher
23 | SymmetricEncryption.cipher = SymmetricEncryption::Cipher.new(
24 | cipher_name: 'aes-128-cbc',
25 | key: '1234567890ABCDEF',
26 | iv: '1234567890ABCDEF',
27 | encoding: :base64strict
28 | )
29 |
30 | encrypted = SymmetricEncryption.encrypt 'Hello World'
31 |
32 | # => "NIuPIXv/ii1IP1dF6T0NpQ=="
33 |
34 | SymmetricEncryption.decrypt(encrypted)
35 |
36 | # => "Hello World"
37 | ~~~
38 |
39 | ### Create configuration file
40 |
41 | Manually create a symmetric-encryption.yml configuration file based on the
42 | one supplied in [examples/symmetric-encryption.yml](https://github.com/reidmorrison/symmetric-encryption/blob/master/examples/symmetric-encryption.yml).
43 |
44 | TODO: Add a command to generate a new stand-alone config file
45 |
46 | ### Development use
47 |
48 | The configuration file above can be used immediately for development and testing purposes as follows:
49 |
50 | ~~~ruby
51 | require 'symmetric-encryption'
52 | SymmetricEncryption.load!('symmetric-encryption.yml', 'development')
53 |
54 | encrypted = SymmetricEncryption.encrypt 'Hello World'
55 |
56 | SymmetricEncryption.decrypt(encrypted)
57 | ~~~
58 |
59 | Parameters:
60 |
61 | * Filename of the configuration file created above
62 | * Name of the environment to load the configuration for
63 |
64 | #### Save to version control
65 |
66 | The configuration file should be checked into the source code control system.
67 | It does Not include the Symmetric Encryption keys themselves.
68 |
69 | ### Generate production keys
70 |
71 | First edit the `symmetric-encryption.yml` configuration file and specify a writable
72 | directory where the files can be written to.
73 |
74 | It is recommended that the step below be run on only one of the production servers.
75 | The generated key files must then be copied to all the production servers.
76 |
77 | Make sure that the current user has read and write access to the folder listed
78 | in the config file option `key_filename` and `iv_filename`.
79 |
80 | To generate the symmetric encryption keys, run the code below in an irb console:
81 |
82 | ~~~ruby
83 | require 'symmetric-encryption'
84 | SymmetricEncryption.generate_symmetric_key_files('symmetric-encryption.yml', 'production')
85 | ~~~
86 |
87 | Parameters:
88 |
89 | * Filename of the configuration file created above
90 | * Name of the environment to load the configuration for
91 |
92 | #### Notes
93 |
94 | * Do not run the key generation step more than once, otherwise new keys will be generated
95 | and any encrypted data will no longer be accessible.
96 | * Do not run the key generation step on more than one server in each environment otherwise
97 | each server will be encrypting with its own key and the servers will not be able
98 | to decrypt data encrypted on another server. Just copy the generated files to each server
99 |
100 | ### Securing the Symmetric Encryption production key files
101 |
102 | The encryption key files must _not_ be checked into the source control system
103 | and must be distributed and managed separately from the source code.
104 |
105 | The symmetric encryption key consists of the key itself and an optional
106 | initialization vector.
107 |
108 | Once the Symmetric Encryption keys have been generated, secure them further by
109 | making the files read-only to the user under which your application will be running and
110 | not readable by any other user.
111 | Change ownership of the keys to your user and only give it access to read the key files:
112 |
113 | In the example below, the application will run under the username `jblogs`
114 |
115 | chown jblogs ~/jblogs/.keys/*
116 | chmod 0400 ~/jblogs/.keys/*
117 |
118 | Change `jblogs` above to the userid under which your Ruby processes are run
119 | and update the path to the one supplied in the config file.
120 |
121 | When running Ruby servers in a particular environment copy the same
122 | key files to every server in that environment. I.e. All Ruby servers in each
123 | environment must run the same encryption keys.
124 |
125 | ### Next => [Key Rotation](key_rotation.html)
126 |
--------------------------------------------------------------------------------
/test/keystore/aws_test.rb:
--------------------------------------------------------------------------------
1 | require_relative "../test_helper"
2 | require "stringio"
3 |
4 | module SymmetricEncryption
5 | module Keystore
6 | class AwsTest < Minitest::Test
7 | describe SymmetricEncryption::Keystore::File do
8 | before do
9 | unless (ENV.fetch("AWS_ACCESS_KEY_ID", nil) && ENV.fetch("AWS_SECRET_ACCESS_KEY", nil)) || ENV["AWS_CONFIG_FILE"]
10 | # For example: export AWS_CONFIG_FILE=~/.aws/credentials
11 | skip "Set AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY, or AWS_CONFIG_FILE to run AWS KMS tests"
12 | end
13 | end
14 |
15 | let :the_test_path do
16 | path = "tmp/keystore/aws_test"
17 | FileUtils.makedirs(path) unless ::File.exist?(path)
18 | path
19 | end
20 |
21 | after do
22 | # Cleanup generated encryption key files.
23 | `rm #{the_test_path}/* 2> /dev/null`
24 | end
25 |
26 | let :regions do
27 | %w[us-east-1 us-east-2]
28 | end
29 |
30 | let :version do
31 | 10
32 | end
33 |
34 | let :key_config do
35 | SymmetricEncryption::Keystore::Aws.generate_data_key(
36 | regions: regions,
37 | key_path: the_test_path,
38 | cipher_name: "aes-256-cbc",
39 | app_name: "tester",
40 | environment: "test",
41 | version: version
42 | )
43 | end
44 |
45 | let :master_key_alias do
46 | "alias/symmetric-encryption/test"
47 | end
48 |
49 | describe ".generate_data_key" do
50 | it "increments the version" do
51 | assert_equal 11, key_config[:version]
52 | end
53 |
54 | describe "with 255 version" do
55 | let :version do
56 | 255
57 | end
58 |
59 | it "handles version wrap" do
60 | assert_equal 1, key_config[:version]
61 | end
62 | end
63 |
64 | describe "with 0 version" do
65 | let :version do
66 | 0
67 | end
68 |
69 | it "increments version" do
70 | assert_equal 1, key_config[:version]
71 | end
72 | end
73 |
74 | it "creates encrypted key file for every region" do
75 | assert key_files = key_config[:key_files]
76 | common_data_key = nil
77 | first_encrypted_data_key = nil
78 |
79 | master_key_alias = "alias/symmetric-encryption/tester/test"
80 |
81 | key_files.each do |key_file|
82 | assert region = key_file[:region]
83 | assert file_name = key_file[:file_name]
84 | expected_file_name = "#{the_test_path}/tester_test_#{region}_v11.encrypted_key"
85 |
86 | assert_equal expected_file_name, file_name
87 | assert ::File.exist?(file_name)
88 |
89 | assert encoded_data_key = ::File.read(file_name)
90 | encrypted_data_key = Base64.strict_decode64(encoded_data_key)
91 |
92 | aws = SymmetricEncryption::Utils::Aws.new(region: region, master_key_alias: master_key_alias)
93 | assert data_key = aws.decrypt(encrypted_data_key)
94 |
95 | # Verify that the dek is the same in every region, but encrypted with the CMK for that region.
96 | if common_data_key
97 | refute_equal encrypted_data_key, first_encrypted_data_key, "Must be encrypted with region specific CMK"
98 | assert_equal common_data_key, data_key, "All regions must have the same data key"
99 | else
100 | first_encrypted_data_key = encrypted_data_key
101 | common_data_key = data_key
102 | end
103 | end
104 | end
105 |
106 | it "retains cipher_name" do
107 | assert_equal "aes-256-cbc", key_config[:cipher_name]
108 | end
109 |
110 | it "is readable by Keystore.read_key" do
111 | ENV["AWS_REGION"] = "us-east-1"
112 | assert SymmetricEncryption::Keystore.read_key(key_config)
113 | end
114 | end
115 |
116 | describe "#write, #read" do
117 | let :keystore do
118 | SymmetricEncryption::Keystore::Aws.new(
119 | region: "us-east-1",
120 | master_key_alias: master_key_alias,
121 | key_files: [{region: "us-east-1", file_name: "#{the_test_path}/file_1"}]
122 | )
123 | end
124 |
125 | it "stores the key" do
126 | keystore.write("TEST")
127 | assert_equal "TEST", keystore.read
128 | end
129 | end
130 | end
131 | end
132 | end
133 | end
134 |
--------------------------------------------------------------------------------
/lib/symmetric_encryption/railties/mongoid_encrypted.rb:
--------------------------------------------------------------------------------
1 | require "mongoid"
2 | # Add :encrypted option for Mongoid models
3 | #
4 | # Example:
5 | #
6 | # require 'mongoid'
7 | # require 'symmetric-encryption'
8 | #
9 | # # Initialize Mongoid in a standalone environment. In a Rails app this is not required
10 | # Mongoid.logger = Logger.new($stdout)
11 | # Mongoid.load!('config/mongoid.yml')
12 | #
13 | # # Initialize SymmetricEncryption in a standalone environment. In a Rails app this is not required
14 | # SymmetricEncryption::Config.load!('config/symmetric-encryption.yml', 'test')
15 | #
16 | # class Person
17 | # include Mongoid::Document
18 | #
19 | # field :name, type: String
20 | # field :encrypted_social_security_number, type: String, encrypted: true
21 | # field :date_of_birth, type: Date
22 | # field :encrypted_life_history, type: String, encrypted: {compress: true, random_iv: true}
23 | #
24 | # # Encrypted fields are _always_ stored in Mongo as a String
25 | # # To get the result back as an Integer, Symmetric Encryption can do the
26 | # # necessary conversions by specifying the internal type as an option
27 | # # to :encrypted
28 | # # #see SymmetricEncryption::COERCION_TYPES for full list of types
29 | # field :encrypted_age, type: String, encrypted: {type: :integer, random_iv: true}
30 | # end
31 | #
32 | # The above document results in the following document in the Mongo collection 'persons':
33 | # {
34 | # 'name' : 'Joe',
35 | # 'encrypted_social_security_number' : '...',
36 | # 'age' : 21
37 | # 'encrypted_life_history' : '...',
38 | # }
39 | #
40 | # Symmetric Encryption creates the getters and setters to be able to work with the field
41 | # in it's unencrypted form. For example
42 | #
43 | # Example:
44 | # person = Person.where(encrypted_social_security_number: '...').first
45 | #
46 | # puts "Decrypted Social Security Number is: #{person.social_security_number}"
47 | #
48 | # # Or is the same as
49 | # puts "Decrypted Social Security Number is: #{SymmetricEncryption.decrypt(person.encrypted_social_security_number)}"
50 | #
51 | # # Sets the encrypted_social_security_number to encrypted version
52 | # person.social_security_number = '123456789'
53 | #
54 | # # Or, is equivalent to:
55 | # person.encrypted_social_security_number = SymmetricEncryption.encrypt('123456789')
56 | #
57 | # Note: Only 'String' types are currently supported for encryption
58 | #
59 | # Note: Unlike attr_encrypted finders must use the encrypted field name
60 | # Invalid Example, does not work:
61 | # person = Person.where(social_security_number: '123456789').first
62 | #
63 | # Valid Example:
64 | # person = Person.where(encrypted_social_security_number: SymmetricEncryption.encrypt('123456789')).first
65 | #
66 | # Defines all the fields that are accessible on the Document
67 | # For each field that is defined, a getter and setter will be
68 | # added as an instance method to the Document.
69 | #
70 | # @example Define a field.
71 | # field :social_security_number, type: String, encrypted: {compress: false, random_iv: false}
72 | # field :sensitive_text, type: String, encrypted: {compress: true, random_iv: true}
73 | #
74 | # @param [ Symbol ] name The name of the field.
75 | # @param [ Hash ] options The options to pass to the field.
76 | #
77 | # @option options [ Boolean | Hash ] :encrypted If the field contains encrypted data.
78 | # When :encrypted is a Hash it consists of:
79 | # @option options [ Symbol ] :type The type for this field, #see SymmetricEncryption::COERCION_TYPES
80 | # @option options [ Boolean ] :random_iv Whether the encrypted value should use a random IV every time the field is encrypted.
81 | # @option options [ Boolean ] :compress Whether to compress this encrypted field
82 | # @option options [ Symbol ] :decrypt_as Name of the getters and setters to generate to access the decrypted value of this field.
83 | #
84 | # Some of the other regular Mongoid options:
85 | #
86 | # @option options [ Class ] :type The type of the field.
87 | # @option options [ String ] :label The label for the field.
88 | # @option options [ Object, Proc ] :default The fields default
89 | #
90 | # @return [ Field ] The generated field
91 | Mongoid::Fields.option :encrypted do |model, field, options|
92 | if options != false
93 | options = options.is_a?(Hash) ? options.dup : {}
94 | encrypted_field_name = field.name
95 |
96 | # Support overriding the name of the decrypted attribute
97 | decrypted_field_name = options.delete(:decrypt_as)
98 | if decrypted_field_name.nil? && encrypted_field_name.to_s.start_with?("encrypted_")
99 | decrypted_field_name = encrypted_field_name.to_s[("encrypted_".length)..-1]
100 | end
101 |
102 | if decrypted_field_name.nil?
103 | raise(ArgumentError,
104 | "SymmetricEncryption for Mongoid. Encryption enabled for field #{encrypted_field_name}. It must either start with 'encrypted_' or the option :decrypt_as must be supplied")
105 | end
106 |
107 | SymmetricEncryption::Generator.generate_decrypted_accessors(model, decrypted_field_name, encrypted_field_name, options)
108 | end
109 | end
110 |
--------------------------------------------------------------------------------
/lib/symmetric_encryption/utils/aws.rb:
--------------------------------------------------------------------------------
1 | require "base64"
2 | require "aws-sdk-kms"
3 | module SymmetricEncryption
4 | module Utils
5 | # Wrap the AWS KMS client so that it automatically creates the Customer Master Key,
6 | # if one does not already exist.
7 | #
8 | # Map OpenSSL cipher names to AWS KMS key specs.
9 | class Aws
10 | attr_reader :master_key_alias, :client
11 |
12 | AWS_US_REGIONS = %w[us-east-1 us-east-2 us-west-1 us-west-2].freeze
13 |
14 | # TODO: Map to OpenSSL ciphers
15 | AWS_KEY_SPEC_MAP = {
16 | "aes-256-cbc" => "AES_256",
17 | "aes-128-cbc" => "AES_128"
18 | }.freeze
19 |
20 | # TODO: Move to Keystore::Aws
21 | # Rotate the Customer Master key in each of the supplied regions.
22 | # After the master key has been rotated, use `.write_key_files` to generate
23 | # a new DEK and re-encrypt with the new CMK in each region.
24 | # def self.rotate_master_key(master_key_alias:, cipher_name:, regions: AWS_US_REGIONS)
25 | # Array(regions).collect do |region|
26 | # key_manager = new(region: region, master_key_alias: master_key_alias, cipher_name: cipher_name)
27 | # key_id = key_manager.create_master_key
28 | # key_manager.create_alias(key_id)
29 | # end
30 | # end
31 |
32 | def initialize(region:, master_key_alias:)
33 | # Can region be read from environment?
34 | # Region is required for filename / env var name
35 | @client = ::Aws::KMS::Client.new(region: region)
36 | @master_key_alias = master_key_alias
37 | end
38 |
39 | # Returns a new DEK encrypted using the CMK
40 | def generate_encrypted_data_key(cipher_name)
41 | auto_create_master_key do
42 | client.generate_data_key_without_plaintext(key_id: master_key_alias, key_spec: key_spec(cipher_name)).ciphertext_blob
43 | end
44 | end
45 |
46 | # Returns a new DEK in the clear
47 | def generate_data_key(cipher_name)
48 | auto_create_master_key do
49 | client.generate_data_key(key_id: master_key_alias, key_spec: key_spec(cipher_name)).plaintext
50 | end
51 | end
52 |
53 | # Decrypt data previously encrypted using the cmk
54 | def decrypt(encrypted_data)
55 | auto_create_master_key do
56 | client.decrypt(ciphertext_blob: encrypted_data).plaintext
57 | end
58 | end
59 |
60 | # Decrypt data previously encrypted using the cmk
61 | def encrypt(data)
62 | auto_create_master_key do
63 | client.encrypt(key_id: master_key_alias, plaintext: data).ciphertext_blob
64 | end
65 | end
66 |
67 | # Returns the AWS KMS key spec that matches the supplied OpenSSL cipher name
68 | def key_spec(cipher_name)
69 | key_spec = AWS_KEY_SPEC_MAP[cipher_name]
70 | raise("OpenSSL Cipher: #{cipher_name} has not yet been mapped to an AWS key spec.") unless key_spec
71 |
72 | key_spec
73 | end
74 |
75 | # Creates a new master key along with an alias that points to it.
76 | # Returns [String] the new master key id that was created.
77 | def create_master_key
78 | key_id = create_new_master_key
79 | create_alias(key_id)
80 | key_id
81 | end
82 |
83 | # Deletes the current master key and its alias.
84 | #
85 | # retention_days: Number of days to keep the CMK before completely destroying it.
86 | #
87 | # NOTE:
88 | # Use with caution, only intended for testing purposes !!!
89 | def delete_master_key(retention_days: 30)
90 | key_info = client.describe_key(key_id: master_key_alias)
91 | ap key_info
92 | resp = client.schedule_key_deletion(key_id: key_info.key_metadata.key_id, pending_window_in_days: retention_days)
93 | ap client.delete_alias(alias_name: master_key_alias)
94 | resp.deletion_date
95 | rescue ::Aws::KMS::Errors::NotFoundException
96 | nil
97 | end
98 |
99 | private
100 |
101 | def whoami
102 | @whoami ||= `whoami`.strip
103 | rescue StandardError
104 | @whoami = "unknown"
105 | end
106 |
107 | # Creates a new Customer Master Key for Symmetric Encryption use.
108 | def create_new_master_key
109 | # TODO: Add error handling and retry
110 |
111 | resp = client.create_key(
112 | description: "Symmetric Encryption for Ruby Customer Masker Key",
113 | tags: [
114 | {tag_key: "CreatedAt", tag_value: Time.now.to_s},
115 | {tag_key: "CreatedBy", tag_value: whoami}
116 | ]
117 | )
118 | resp.key_metadata.key_id
119 | end
120 |
121 | def create_alias(key_id)
122 | # TODO: Add error handling and retry
123 | # TODO: Move existing alias if any
124 | client.create_alias(alias_name: master_key_alias, target_key_id: key_id)
125 | end
126 |
127 | def auto_create_master_key
128 | attempt = 1
129 | yield
130 | rescue ::Aws::KMS::Errors::NotFoundException
131 | raise if attempt >= 2
132 |
133 | create_master_key
134 | attempt += 1
135 | retry
136 | end
137 | end
138 | end
139 | end
140 |
--------------------------------------------------------------------------------
/test/active_record/encrypted_attribute_test.rb:
--------------------------------------------------------------------------------
1 | require_relative "../test_helper"
2 |
3 | ActiveRecord::Base.configurations = YAML.safe_load(ERB.new(File.read("test/config/database.yml")).result)
4 | ActiveRecord::Base.establish_connection(:test)
5 |
6 | ActiveRecord::Schema.define version: 0 do
7 | create_table :people, force: true do |t|
8 | t.string :name
9 | t.string :age
10 | t.string :address
11 | t.string :integer_value
12 | t.string :float_value
13 | t.string :decimal_value
14 | t.string :datetime_value
15 | t.string :time_value
16 | t.string :date_value
17 | t.string :true_value
18 | t.string :false_value
19 | end
20 | end
21 |
22 | class Person < ActiveRecord::Base
23 | attribute :name, :encrypted, random_iv: false
24 | attribute :age, :encrypted, type: :integer, random_iv: false
25 | attribute :address, :encrypted
26 | attribute :integer_value, :encrypted, type: :integer
27 | attribute :float_value, :encrypted, type: :float
28 | attribute :decimal_value, :encrypted, type: :decimal
29 | attribute :datetime_value, :encrypted, type: :datetime
30 | attribute :time_value, :encrypted, type: :time
31 | attribute :date_value, :encrypted, type: :date
32 | attribute :true_value, :encrypted, type: :boolean
33 | attribute :false_value, :encrypted, type: :boolean
34 | end
35 |
36 | class EncryptedAttributeTest < Minitest::Test
37 | describe "SymmetricEncryption::ActiveRecord::EncryptedAttribute" do
38 | before do
39 | skip "Custom attribute types support starting from Rails 5" if ActiveRecord.version < Gem::Version.new("5.0.0")
40 | Person.delete_all
41 | end
42 |
43 | let(:person_name) { "Abcd Efgh" }
44 | let(:encrypted_name) { "QEVuQwIAsvPWRoF61GxkAr5+f+eTfg==" }
45 | let(:age) { 23 }
46 | let(:encrypted_age) { "QEVuQwIA/YvnMQ8QAoDpiOaIAmrUkg==" }
47 | let(:address) { "Some test value" }
48 |
49 | let(:integer_value) { 13_456 }
50 | let(:float_value) { 88.12345 }
51 | let(:decimal_value) { BigDecimal("22.51") }
52 | let(:datetime_value) { DateTime.new(2001, 11, 26, 20, 55, 54, "-5") }
53 | let(:time_value) { Time.new(2013, 1, 1, 22, 30, 0, "-04:00") }
54 | let(:date_value) { Date.new(1927, 4, 2) }
55 |
56 | let :person do
57 | Person.create(
58 | name: person_name,
59 | age: age,
60 | address: address,
61 | integer_value: integer_value,
62 | float_value: float_value,
63 | decimal_value: decimal_value,
64 | datetime_value: datetime_value,
65 | time_value: time_value,
66 | date_value: date_value,
67 | true_value: true,
68 | false_value: false
69 | )
70 | end
71 |
72 | it "stores encrypted string value" do
73 | assert_equal encrypted_name, person.read_attribute_before_type_cast(:name)
74 | end
75 |
76 | it "reads unencrypted string value" do
77 | assert_equal person_name, person.reload.name
78 | end
79 |
80 | it "stores encrypted age value" do
81 | assert_equal encrypted_age, person.read_attribute_before_type_cast(:age)
82 | end
83 |
84 | it "reads unencrypted integer value" do
85 | assert_equal age, person.reload.age
86 | end
87 |
88 | it "stores nil value" do
89 | person = Person.create(name: nil)
90 | assert_nil person.reload.name
91 | assert_nil person.read_attribute_before_type_cast(:name)
92 | end
93 |
94 | it "stores a value which can later be decrypted" do
95 | person = Person.create(address: address)
96 | encrypted_address = person.read_attribute_before_type_cast(:address)
97 | assert_equal address, SymmetricEncryption.decrypt(encrypted_address)
98 | end
99 |
100 | it "uses different iv each time" do
101 | person.update(address: address)
102 | address1 = person.read_attribute_before_type_cast(:address)
103 | person.update(address: address)
104 | address2 = person.read_attribute_before_type_cast(:address)
105 | iv1 = SymmetricEncryption.header(address1).iv
106 | iv2 = SymmetricEncryption.header(address2).iv
107 | refute_equal iv1, iv2
108 | end
109 |
110 | it "reports whether it has changed" do
111 | person.name # Call field so decryption happens
112 | assert !person.name_changed?
113 |
114 | person.name = "Abcde fghij"
115 | assert person.name_changed?
116 | end
117 |
118 | it "reports whether it has changed since last save" do
119 | person.reload
120 | person.name # Call field so decryption happens
121 | assert !person.saved_change_to_name?
122 |
123 | person.update!(address: "Some other test value")
124 | assert !person.saved_change_to_name?
125 | assert person.saved_change_to_address?
126 | end
127 |
128 | describe "types" do
129 | it "serializes" do
130 | assert_equal person_name, person.name
131 | assert_equal age, person.age
132 | assert_equal address, person.address
133 |
134 | assert_equal integer_value, person.integer_value
135 | assert_equal float_value, person.float_value
136 | assert_equal decimal_value, person.decimal_value
137 | assert_equal datetime_value, person.datetime_value
138 | assert_equal time_value, person.time_value
139 | assert_equal date_value, person.date_value
140 | assert_equal true, person.true_value
141 | assert_equal false, person.false_value
142 | end
143 | end
144 | end
145 | end
146 |
--------------------------------------------------------------------------------
/lib/symmetric_encryption/active_record/attr_encrypted.rb:
--------------------------------------------------------------------------------
1 | module SymmetricEncryption
2 | module ActiveRecord
3 | module AttrEncrypted
4 | def self.included(base)
5 | base.extend ClassMethods
6 | end
7 |
8 | module ClassMethods
9 | # Transparently encrypt and decrypt values stored via ActiveRecord.
10 | #
11 | # Parameters:
12 | # * Symbolic names of each method to create which has a corresponding
13 | # method already defined in rails starting with: encrypted_
14 | # * Followed by an optional hash:
15 | # :random_iv [true|false]
16 | # Whether the encrypted value should use a random IV every time the
17 | # field is encrypted.
18 | # It is recommended to set this to true where feasible. If the encrypted
19 | # value could be used as part of a SQL where clause, or as part
20 | # of any lookup, then it must be false.
21 | # Setting random_iv to true will result in a different encrypted output for
22 | # the same input string.
23 | # Note: Only set to true if the field will never be used as part of
24 | # the where clause in an SQL query.
25 | # Note: When random_iv is true it will add a 8 byte header, plus the bytes
26 | # to store the random IV in every returned encrypted string, prior to the
27 | # encoding if any.
28 | # Default: false
29 | # Highly Recommended where feasible: true
30 | #
31 | # :type [Symbol]
32 | # The type for this field, #see SymmetricEncryption::COERCION_TYPES
33 | # Default: :string
34 | #
35 | # :compress [true|false]
36 | # Whether to compress str before encryption
37 | # Should only be used for large strings since compression overhead and
38 | # the overhead of adding the 'magic' header may exceed any benefits of
39 | # compression
40 | # Note: Adds a 6 byte header prior to encoding, only if :random_iv is false
41 | # Default: false
42 | def attr_encrypted(*attributes, random_iv: nil, type: :string, compress: false)
43 | # Ensure ActiveRecord has created all its methods first
44 | # Ignore failures since the table may not yet actually exist
45 | begin
46 | define_attribute_methods
47 | rescue StandardError
48 | nil
49 | end
50 |
51 | random_iv = true if random_iv.nil? && SymmetricEncryption.randomize_iv?
52 |
53 | if random_iv.nil?
54 | warn("attr_encrypted() no longer allows a default value for option `random_iv`. Add `random_iv: false` if it is required.")
55 | end
56 |
57 | attributes.each do |attribute|
58 | SymmetricEncryption::Generator.generate_decrypted_accessors(
59 | self,
60 | attribute,
61 | "encrypted_#{attribute}",
62 | random_iv: random_iv,
63 | type: type,
64 | compress: compress
65 | )
66 | encrypted_attributes[attribute.to_sym] = "encrypted_#{attribute}".to_sym
67 | end
68 | end
69 |
70 | # Contains a hash of encrypted attributes with virtual attribute names as keys and real attribute
71 | # names as values
72 | #
73 | # Example
74 | #
75 | # class User < ActiveRecord::Base
76 | # attr_encrypted :email
77 | # end
78 | #
79 | # User.encrypted_attributes => { email: encrypted_email }
80 | def encrypted_attributes
81 | @encrypted_attributes ||= superclass.respond_to?(:encrypted_attributes) ? superclass.encrypted_attributes.dup : {}
82 | end
83 |
84 | # Return the name of all encrypted virtual attributes as an Array of symbols
85 | # Example: [:email, :password]
86 | def encrypted_keys
87 | @encrypted_keys ||= encrypted_attributes.keys
88 | end
89 |
90 | # Return the name of all encrypted columns as an Array of symbols
91 | # Example: [:encrypted_email, :encrypted_password]
92 | def encrypted_columns
93 | @encrypted_columns ||= encrypted_attributes.values
94 | end
95 |
96 | # Returns whether an attribute has been configured to be encrypted
97 | #
98 | # Example
99 | #
100 | # class User < ActiveRecord::Base
101 | # attr_accessor :name
102 | # attr_encrypted :email
103 | # end
104 | #
105 | # User.encrypted_attribute?(:name) # false
106 | # User.encrypted_attribute?(:email) # true
107 | def encrypted_attribute?(attribute)
108 | encrypted_keys.include?(attribute)
109 | end
110 |
111 | # Returns whether the attribute is the database column to hold the
112 | # encrypted data for a matching encrypted attribute
113 | #
114 | # Example
115 | #
116 | # class User < ActiveRecord::Base
117 | # attr_accessor :name
118 | # attr_encrypted :email
119 | # end
120 | #
121 | # User.encrypted_column?(:encrypted_name) # false
122 | # User.encrypted_column?(:encrypted_email) # true
123 | def encrypted_column?(attribute)
124 | encrypted_columns.include?(attribute)
125 | end
126 | end
127 | end
128 | end
129 | end
130 |
--------------------------------------------------------------------------------
/lib/symmetric_encryption/utils/re_encrypt_files.rb:
--------------------------------------------------------------------------------
1 | # Used for re-encrypting encrypted passwords stored in configuration files.
2 | #
3 | # Search for any encrypted value and re-encrypt it using the latest encryption key.
4 | # Note:
5 | # * Only works with encrypted values that have the standard header.
6 | # * The search looks for the header and then replaces the encrypted value.
7 | #
8 | # Example:
9 | # re_encrypt = SymmetricEncryption::Utils::ReEncryptFiles.new(version: 4)
10 | # re_encrypt.process_directory('../../**/*.yml')
11 | #
12 | # Notes:
13 | # * Only supports the output from encrypting data.
14 | # * I.e. Manually adding newlines to base 64 output is not supported.
15 | # * For now only supports one encrypted value per line.
16 | module SymmetricEncryption
17 | module Utils
18 | # ReEncrypt files
19 | #
20 | # If a file is encrypted, it is re-encrypted with the cipher that has the highest version number.
21 | # A file that is already encrypted with the specified key version is not re-encrypted.
22 | # If an encrypted value cannot be decypted in the current environment it is left unmodified.
23 | #
24 | # If a file is not encrypted, the file is searched for any encrypted values, and those values are re-encrypted.
25 | #
26 | # symmetric_encryption --reencrypt "**/*.yml"
27 | class ReEncryptFiles
28 | attr_accessor :cipher, :version
29 |
30 | # Parameters:
31 | # version: [Integer]
32 | # Version of the encryption key to use when re-encrypting the value.
33 | # Default: Default cipher ( first in the list of configured ciphers )
34 | def initialize(version: SymmetricEncryption.cipher.version)
35 | @version = version || SymmetricEncryption.cipher.version
36 | @cipher = SymmetricEncryption.cipher(@version)
37 | raise(ArgumentError, "Undefined encryption key version: #{version}") if @cipher.nil?
38 | end
39 |
40 | # Re-encrypt the supplied encrypted value with the new cipher
41 | def re_encrypt(encrypted)
42 | if (unencrypted = SymmetricEncryption.try_decrypt(encrypted))
43 | cipher.encrypt(unencrypted)
44 | else
45 | encrypted
46 | end
47 | end
48 |
49 | # Process a single file.
50 | #
51 | # Returns [Integer] number of encrypted values re-encrypted.
52 | def re_encrypt_contents(file_name)
53 | return 0 if File.size(file_name) > 256 * 1024
54 |
55 | lines = File.read(file_name)
56 | hits, output_lines = re_encrypt_lines(lines)
57 |
58 | File.binwrite(file_name, output_lines) if hits.positive?
59 | hits
60 | end
61 |
62 | # Replaces instances of encrypted data within lines of text with re-encrypted values
63 | def re_encrypt_lines(lines)
64 | hits = 0
65 | output_lines = ""
66 | r = regexp
67 | lines.each_line do |line|
68 | line.force_encoding(SymmetricEncryption::UTF8_ENCODING)
69 | output_lines <<
70 | if line.valid_encoding? && (result = line.match(r))
71 | encrypted = result[0]
72 | new_value = re_encrypt(encrypted)
73 | if new_value == encrypted
74 | line
75 | else
76 | hits += 1
77 | line.gsub(encrypted, new_value)
78 | end
79 | else
80 | line
81 | end
82 | end
83 | [hits, output_lines]
84 | end
85 |
86 | # Re Encrypt an entire file
87 | def re_encrypt_file(file_name)
88 | temp_file_name = "__re_encrypting_#{file_name}"
89 | SymmetricEncryption::Reader.open(file_name) do |source|
90 | SymmetricEncryption::Writer.encrypt(source: source, target: temp_file_name, compress: true, version: version)
91 | end
92 | File.delete(file_name)
93 | File.rename(temp_file_name, file_name)
94 | rescue StandardError
95 | File.delete(temp_file_name) if temp_file_name && File.exist?(temp_file_name)
96 | raise
97 | end
98 |
99 | # Process a directory of files.
100 | #
101 | # Parameters:
102 | # path: [String]
103 | # Search path to look for files in.
104 | # Example: '../../**/*.yml'
105 | def process_directory(path)
106 | Dir[path].each do |file_name|
107 | next if File.directory?(file_name)
108 |
109 | if (v = encrypted_file_version(file_name))
110 | if v == version
111 | puts "Skipping already re-encrypted file: #{file_name}"
112 | else
113 | puts "Re-encrypting entire file: #{file_name}"
114 | re_encrypt_file(file_name)
115 | end
116 | else
117 | begin
118 | count = re_encrypt_contents(file_name)
119 | puts "Re-encrypted #{count} encrypted value(s) in: #{file_name}" if count.positive?
120 | rescue StandardError => e
121 | puts "Failed re-encrypting the file contents of: #{file_name}. #{e.class.name}: #{e.message}"
122 | end
123 | end
124 | end
125 | end
126 |
127 | private
128 |
129 | def regexp
130 | @regexp ||= %r{#{SymmetricEncryption.cipher.encoded_magic_header}([A-Za-z0-9+/]+[=\\n]*)}
131 | end
132 |
133 | # Returns [Integer] encrypted file key version.
134 | # Returns [nil] if the file is not encrypted or does not have a header.
135 | def encrypted_file_version(file_name)
136 | ::File.open(file_name, "rb") do |file|
137 | reader = SymmetricEncryption::Reader.new(file)
138 | reader.version if reader.header_present?
139 | end
140 | rescue OpenSSL::Cipher::CipherError
141 | nil
142 | end
143 | end
144 | end
145 | end
146 |
--------------------------------------------------------------------------------
/docs/heroku.md:
--------------------------------------------------------------------------------
1 | ---
2 | layout: default
3 | ---
4 |
5 | ## Configuring Symmetric Encryption v3 for Heroku
6 |
7 |
8 | ### Add to Gemfile
9 |
10 | Add the following line to your Gemfile _after_ the rails gems:
11 |
12 | ~~~ruby
13 | gem 'symmetric-encryption'
14 | ~~~
15 |
16 | Install using bundler:
17 |
18 | bundle
19 |
20 | ### Generate configuration file
21 |
22 | Deploying to Heroku requires the encrypted key to be stored in an environment
23 | variable rather than as a file on disk.
24 |
25 | Generate the configuration file:
26 |
27 | rails g symmetric_encryption:heroku_config
28 |
29 | The output would be similar to the following, except your keys will be different:
30 |
31 | ~~~
32 | Symmetric Encryption config not found.
33 | To generate one for the first time: rails generate symmetric_encryption:config
34 |
35 | create config/symmetric-encryption.yml
36 |
37 |
38 | ********************************************************************************
39 | Add the release environment key to Heroku: (Optional)
40 |
41 | heroku config:add RELEASE_KEY1=NmWJ6QF7tpVphtkVAWo0dloPQdhSkQ/GNR+TTScM4UKqzLGH/6I9Gc2eT3Odau6vRwJwR8G9G1wwrrIxmIKA4SD9+WF8r8gZTFqc8SR61gkCzbpKOG2MrPFZN84Y96A9C+qPU7tGHRQwhbnPyjkjdZVVIrp8oW1DipzmxeRV5KYAJu9hoGkkd6vMmV9hVTjyAlTzgqtv1I/9olaNDPtiPLNddfG8xB2rP5pmzqkRZUZ1ihe5b9ecb+1q0N0OVV3V9NbftqKG+yb8DbPkGkA2Mraj464PA6LLYkJv7+ffLvZAf4zlv0BPaXLbx31/Zwb07j+Qx/e+m43UvdSWFHUghQ==
42 |
43 | Add the production key to Heroku:
44 |
45 | heroku config:add PRODUCTION_KEY1=dnqpGTng7QNOXOkGqqUAmSdQbL8Dp8nf2qa3JoUbeYpNTELKX1o/HeSNADL4Btr7dLrdonUJvwqRp1B9EtVFRaNJBqkrKC4/0FI+km6LrAa36QGwqHXZ6XBMGoqSJ4smgIF1YgxTeZfRGMDwJ+szq7RuNSNdRd+jHQvJ8TEQYte/3oFoYkHxQVCdOIdmdhPebiqk6snRRvbilitGEnAbUTHQGzkpf8cEdCv8qfecIQoJDvDSWUzEMJ+gMm80W26xBxlfd72Raog61R5Vu5l/bv5X7+pHvtRio9xr+/HS2y+YNFNH52oUOu2dMcBcV7AFsIgSY06xtBF9fO53WcIVqA==
46 |
47 | ********************************************************************************
48 | ~~~
49 |
50 | #### Notes
51 |
52 | * Ignore the warning about `Symmetric Encryption config not found` since it is
53 | being generated.
54 | * The encrypted keys for the release and production environments are displayed on
55 | screen and must be entered manually as environment variables into Heroku so that the
56 | application can find them when it starts.
57 |
58 | Follow the onscreen instructions to add the environment keys to Heroku.
59 | The release environment is optional.
60 |
61 | ### Save to version control
62 |
63 | The generated configuration file should be checked into the source code control system.
64 | It does Not include the Symmetric Encryption keys.
65 |
66 | ## Supporting multiple production encryption keys
67 |
68 | To create multiple encryption keys in production where each heroku application has its own encryption key, this can be done as follows:
69 |
70 | Generate the configuration file which includes the RSA key to unlock the encryption key, as well as the first encryption key:
71 |
72 | ~~~
73 | $ rails g symmetric_encryption:heroku_config
74 |
75 | Symmetric Encryption config not found.
76 | To generate one for the first time: rails generate symmetric_encryption:config
77 |
78 | create config/symmetric-encryption.yml
79 |
80 |
81 | ********************************************************************************
82 | Add the release environment key to Heroku: (Optional)
83 |
84 | heroku config:add RELEASE_KEY1=a9NwhS6Wv/Kd2ltGkO9/5mqT6yPA5YcnRWicAYU8d7Lc71sIxWq41wyL8h/jLKMUZfe2wUU/4lv0PfTJ8E6Or+5zNaFLWwuygzZgWFB2a0lyIVetV7pLgSq1ndFCzgbOoPzTSk9HL5FsJEXJgvFckPp+OP1+QUfRuYXyZ8YzMvgq33sBWNciB4W583BuOvBwvx2OT9apmIyWE9NU3+3axHq89NJs3Yo2Yg7tNVxsCBlkxhtOq6glmpoTHIxv3HPmGbG0o1rD6K94DkcWs9iV8UhxTn2l2bh/ejaNWmgJRLcECo+/y1KkChe5xiUI+TptEnNPWvDbosanAQFj94RkLg==
85 |
86 | Add the production key to Heroku:
87 |
88 | heroku config:add PRODUCTION_KEY1=nqNxlfsq/XX4ffoF2Z7APAe2778pKVrJsxEG63PwnU+f6IUUwhj1S1v8iHgSIAibqW85EDWT3m1RCUPw7tAXyr3J4HpfgyTheJTIIV3RQDXC09l0Mk9n1xS77tS4xIBX+YRRuA0PYF/bHMezi69Khie6o+VL0/GKpo/Pkhrhwb/Hel90A2f+stuhrl/aXWHnM9vsKFG6Ufrusg1ZQejuoburzmQqYVorI/BVvufTxNq72stRWdKruTZlgKTEP5LMSxps28jnh4X4bZXU2StbTkOFJzGEBhDWhpepXrUgXZ+3MHHaTg45ZSj+LUFil1pBPgZKaBDDad1ATTfaXwNLJg==
89 |
90 | ********************************************************************************
91 |
92 | ~~~
93 |
94 | Then generate new additional keys for the other heroku applications:
95 |
96 | ~~~
97 | $ rails generate symmetric_encryption:new_keys production
98 |
99 | Generated new Symmetric Key for encryption. Set the KEY environment variable in production to:
100 | IVFlzQP604dlD98Tj94gJzAqqmD2ZFGlScbqiUCJgMYZrfhDymxm+LO2TtIP
101 | +tSq4fnXfuNbMlCkTCmyNUkXlJU9VC2oGIvt4aW/wZgaNac8jsjfZQa59w3d
102 | IaNuzvy9DAEskFQhmbHSCIemgAIvsyKjJ46CKOO9c8UifIlA/fSe89HhlwHJ
103 | e2rJj4K8hOCKonxvnIY2DbJLa78+THVN25AQMjRq3ISZjlULxYn9chpGTuTB
104 | KKQ8w9mdnqwpkr6wQVL5zCQLv6yIdVZrp/EHWoBk5tfChWUmB97mY5I3vogk
105 | JbwCtvOPpumiaeORimo+cDHoRGFDK1ACVeWg1hRkvQ==
106 | ~~~
107 |
108 | The above output needs to be reformatted to remove the newlines and to include the appropriate heroku command.
109 | For example the above output would be added as follows:
110 |
111 | ~~~
112 | heroku config:add PRODUCTION_KEY1=IVFlzQP604dlD98Tj94gJzAqqmD2ZFGlScbqiUCJgMYZrfhDymxm+LO2TtIP+tSq4fnXfuNbMlCkTCmyNUkXlJU9VC2oGIvt4aW/wZgaNac8jsjfZQa59w3dIaNuzvy9DAEskFQhmbHSCIemgAIvsyKjJ46CKOO9c8UifIlA/fSe89HhlwHJe2rJj4K8hOCKonxvnIY2DbJLa78+THVN25AQMjRq3ISZjlULxYn9chpGTuTBKKQ8w9mdnqwpkr6wQVL5zCQLv6yIdVZrp/EHWoBk5tfChWUmB97mY5I3vogkJbwCtvOPpumiaeORimo+cDHoRGFDK1ACVeWg1hRkvQ==
113 | ~~~
114 |
115 | The above step can be run as many times as need to generate new encrypted symmetric keys. Old ones can be discarded if not used.
116 |
117 | ### Securing the Symmetric Encryption production keys
118 |
119 | The production encryption keys added to your Heroku configuration are only as secure as your
120 | Heroku account and password are.
121 |
122 | #### Note
123 |
124 | * Heroku administrators that have access to your Heroku environment variables will have full
125 | access to your encryption keys, and can therefore decrypt your encrypted data.
126 |
127 | ### Next => [Standalone Configuration](standalone.html)
128 |
--------------------------------------------------------------------------------
/test/header_test.rb:
--------------------------------------------------------------------------------
1 | require_relative "test_helper"
2 |
3 | class CipherTest < Minitest::Test
4 | describe SymmetricEncryption::Header do
5 | let :clear_value do
6 | "Hello World"
7 | end
8 |
9 | let :random_iv do
10 | false
11 | end
12 |
13 | let :compress do
14 | false
15 | end
16 |
17 | let :binary_encrypted_value do
18 | SymmetricEncryption.cipher.binary_encrypt(clear_value, random_iv: random_iv, compress: compress)
19 | end
20 |
21 | let :header do
22 | header = SymmetricEncryption::Header.new
23 | header.parse(binary_encrypted_value)
24 | header
25 | end
26 |
27 | describe "#new" do
28 | it "sets defaults" do
29 | header = SymmetricEncryption::Header.new
30 | assert_equal SymmetricEncryption.cipher.version, header.version
31 | refute header.compressed?
32 | refute header.iv
33 | refute header.key
34 | refute header.cipher_name
35 | refute header.auth_tag
36 | end
37 | end
38 |
39 | describe ".present?" do
40 | it "has a header" do
41 | assert SymmetricEncryption::Header.present?(binary_encrypted_value)
42 | end
43 |
44 | it "does not have a header" do
45 | refute SymmetricEncryption::Header.present?(clear_value)
46 | end
47 |
48 | it "does not have a header when nil" do
49 | refute SymmetricEncryption::Header.present?(nil)
50 | end
51 |
52 | it "does not have a header when empty string" do
53 | refute SymmetricEncryption::Header.present?("")
54 | end
55 | end
56 |
57 | describe "#cipher" do
58 | it "returns the global cipher used to encrypt the value" do
59 | assert_equal SymmetricEncryption.cipher, header.cipher
60 | end
61 | end
62 |
63 | describe "#version" do
64 | it "returns the global cipher used to encrypt the value" do
65 | assert_equal SymmetricEncryption.cipher.version, header.version
66 | end
67 | end
68 |
69 | describe "#cipher_name" do
70 | it "returns nil when cipher name was not overridden" do
71 | assert_nil header.cipher_name
72 | end
73 | end
74 |
75 | describe "#key" do
76 | it "returns nil when key was not overridden" do
77 | assert_nil header.key
78 | end
79 | end
80 |
81 | describe "#compress" do
82 | it "encrypted string" do
83 | refute header.compressed?
84 | end
85 |
86 | describe "with compression" do
87 | let :compress do
88 | true
89 | end
90 |
91 | it "encrypted string" do
92 | assert header.compressed?
93 | end
94 | end
95 | end
96 |
97 | describe "#to_s" do
98 | end
99 |
100 | describe "#parse" do
101 | it "nil string" do
102 | header = SymmetricEncryption::Header.new
103 | assert_equal 0, header.parse(nil)
104 | end
105 |
106 | it "empty string" do
107 | header = SymmetricEncryption::Header.new
108 | assert_equal 0, header.parse("")
109 | end
110 |
111 | it "unencrypted string" do
112 | header = SymmetricEncryption::Header.new
113 | assert_equal 0, header.parse("hello there")
114 | end
115 |
116 | it "encrypted string" do
117 | header = SymmetricEncryption::Header.new
118 | assert_equal 6, header.parse(binary_encrypted_value)
119 | end
120 |
121 | describe "with random_iv" do
122 | let :random_iv do
123 | true
124 | end
125 |
126 | it "encrypted string" do
127 | header = SymmetricEncryption::Header.new
128 | assert_equal 24, header.parse(binary_encrypted_value)
129 | end
130 |
131 | describe "with compression" do
132 | let :compress do
133 | true
134 | end
135 |
136 | it "encrypted string" do
137 | assert header.compressed?
138 | end
139 | end
140 | end
141 | end
142 |
143 | describe "#parse!" do
144 | it "nil string" do
145 | header = SymmetricEncryption::Header.new
146 | assert_nil header.parse!(nil)
147 | end
148 |
149 | it "empty string" do
150 | header = SymmetricEncryption::Header.new
151 | assert_nil header.parse!("")
152 | end
153 |
154 | it "unencrypted string" do
155 | header = SymmetricEncryption::Header.new
156 | assert_nil header.parse!("hello there")
157 | end
158 |
159 | it "encrypted string" do
160 | header = SymmetricEncryption::Header.new
161 | remainder = header.parse!(binary_encrypted_value.dup)
162 | assert_equal SymmetricEncryption.cipher.version, header.version
163 | refute header.compressed?
164 | refute header.iv
165 | refute header.key
166 | refute header.cipher_name
167 | refute header.auth_tag
168 |
169 | # Decrypt with this new header
170 | encrypted_without_header = SymmetricEncryption.cipher.binary_encrypt(clear_value, header: false)
171 | assert_equal encrypted_without_header, remainder
172 |
173 | assert_equal clear_value, SymmetricEncryption.cipher.binary_decrypt(remainder, header: header)
174 | end
175 |
176 | describe "with random_iv" do
177 | let :random_iv do
178 | true
179 | end
180 |
181 | it "encrypted string" do
182 | header = SymmetricEncryption::Header.new
183 | assert remainder = header.parse!(binary_encrypted_value)
184 | assert_equal SymmetricEncryption.cipher.version, header.version
185 | refute header.compressed?
186 | assert header.iv
187 | refute header.key
188 | refute header.cipher_name
189 | refute header.auth_tag
190 | assert_equal clear_value, SymmetricEncryption.cipher.binary_decrypt(remainder, header: header)
191 | end
192 | end
193 | end
194 |
195 | describe "#iv" do
196 | it "encrypted string" do
197 | header = SymmetricEncryption::Header.new
198 | header.parse(binary_encrypted_value)
199 | assert_nil header.iv
200 | end
201 |
202 | describe "with random_iv" do
203 | let :random_iv do
204 | true
205 | end
206 |
207 | it "encrypted string" do
208 | assert header.iv
209 | refute_equal SymmetricEncryption.cipher.iv, header.iv
210 | end
211 | end
212 | end
213 | end
214 | end
215 |
--------------------------------------------------------------------------------
/lib/symmetric_encryption/keystore/aws.rb:
--------------------------------------------------------------------------------
1 | require "aws-sdk-kms"
2 | module SymmetricEncryption
3 | module Keystore
4 | # Support AWS Key Management Service (KMS)
5 | #
6 | # Terms:
7 | # Aws
8 | # Amazon Web Services.
9 | #
10 | # CMK
11 | # Customer Master Key.
12 | # Master key to encrypt and decrypt data, specifically the DEK in this case.
13 | # Stored in AWS, cannot be exported.
14 | #
15 | # DEK
16 | # Data Encryption Key.
17 | # Key used to encrypt local data.
18 | # Encrypted with the CMK and stored locally.
19 | #
20 | # KMS
21 | # Key Management Service.
22 | # For generating and storing the CMK.
23 | # Used to encrypt and decrypt the DEK.
24 | #
25 | # Recommended reading:
26 | #
27 | # Concepts:
28 | # https://docs.aws.amazon.com/kms/latest/developerguide/concepts.html
29 | #
30 | # Overview:
31 | # https://docs.aws.amazon.com/kms/latest/developerguide/overview.html
32 | #
33 | # Process:
34 | # 1. Create a customer master key (CMK) along with an alias for use by Symmetric Encryption.
35 | # - Note: CMK is region specific.
36 | # - Stored exclusively in AWS KMS, cannot be exported.
37 | #
38 | # 2. Generate and encrypt a data encryption key (DEK).
39 | # - CMK is used to encrypt the DEK.
40 | # - Encrypted DEK is stored locally.
41 | # - Encrypted DEK is region specific.
42 | # - DEK can be shared, but then must be re-encrypted in each region.
43 | # - Shared DEK across regions for database replication.
44 | # - List of regions to publish DEK to during generation / key-rotation.
45 | # - DEK must be encrypted with CMK in each region consecutively.
46 | #
47 | # Warning:
48 | # If access to the AWS KMS is ever lost, then it is not possible to decrypt any encrypted data.
49 | # Examples:
50 | # - Loss of access to AWS accounts.
51 | # - Loss of region(s) in which master keys are stored.
52 | class Aws
53 | include Utils::Files
54 |
55 | attr_reader :region, :key_files, :master_key_alias
56 |
57 | # Returns [Hash] a new keystore configuration after generating the data key.
58 | #
59 | # Increments the supplied version number by 1.
60 | #
61 | # Sample Hash layout returned:
62 | # {
63 | # cipher_name: aes-256-cbc,
64 | # version: 8,
65 | # keystore: :aws,
66 | # master_key_alias: 'alias/symmetric-encryption/application/production',
67 | # key_files: [
68 | # {region: blah1, file_name: "~/symmetric-encryption/application_production_blah1_v6.encrypted_key"},
69 | # {region: blah2, file_name: "~/symmetric-encryption/application_production_blah2_v6.encrypted_key"},
70 | # ],
71 | # iv: 'T80pYzD0E6e/bJCdjZ6TiQ=='
72 | # }
73 | def self.generate_data_key(cipher_name:, app_name:, environment:, key_path:, version: 0,
74 | regions: Utils::Aws::AWS_US_REGIONS,
75 | dek: nil,
76 | **_args)
77 |
78 | # TODO: Also support generating environment variables instead of files.
79 |
80 | version >= 255 ? (version = 1) : (version += 1)
81 | regions = Array(regions).dup
82 |
83 | master_key_alias = master_key_alias(app_name, environment)
84 |
85 | # File per region for holding the encrypted data key
86 | key_files = regions.collect do |region|
87 | file_name = "#{app_name}_#{environment}_#{region}_v#{version}.encrypted_key"
88 | {region: region, file_name: ::File.join(key_path, file_name)}
89 | end
90 |
91 | keystore = new(key_files: key_files, master_key_alias: master_key_alias)
92 | unless dek
93 | data_key = keystore.aws(regions.first).generate_data_key(cipher_name)
94 | dek = Key.new(key: data_key, cipher_name: cipher_name)
95 | end
96 | keystore.write(dek.key)
97 |
98 | {
99 | keystore: :aws,
100 | cipher_name: dek.cipher_name,
101 | version: version,
102 | master_key_alias: master_key_alias,
103 | key_files: key_files,
104 | iv: dek.iv
105 | }
106 | end
107 |
108 | # Alias pointing to the active version of the master key for that region.
109 | def self.master_key_alias(app_name, environment)
110 | @master_key_alias ||= "alias/symmetric-encryption/#{app_name}/#{environment}"
111 | end
112 |
113 | # Stores the Encryption key in a file.
114 | # Secures the Encryption key by encrypting it with a key encryption key.
115 | def initialize(key_files:, master_key_alias:, region: nil, key_encrypting_key: nil)
116 | @key_files = key_files
117 | @master_key_alias = master_key_alias
118 | @region = region || ENV["AWS_REGION"] || ENV["AWS_DEFAULT_REGION"] || ::Aws.config[:region]
119 | return unless key_encrypting_key
120 |
121 | raise(SymmetricEncryption::ConfigError,
122 | "AWS KMS keystore encrypts the key itself, so does not support supplying a key_encrypting_key")
123 | end
124 |
125 | # Reads the data key environment variable, if present, otherwise a file.
126 | # Decrypts the key using the master key for this region.
127 | def read
128 | key_file = key_files.find { |i| i[:region] == region }
129 | raise(SymmetricEncryption::ConfigError, "region: #{region} not available in the supplied key_files") unless key_file
130 |
131 | file_name = key_file[:file_name]
132 |
133 | encrypted_data_key = read_file_and_decode(file_name)
134 | aws(region).decrypt(encrypted_data_key)
135 | end
136 |
137 | # Encrypt and write the data key to the file for each region.
138 | def write(data_key)
139 | key_files.each do |key_file|
140 | region = key_file[:region]
141 | file_name = key_file[:file_name]
142 |
143 | raise(ArgumentError, "region and file_name are mandatory for each key_file entry") unless region && file_name
144 |
145 | encrypted_data_key = aws(region).encrypt(data_key)
146 | write_encoded_to_file(file_name, encrypted_data_key)
147 | end
148 | end
149 |
150 | def aws(region)
151 | Utils::Aws.new(region: region, master_key_alias: master_key_alias)
152 | end
153 | end
154 | end
155 | end
156 |
--------------------------------------------------------------------------------
/lib/symmetric_encryption/config.rb:
--------------------------------------------------------------------------------
1 | require "erb"
2 | require "yaml"
3 | module SymmetricEncryption
4 | class Config
5 | attr_reader :file_name, :env
6 |
7 | # Load the Encryption Configuration from a YAML file.
8 | #
9 | # file_name:
10 | # Name of configuration file.
11 | # Default: "#{Rails.root}/config/symmetric-encryption.yml"
12 | # Note:
13 | # The Symmetric Encryption config file name can also be set using the `SYMMETRIC_ENCRYPTION_CONFIG`
14 | # environment variable.
15 | #
16 | # env:
17 | # Which environments config to load. Usually: production, development, etc.
18 | # Non-Rails apps can set env vars: RAILS_ENV, or RACK_ENV
19 | # Default: Rails.env || ENV['RACK_ENV'] || ENV['RAILS_ENV'] || 'development'
20 | def self.load!(file_name: nil, env: nil)
21 | config = new(file_name: file_name, env: env)
22 | ciphers = config.ciphers
23 | SymmetricEncryption.cipher = ciphers.shift
24 | SymmetricEncryption.secondary_ciphers = ciphers
25 | true
26 | end
27 |
28 | # Reads the entire configuration for all environments from the supplied file name.
29 | def self.read_file(file_name)
30 | config = load_yaml(ERB.new(File.new(file_name).read).result)
31 | config = deep_symbolize_keys(config)
32 | config.each_pair { |_env, cfg| SymmetricEncryption::Config.send(:migrate_old_formats!, cfg) }
33 | config
34 | end
35 |
36 | # Write the entire configuration for all environments to the supplied file name.
37 | def self.write_file(file_name, config)
38 | config = deep_stringify_keys(config)
39 |
40 | FileUtils.mkdir_p(File.dirname(file_name))
41 | File.open(file_name, "w") do |f|
42 | f.puts "# This file was auto generated by symmetric-encryption."
43 | f.puts "# Recommend using symmetric-encryption to make changes."
44 | f.puts "# For more info, run:"
45 | f.puts "# symmetric-encryption --help"
46 | f.puts "#"
47 | f.write(config.to_yaml)
48 | end
49 | end
50 |
51 | # Load the Encryption Configuration from a YAML file.
52 | #
53 | # See: `.load!` for parameters.
54 | def initialize(file_name: nil, env: nil)
55 | env ||= defined?(Rails) ? Rails.env : ENV["RACK_ENV"] || ENV["RAILS_ENV"] || "development"
56 |
57 | unless file_name
58 | root = defined?(Rails) ? Rails.root : "."
59 | file_name =
60 | if (env_var = ENV.fetch("SYMMETRIC_ENCRYPTION_CONFIG", nil))
61 | File.expand_path(env_var)
62 | else
63 | File.join(root, "config", "symmetric-encryption.yml")
64 | end
65 | raise(ConfigError, "Cannot find config file: #{file_name}") unless File.exist?(file_name)
66 | end
67 |
68 | @env = env
69 | @file_name = file_name
70 | end
71 |
72 | # Returns [Hash] the configuration for the supplied environment.
73 | def config
74 | @config ||=
75 | begin
76 | raise(ConfigError, "Cannot find config file: #{file_name}") unless File.exist?(file_name)
77 |
78 | env_config = self.class.load_yaml(ERB.new(File.new(file_name).read).result)[env]
79 | raise(ConfigError, "Cannot find environment: #{env} in config file: #{file_name}") unless env_config
80 |
81 | env_config = self.class.send(:deep_symbolize_keys, env_config)
82 | self.class.send(:migrate_old_formats!, env_config)
83 | end
84 | end
85 |
86 | # Returns [Array(SymmetricEncryption::Cipher)] ciphers specified in the configuration file.
87 | def ciphers
88 | @ciphers ||= config[:ciphers].collect { |cipher_config| Cipher.from_config(**cipher_config) }
89 | end
90 |
91 | # Iterate through the Hash symbolizing all keys.
92 | def self.deep_symbolize_keys(object)
93 | case object
94 | when Hash
95 | result = {}
96 | object.each_pair do |key, value|
97 | key = key.to_sym if key.is_a?(String)
98 | result[key] = deep_symbolize_keys(value)
99 | end
100 | result
101 | when Array
102 | object.collect { |i| deep_symbolize_keys(i) }
103 | else
104 | object
105 | end
106 | end
107 |
108 | private_class_method :deep_symbolize_keys
109 |
110 | # Iterate through the Hash symbolizing all keys.
111 | def self.deep_stringify_keys(object)
112 | case object
113 | when Hash
114 | result = {}
115 | object.each_pair do |key, value|
116 | key = key.to_s if key.is_a?(Symbol)
117 | result[key] = deep_stringify_keys(value)
118 | end
119 | result
120 | when Array
121 | object.collect { |i| deep_stringify_keys(i) }
122 | else
123 | object
124 | end
125 | end
126 |
127 | private_class_method :deep_stringify_keys
128 |
129 | # Migrate old configuration format for this environment
130 | def self.migrate_old_formats!(config)
131 | # Inline single cipher before :ciphers
132 | unless config.key?(:ciphers)
133 | inline_cipher = {}
134 | config.each_key { |key| inline_cipher[key] = config.delete(key) }
135 | config[:ciphers] = [inline_cipher]
136 | end
137 |
138 | # Copy Old :private_rsa_key into each ciphers config
139 | # Cipher.from_config replaces it with the RSA Kek
140 | if config[:private_rsa_key]
141 | private_rsa_key = config.delete(:private_rsa_key)
142 | config[:ciphers].each { |cipher| cipher[:private_rsa_key] = private_rsa_key }
143 | end
144 |
145 | # Old :cipher_name
146 | config[:ciphers].each do |cipher|
147 | if (old_key_name_cipher = cipher.delete(:cipher))
148 | cipher[:cipher_name] = old_key_name_cipher
149 | end
150 |
151 | # Only temporarily used during v4 Beta process
152 | cipher[:private_rsa_key] = cipher.delete(:key_encrypting_key) if cipher[:key_encrypting_key].is_a?(String)
153 |
154 | # Check for a prior env var in encrypted key
155 | # Example:
156 | # encrypted_key: <%= ENV['VAR'] %>
157 | if cipher.key?(:encrypted_key) && cipher[:encrypted_key].nil?
158 | cipher[:key_env_var] = :placeholder
159 | puts "WARNING: :encrypted_key resolved to nil. Please see the migrated config file for the new option :key_env_var."
160 | end
161 | end
162 | config
163 | end
164 |
165 | private_class_method :migrate_old_formats!
166 |
167 | def self.load_yaml(src)
168 | return YAML.safe_load(src, permitted_classes: [Symbol], aliases: true) if Psych::VERSION.to_i >= 4
169 |
170 | YAML.load(src)
171 | end
172 | end
173 | end
174 |
--------------------------------------------------------------------------------
/docs/stylesheets/stylesheet.css:
--------------------------------------------------------------------------------
1 | * {
2 | box-sizing: border-box; }
3 |
4 | body {
5 | padding: 0;
6 | margin: 0;
7 | font-family: "Open Sans", "Helvetica Neue", Helvetica, Arial, sans-serif;
8 | font-size: 16px;
9 | line-height: 1.5;
10 | color: #606c71; }
11 |
12 | a {
13 | color: #1e6bb8;
14 | text-decoration: none; }
15 | a:hover {
16 | text-decoration: underline; }
17 |
18 | .btn {
19 | display: inline-block;
20 | margin-bottom: 0.4rem;
21 | color: rgba(255, 255, 255, 0.7);
22 | background-color: rgba(255, 255, 255, 0.08);
23 | border-color: rgba(255, 255, 255, 0.2);
24 | border-style: solid;
25 | border-width: 1px;
26 | border-radius: 0.3rem;
27 | transition: color 0.2s, background-color 0.2s, border-color 0.2s; }
28 | .btn + .btn {
29 | margin-left: 0.2rem; }
30 |
31 | .btn:hover {
32 | color: rgba(255, 255, 255, 0.8);
33 | text-decoration: none;
34 | background-color: rgba(255, 255, 255, 0.2);
35 | border-color: rgba(255, 255, 255, 0.3); }
36 |
37 | @media screen and (min-width: 64em) {
38 | .btn {
39 | padding: 0.2rem 0.4rem;
40 | font-size: 0.9rem;
41 | } }
42 |
43 | @media screen and (min-width: 42em) and (max-width: 64em) {
44 | .btn {
45 | padding: 0.2rem 0.4rem;
46 | font-size: 0.9rem; } }
47 |
48 | @media screen and (max-width: 42em) {
49 | .btn {
50 | display: block;
51 | width: 100%;
52 | padding: 0.2rem;
53 | font-size: 0.9rem; }
54 | .btn + .btn {
55 | margin-top: 0.4rem;
56 | margin-left: 0; } }
57 |
58 | .page-header {
59 | color: #fff;
60 | text-align: center;
61 | background-color: #363899;
62 | background-image: linear-gradient(90deg, #000000, #99120f); }
63 |
64 | .header-image {
65 | align: middle;
66 | height: 140px;
67 | }
68 |
69 | @media screen and (min-width: 64em) {
70 | .page-header {
71 | padding: 1rem 1rem; } }
72 |
73 | @media screen and (min-width: 42em) and (max-width: 64em) {
74 | .page-header {
75 | padding: 1rem 1rem; } }
76 |
77 | @media screen and (max-width: 42em) {
78 | .page-header {
79 | padding: 0rem 0rem; } }
80 |
81 | .project-name {
82 | margin-top: 0;
83 | margin-bottom: 0.1rem; }
84 |
85 | @media screen and (min-width: 64em) {
86 | .project-name {
87 | font-size: 3.25rem; } }
88 |
89 | @media screen and (min-width: 42em) and (max-width: 64em) {
90 | .project-name {
91 | font-size: 2.25rem; } }
92 |
93 | @media screen and (max-width: 42em) {
94 | .project-name {
95 | font-size: 1.75rem; } }
96 |
97 | .project-tagline {
98 | margin-bottom: 0rem;
99 | font-weight: normal;
100 | opacity: 0.7; }
101 |
102 | @media screen and (min-width: 64em) {
103 | .project-tagline {
104 | font-size: 1.25rem; } }
105 |
106 | @media screen and (min-width: 42em) and (max-width: 64em) {
107 | .project-tagline {
108 | font-size: 1.15rem; } }
109 |
110 | @media screen and (max-width: 42em) {
111 | .project-tagline {
112 | font-size: 1rem; } }
113 |
114 | .main-content :first-child {
115 | margin-top: 0; }
116 | .main-content img {
117 | max-width: 100%; }
118 | .main-content h1, .main-content h2, .main-content h3, .main-content h4, .main-content h5, .main-content h6 {
119 | margin-top: 2rem;
120 | margin-bottom: 1rem;
121 | font-weight: normal;
122 | color: #159957; }
123 | .main-content p {
124 | margin-bottom: 1em; }
125 | .main-content code {
126 | padding: 2px 4px;
127 | font-family: Consolas, "Liberation Mono", Menlo, Courier, monospace;
128 | font-size: 0.9rem;
129 | color: #383e41;
130 | background-color: #f3f6fa;
131 | border-radius: 0.3rem; }
132 | .main-content pre {
133 | padding: 0.8rem;
134 | margin-top: 0;
135 | margin-bottom: 1rem;
136 | font: 1rem Consolas, "Liberation Mono", Menlo, Courier, monospace;
137 | color: #567482;
138 | word-wrap: normal;
139 | background-color: #f3f6fa;
140 | border: solid 1px #dce6f0;
141 | border-radius: 0.3rem; }
142 | .main-content pre > code {
143 | padding: 0;
144 | margin: 0;
145 | font-size: 0.9rem;
146 | color: #567482;
147 | word-break: normal;
148 | white-space: pre;
149 | background: transparent;
150 | border: 0; }
151 | .main-content .highlight {
152 | margin-bottom: 1rem; }
153 | .main-content .highlight pre {
154 | margin-bottom: 0;
155 | word-break: normal; }
156 | .main-content .highlight pre, .main-content pre {
157 | padding: 0.8rem;
158 | overflow: auto;
159 | font-size: 0.9rem;
160 | line-height: 1.45;
161 | border-radius: 0.3rem; }
162 | .main-content pre code, .main-content pre tt {
163 | display: inline;
164 | max-width: initial;
165 | padding: 0;
166 | margin: 0;
167 | overflow: initial;
168 | line-height: inherit;
169 | word-wrap: normal;
170 | background-color: transparent;
171 | border: 0; }
172 | .main-content pre code:before, .main-content pre code:after, .main-content pre tt:before, .main-content pre tt:after {
173 | content: normal; }
174 | .main-content ul, .main-content ol {
175 | margin-top: 0; }
176 | .main-content blockquote {
177 | padding: 0 1rem;
178 | margin-left: 0;
179 | color: #819198;
180 | border-left: 0.3rem solid #dce6f0; }
181 | .main-content blockquote > :first-child {
182 | margin-top: 0; }
183 | .main-content blockquote > :last-child {
184 | margin-bottom: 0; }
185 | .main-content table {
186 | display: block;
187 | width: 100%;
188 | overflow: auto;
189 | word-break: normal;
190 | word-break: keep-all; }
191 | .main-content table th {
192 | font-weight: bold; }
193 | .main-content table th, .main-content table td {
194 | padding: 0.5rem 1rem;
195 | border: 1px solid #e9ebec; }
196 | .main-content dl {
197 | padding: 0; }
198 | .main-content dl dt {
199 | padding: 0;
200 | margin-top: 1rem;
201 | font-size: 1rem;
202 | font-weight: bold; }
203 | .main-content dl dd {
204 | padding: 0;
205 | margin-bottom: 1rem; }
206 | .main-content hr {
207 | height: 2px;
208 | padding: 0;
209 | margin: 1rem 0;
210 | background-color: #eff0f1;
211 | border: 0; }
212 |
213 | @media screen and (min-width: 64em) {
214 | .main-content {
215 | padding: 2rem 6rem;
216 | margin: 0 auto;
217 | font-size: 1.1rem; } }
218 |
219 | @media screen and (min-width: 42em) and (max-width: 64em) {
220 | .main-content {
221 | padding: 2rem 4rem;
222 | font-size: 1.1rem; } }
223 |
224 | @media screen and (max-width: 42em) {
225 | .main-content {
226 | padding: 2rem 1rem;
227 | font-size: 1rem; } }
228 |
229 | .site-footer {
230 | padding-top: 2rem;
231 | margin-top: 2rem;
232 | border-top: solid 1px #eff0f1; }
233 |
234 | .site-footer-owner {
235 | display: block;
236 | font-weight: bold; }
237 |
238 | .site-footer-credits {
239 | color: #819198; }
240 |
241 | @media screen and (min-width: 64em) {
242 | .site-footer {
243 | font-size: 1rem; } }
244 |
245 | @media screen and (min-width: 42em) and (max-width: 64em) {
246 | .site-footer {
247 | font-size: 1rem; } }
248 |
249 | @media screen and (max-width: 42em) {
250 | .site-footer {
251 | font-size: 0.9rem; } }
252 |
--------------------------------------------------------------------------------
/docs/key_rotation.md:
--------------------------------------------------------------------------------
1 | ---
2 | layout: default
3 | ---
4 |
5 | ## Encryption Key Rotation
6 |
7 | According to the PCI Compliance documentation: "Cryptographic keys must be changed on an annual basis."
8 |
9 | During the transition period of moving from one encryption key to another symmetric-encryption supports multiple
10 | Symmetric Encryption keys. Since every encrypted value has a header that contains the version number of the key
11 | that was used to encrypt it, that key will be used to decrypt it, even though a new key is already active and
12 | is being used to encrypt new values.
13 |
14 | The active key is the first key in the list in `symmetric-encryption.yml`. Other keys are only used to decrypt
15 | values that were encrypted with those keys.
16 |
17 | Encryption keys are secured (encrypted) using a Key Encryption Key (RSA Private key). New keys are secured using the
18 | same Key Encryption Key, so that multiple encryption keys can be secured at the same time.
19 |
20 |
21 | ### Recommended steps
22 |
23 | Below are the recommended steps to perform "hot" key rotation, so that the encryption key can be changed without
24 | requiring system downtime or maintenance window.
25 |
26 | The steps can be reduced if they are being performed during a maintenance window. In this case do not supply
27 | the `--deploy` option below so that new key will be active immediately, and skip step 4 below.
28 |
29 | ### 1. Add the new key as secondary key
30 |
31 | During a rolling deploy it is possible for servers to encrypt data using a new
32 | key before the other servers have been updated. This would result in cipher
33 | errors should any of the servers try to decrypt the data since they do not have
34 | the new key.
35 |
36 | To avoid this race-condition add the new key as the second key in the configuration
37 | file. That way it will continue decrypting using the current key, but can also
38 | decrypt with the new key during the rolling deploy.
39 |
40 | For example, with Symmetric Encryption v4, use the command line interface to update the config file
41 | and generate the new keys:
42 |
43 | symmetric-encryption --rotate-keys --rolling-deploy --app-name my_app
44 |
45 | The `--rolling-deploy` option stores the new key as the second key so that it will not be activated yet.
46 |
47 | Replace `my_app` with the name of the application that is going to use this key. Recommend using lower case.
48 |
49 | By default a new key is generated for every environment, to limit it to just production:
50 |
51 | symmetric-encryption --rotate-keys --rolling-deploy --app-name my_app --environments production
52 |
53 | Copy the key file to every server in that particular environment that runs the application or uses Symmetric Encryption.
54 |
55 | If the keys for multiple environments are generated above, then move the relevant key files to the servers for that environment.
56 |
57 | By default the key files are located in `/etc/symmetric-encryption`.
58 |
59 |
60 | ### 2. Re-encrypt all passwords in the source repository
61 |
62 | Passwords, such as those for the database, need to be re-encrypted using the new key.
63 | Scan the source code repository for YAML files or other files that contain any encrypted passwords or
64 | other encrypted values.
65 |
66 | Since the new key is the secondary key, its version must be supplied when re-encrypting.
67 |
68 | For example, with Symmetric Encryption v4, re-encrypt yaml files:
69 |
70 | symmetric-encryption --re-encrypt --key-version 5
71 |
72 | Where key-version `5` above must be the version of the new key generated above.
73 |
74 | Note:
75 | * Since the keys for each environment are different, the above step must be run in each
76 | environment and then the modified files committed back into version control.
77 |
78 | ### 3. Deploy
79 |
80 | Deploy the updated source code to each environment so that the new key is available to all
81 | servers for decryption purposes.
82 |
83 | ### 4. Activate the new key
84 |
85 | Once the new key has been deployed as a secondary key, the next deploy can move
86 | the new key to the top of the list so that it will be the active key for encrypting new data.
87 | The previous key should be kept as the second key in the list so that it can continue to
88 | decrypt old data using the previous key(s).
89 |
90 | Move the new key ( the key with the highest version ) to the top of the list so that all
91 | new data is encrypted with this key.
92 |
93 | symmetric-encryption --activate-key
94 |
95 | Restart the application so that it will encrypt using the new encryption key.
96 |
97 | ### 5. Re-encrypting existing data
98 |
99 | For PCI Compliance it is necessary to re-encrypt old data with the new key and
100 | then to destroy the old key so that it cannot be used again.
101 |
102 | The sister project [RocketJob](http://rocketjob.io) comes with a batch job to re-encrypt
103 | all the data in a relational database for you. Uses multiple workers concurrently to spread the load,
104 | and is capable of re-encrypting terabytes of data. With built-in throttling mechanisms to allow
105 | re-encryption to continue while live traffic is being processed.
106 |
107 | To kick off the re-encryption job, run this from the console or via a migration:
108 |
109 | ~~~ruby
110 | RocketJob::Jobs::ReEncrypt::RelationalJob.start
111 | ~~~
112 |
113 | A job is created for every database table that contains a column starting with `encrypted_`.
114 | The job is throttled in 2 ways:
115 | * Only one job instance is permitted to run at a time.
116 | * For each job at most 100 workers will work on that table at a time.
117 |
118 | Both of the above throttle are configurable and can be tuned for your environment,
119 | by modifying the values below:
120 |
121 | ~~~ruby
122 | RocketJob::Jobs::ReEncrypt::RelationalJob.throttle_running_jobs = 1
123 | RocketJob::Jobs::ReEncrypt::RelationalJob.throttle_running_slices = 100
124 | ~~~
125 |
126 | Custom throttles can be added to the jobs, for example to throttle based on database slave delay, etc.
127 |
128 | ### 6. Re-encrypting Files
129 |
130 | Remember to re-encrypt any files on disk that were encrypted with Symmetric Encryption
131 | if they need to be kept after the old encryption key has been destroyed.
132 |
133 | For example, with Symmetric Encryption v4, re-encrypt files:
134 |
135 | symmetric-encryption --re-encrypt "/export/**/*"
136 |
137 | Replace `"/export/**/*"` above as needed to point to where the encrypted files are that
138 | should be re-encrypted using the new key.
139 |
140 | ### 7. Remove old key from configuration file
141 |
142 | Once all data and files have been re-encrypted using the new key, remove the
143 | old key from the configuration file.
144 |
145 | symmetric-encryption --cleanup-keys
146 |
147 | If you get cipher errors, you can restore the old key in the configuration file and
148 | then re-encrypt that data too.
149 |
150 | ### 8. Destroying old key
151 |
152 | Once sufficient time has passed and you are 100% certain that there is no data
153 | around that is still encrypted with the old key, wipe the old key from all the production
154 | servers.
155 |
156 | ### Next => [PCI Compliance](pci_compliance.html)
157 |
--------------------------------------------------------------------------------
/lib/symmetric_encryption/writer.rb:
--------------------------------------------------------------------------------
1 | require "openssl"
2 |
3 | module SymmetricEncryption
4 | # Write to encrypted files and other IO streams.
5 | #
6 | # Features:
7 | # * Encryption on the fly whilst writing files.
8 | # * Large file support by only buffering small amounts of data in memory.
9 | # * Underlying buffering to ensure that encrypted data fits
10 | # into the Symmetric Encryption Cipher block size.
11 | # Only the last block in the file will be padded if it is less than the block size.
12 | class Writer
13 | # Open a file for writing, or use the supplied IO Stream.
14 | #
15 | # Parameters:
16 | # file_name_or_stream: [String|IO]
17 | # The file_name to open if a string, otherwise the stream to use.
18 | # The file or stream will be closed on completion, use .initialize to
19 | # avoid having the stream closed automatically.
20 | #
21 | # compress: [true|false]
22 | # Uses Zlib to compress the data before it is encrypted and
23 | # written to the file/stream.
24 | # Default: true, unless the file_name extension indicates it is already compressed.
25 | #
26 | # Note: Compression occurs before encryption
27 | #
28 | # # Example: Encrypt and write data to a file
29 | # SymmetricEncryption::Writer.open('test_file.enc') do |file|
30 | # file.write "Hello World\n"
31 | # file.write 'Keep this secret'
32 | # end
33 | #
34 | # # Example: Compress, Encrypt and write data to a file
35 | # SymmetricEncryption::Writer.open('encrypted_compressed.enc', compress: true) do |file|
36 | # file.write "Hello World\n"
37 | # file.write "Compress this\n"
38 | # file.write "Keep this safe and secure\n"
39 | # end
40 | #
41 | # # Example: Writing to a CSV file
42 | # require 'csv'
43 | # begin
44 | # # Must supply :row_sep for CSV otherwise it will attempt to read from and then rewind the file
45 | # csv = CSV.new(SymmetricEncryption::Writer.open('csv.enc'), row_sep: "\n")
46 | # csv << [1,2,3,4,5]
47 | # ensure
48 | # csv.close if csv
49 | # end
50 | def self.open(file_name_or_stream, compress: nil, **args)
51 | if file_name_or_stream.is_a?(String)
52 | file_name_or_stream = ::File.open(file_name_or_stream, "wb")
53 | compress = !(/\.(zip|gz|gzip|xls.|)\z/i === file_name_or_stream) if compress.nil?
54 | elsif compress.nil?
55 | compress = true
56 | end
57 |
58 | begin
59 | file = new(file_name_or_stream, compress: compress, **args)
60 | file = Zlib::GzipWriter.new(file) if compress
61 | block_given? ? yield(file) : file
62 | ensure
63 | file.close if block_given? && file && (file.respond_to?(:closed?) && !file.closed?)
64 | end
65 | end
66 |
67 | # Write the contents of a string in memory to an encrypted file / stream.
68 | #
69 | # Notes:
70 | # * Do not use this method for writing large files.
71 | def self.write(file_name_or_stream, data, **args)
72 | Writer.open(file_name_or_stream, **args) { |f| f.write(data) }
73 | end
74 |
75 | # Encrypt an entire file.
76 | #
77 | # Returns [Integer] the number of encrypted bytes written to the target file.
78 | #
79 | # Params:
80 | # source: [String|IO]
81 | # Source file_name or IOStream
82 | #
83 | # target: [String|IO]
84 | # Target file_name or IOStream
85 | #
86 | # compress: [true|false]
87 | # Whether to compress the target file prior to encryption.
88 | # Default: false
89 | #
90 | # Notes:
91 | # * The file contents are streamed so that the entire file is _not_ loaded into memory.
92 | def self.encrypt(source:, target:, **args)
93 | Writer.open(target, **args) { |output_file| IO.copy_stream(source, output_file) }
94 | end
95 |
96 | # Encrypt data before writing to the supplied stream
97 | def initialize(ios, version: nil, cipher_name: nil, header: true, random_key: true, random_iv: true, compress: false)
98 | # Compress is only used at this point for setting the flag in the header
99 | @ios = ios
100 | raise(ArgumentError, "When :random_key is true, :random_iv must also be true") if random_key && !random_iv
101 | if cipher_name && !random_key && !random_iv
102 | raise(ArgumentError, "Cannot supply a :cipher_name unless both :random_key and :random_iv are true")
103 | end
104 |
105 | # Cipher to encrypt the random_key, or the entire file
106 | cipher = SymmetricEncryption.cipher(version)
107 | unless cipher
108 | raise(SymmetricEncryption::CipherError,
109 | "Cipher with version:#{version} not found in any of the configured SymmetricEncryption ciphers")
110 | end
111 |
112 | # Force header if compressed or using random iv, key
113 | if (header == true) || compress || random_key || random_iv
114 | header = Header.new(version: cipher.version, compress: compress, cipher_name: cipher_name)
115 | end
116 |
117 | @stream_cipher = ::OpenSSL::Cipher.new(cipher_name || cipher.cipher_name)
118 | @stream_cipher.encrypt
119 |
120 | if random_key
121 | header.key = @stream_cipher.key = @stream_cipher.random_key
122 | else
123 | @stream_cipher.key = cipher.send(:key)
124 | end
125 |
126 | if random_iv
127 | header.iv = @stream_cipher.iv = @stream_cipher.random_iv
128 | elsif cipher.iv
129 | @stream_cipher.iv = cipher.iv
130 | end
131 |
132 | @ios.write(header.to_s) if header
133 |
134 | @size = 0
135 | @closed = false
136 | end
137 |
138 | # Close the IO Stream.
139 | #
140 | # Notes:
141 | # * Flushes any unwritten data.
142 | # * Once an EncryptionWriter has been closed a new instance must be
143 | # created before writing again.
144 | # * Closes the passed in io stream or file.
145 | # * `close` must be called _before_ the supplied stream is closed.
146 | #
147 | # It is recommended to call Symmetric::EncryptedStream.open
148 | # rather than creating an instance of Symmetric::Writer directly to
149 | # ensure that the encrypted stream is closed before the stream itself is closed.
150 | def close(close_child_stream = true)
151 | return if closed?
152 |
153 | if size.positive?
154 | final = @stream_cipher.final
155 | @ios.write(final) unless final.empty?
156 | end
157 | @ios.close if close_child_stream
158 | @closed = true
159 | end
160 |
161 | # Write to the IO Stream as encrypted data.
162 | #
163 | # Returns [Integer] the number of bytes written.
164 | if defined?(JRuby)
165 | def write(data)
166 | return unless data
167 |
168 | bytes = data.to_s
169 | @size += bytes.size
170 | partial = @stream_cipher.update(bytes)
171 | @ios.write(partial) unless partial.empty?
172 | data.length
173 | end
174 | else
175 | def write(data)
176 | return unless data
177 |
178 | bytes = data.to_s
179 | @size += bytes.size
180 | partial = @stream_cipher.update(bytes, @cipher_buffer ||= "".b)
181 | @ios.write(partial) unless partial.empty?
182 | data.length
183 | end
184 | end
185 |
186 | # Write to the IO Stream as encrypted data.
187 | #
188 | # Returns [SymmetricEncryption::Writer] self
189 | #
190 | # Example:
191 | # file << "Hello.\n" << 'This is Jack'
192 | def <<(data)
193 | write(data)
194 | self
195 | end
196 |
197 | # Flush the output stream.
198 | # Does not flush internal buffers since encryption requires all data to
199 | # be written following the encryption block size.
200 | # Needed by XLS gem.
201 | def flush
202 | @ios.flush
203 | end
204 |
205 | # Returns [true|false] whether this stream is closed.
206 | def closed?
207 | @closed || (@ios.respond_to?(:closed?) && @ios.closed?)
208 | end
209 |
210 | # Returns [Integer] the number of unencrypted and uncompressed bytes
211 | # written to the file so far.
212 | attr_reader :size
213 | end
214 | end
215 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Symmetric Encryption
2 | [](https://rubygems.org/gems/symmetric-encryption) [](https://github.com/reidmorrison/symmetric-encryption/actions?query=workflow%3Abuild) [](https://rubygems.org/gems/symmetric-encryption) [](http://opensource.org/licenses/Apache-2.0) 
3 |
4 | * https://encryption.rocketjob.io/
5 |
6 | Transparently encrypt ActiveRecord, and Mongoid attributes. Encrypt passwords in configuration files. Encrypt entire files at rest.
7 |
8 | ## Introduction
9 |
10 | Any project that wants to meet PCI compliance has to ensure that the data is encrypted
11 | whilst in flight and at rest. Amongst many other requirements all passwords
12 | in configuration files also have to be encrypted.
13 |
14 | Symmetric Encryption helps achieve compliance by supporting encryption of data in a simple
15 | and consistent way.
16 |
17 | Symmetric Encryption uses OpenSSL to encrypt and decrypt data, and can therefore
18 | expose all the encryption algorithms supported by OpenSSL.
19 |
20 | ## Documentation
21 |
22 | [Symmetric Encryption Guide](https://encryption.rocketjob.io/)
23 |
24 | ## Rocket Job
25 |
26 | Checkout the sister project [Rocket Job](http://rocketjob.io): Ruby's missing batch system.
27 |
28 | Fully supports Symmetric Encryption to encrypt data in flight and at rest while running jobs in the background.
29 |
30 | ## Upgrading to Rails V7
31 |
32 | There is a method naming conflict with Rails 7, which has its own `encrypted_attributes` method.
33 |
34 | As a result the older `attr_encrypted` mechanism is no longer available with Rails 7.
35 | Migrate the use of `attr_encrypted` to `attribute` as described in the [Frameworks Guide](https://encryption.rocketjob.io/frameworks.html).
36 |
37 | ## Upgrading to SymmetricEncryption V4
38 |
39 | Version 4 of Symmetric Encryption has completely adopted the Ruby keyword arguments on most API's where
40 | multiple arguments are being passed, or where a Hash was being used before.
41 |
42 | The encrypt and decrypt API now require keyword arguments for any optional arguments.
43 |
44 | The following does _not_ change:
45 |
46 | ~~~ruby
47 | encrypted = SymmetricEncryption.encrypt('Hello World')
48 | SymmetricEncryption.decrypt(encrypted)
49 | ~~~
50 |
51 | The following is _not_ backward compatible:
52 | ~~~ruby
53 | SymmetricEncryption.encrypt('Hello World', false, false, :date)
54 | ~~~
55 |
56 | Needs to be changed to:
57 | ~~~ruby
58 | SymmetricEncryption.encrypt('Hello World', random_iv: false, compress: false, type: :date)
59 | ~~~
60 |
61 | Or, just to change the type:
62 | ~~~ruby
63 | SymmetricEncryption.encrypt('Hello World', type: :date)
64 | ~~~
65 |
66 | Similarly the `decrypt` api has also changed:
67 | ~~~ruby
68 | SymmetricEncryption.decrypt(encrypted, 2, :date)
69 | ~~~
70 |
71 | Needs to be changed to:
72 | ~~~ruby
73 | SymmetricEncryption.decrypt(encrypted, version: 2, type: :string)
74 | ~~~
75 |
76 | The Rake tasks have been replaced with a new command line interface for managing key configuration and generation.
77 | For more info:
78 | ~~~
79 | symmetric-encryption --help
80 | ~~~
81 |
82 | #### Configuration changes
83 |
84 | In Symmetric Encryption V4 the configuration file is now modified directly instead
85 | of using templates. This change is necessary to allow the command line interface to
86 | generate new keys and automatically update the configuration file.
87 |
88 | Please backup your existing `symmetric-encryption.yml` prior to upgrading if it is not
89 | already in a version control system. This is critical for configurations that have custom
90 | code or for prior configurations targeting heroku.
91 |
92 | In Symmetric Encryption V4 the defaults for `encoding` and `always_add_header` have changed.
93 | If these values are not explicitly set in the `symmetric-encryption.yml` file, set them
94 | prior to upgrading.
95 |
96 | Prior defaults, set explicitly to these values if missing for all environments:
97 | ~~~yaml
98 | encoding: :base64
99 | always_add_header: false
100 | ~~~
101 |
102 | New defaults are:
103 | ~~~yaml
104 | encoding: :base64strict
105 | always_add_header: true
106 | ~~~
107 |
108 |
109 | ## Upgrading to SymmetricEncryption V3
110 |
111 | In version 3 of SymmetricEncryption, the following changes have been made that
112 | may have backward compatibility issues:
113 |
114 | * `SymmetricEncryption.decrypt` no longer rotates through all the decryption keys
115 | when previous ciphers fail to decrypt the encrypted string.
116 | In a very small, yet significant number of cases it was possible to decrypt data
117 | using the incorrect key. Clearly the data returned was garbage, but it still
118 | returned a string of data instead of throwing an exception.
119 | See `SymmetricEncryption.select_cipher` to supply your own custom logic to
120 | determine the correct cipher to use when the encrypted string does not have a
121 | header and multiple ciphers are defined.
122 |
123 | * Configuration file format prior to V1 is no longer supported.
124 |
125 | * New configuration option has been added to support setting encryption keys
126 | from environment variables.
127 |
128 | * `Cipher.parse_magic_header!` now returns a Struct instead of an Array.
129 |
130 | * New config options `:encrypted_key` and `:encrypted_iv` to support setting
131 | the encryption key in environment variables, or from other sources such as ldap
132 | or a central directory service.
133 |
134 | ## New features in V1.1 and V2
135 |
136 | * Ability to randomly generate a new initialization vector (iv) with every
137 | encryption and put the iv in the encrypted data as its header, without having
138 | to use `SymmetricEncryption::Writer`.
139 |
140 | * With file encryption randomly generate a new key and initialization vector (iv) with every
141 | file encryption and put the key and iv in the encrypted data as its header which
142 | is encrypted using the global key and iv.
143 |
144 | * Support for compression.
145 |
146 | * `SymmetricEncryption.encrypt` has two additional optional parameters:
147 | * random_iv `[true|false]`
148 | * Whether the encypted value should use a random IV every time the
149 | field is encrypted.
150 | * It is recommended to set this to true where feasible. If the encrypted
151 | value could be used as part of a SQL where clause, or as part
152 | of any lookup, then it must be false.
153 | * Setting random_iv to true will result in a different encrypted output for
154 | the same input string.
155 | * Note: Only set to true if the field will never be used as part of
156 | the where clause in an SQL query.
157 | * Note: When random_iv is true it will add a 8 byte header, plus the bytes
158 | to store the random IV in every returned encrypted string, prior to the
159 | encoding if any.
160 | * Note: Adds a 6 byte header prior to encoding, if not already configured
161 | to add the header to all encrypted values.
162 | * Default: false
163 | * Highly Recommended where feasible: true
164 |
165 | * compress [true|false]
166 | * Whether to compress prior to encryption.
167 | * Should only be used for large strings since compression overhead and
168 | the overhead of adding the 'magic' header may exceed any benefits of
169 | compression.
170 | * Default: false
171 |
172 | ## Author
173 |
174 | [Reid Morrison](https://github.com/reidmorrison)
175 |
176 | [Contributors](https://github.com/reidmorrison/symmetric-encryption/graphs/contributors)
177 |
178 | ## Versioning
179 |
180 | This project uses [Semantic Versioning](http://semver.org/).
181 |
182 | ## Disclaimer
183 |
184 | Although this library has assisted in meeting PCI Compliance and has passed
185 | previous PCI audits, it in no way guarantees that PCI Compliance will be
186 | achieved by anyone using this library.
187 |
--------------------------------------------------------------------------------
/test/keystore_test.rb:
--------------------------------------------------------------------------------
1 | require_relative "test_helper"
2 |
3 | module SymmetricEncryption
4 | class KeystoreTest < Minitest::Test
5 | describe SymmetricEncryption::Keystore do
6 | let :keystore do
7 | SymmetricEncryption::Keystore::File.new(file_name: "tmp/tester.key", key_encrypting_key: SymmetricEncryption::Key.new)
8 | end
9 |
10 | let :the_test_path do
11 | path = "tmp/keystore_test"
12 | FileUtils.makedirs(path) unless ::File.exist?(path)
13 | path
14 | end
15 |
16 | after do
17 | # Cleanup generated encryption key files.
18 | `rm #{the_test_path}/* 2> /dev/null`
19 | end
20 |
21 | let :random_key do
22 | SymmetricEncryption::Key.new
23 | end
24 |
25 | let :stored_key do
26 | "1234567890ABCDEF1234567890ABCDEF"
27 | end
28 |
29 | let :stored_iv do
30 | "ABCDEF1234567890"
31 | end
32 |
33 | let :key do
34 | SymmetricEncryption::Key.new(key: stored_key, iv: stored_iv)
35 | end
36 |
37 | let :stored_key2 do
38 | "ABCDEF1234567890ABCDEF1234567890"
39 | end
40 |
41 | let :stored_iv2 do
42 | "1234567890ABCDEF"
43 | end
44 |
45 | let :key2 do
46 | SymmetricEncryption::Key.new(key: stored_key2, iv: stored_iv2)
47 | end
48 |
49 | let :stored_key3 do
50 | "ABCDEF0123456789ABCDEF0123456789"
51 | end
52 |
53 | let :stored_iv3 do
54 | "0123456789ABCDEF"
55 | end
56 |
57 | let :key3 do
58 | SymmetricEncryption::Key.new(key: stored_key3, iv: stored_iv3)
59 | end
60 |
61 | describe ".generate_data_keys" do
62 | let :environments do
63 | %i[development test acceptance preprod production]
64 | end
65 |
66 | let :config do
67 | SymmetricEncryption::Keystore.generate_data_keys(
68 | keystore: :file,
69 | key_path: the_test_path,
70 | app_name: "tester",
71 | environments: environments,
72 | cipher_name: "aes-128-cbc"
73 | )
74 | end
75 |
76 | it "creates keys for each environment" do
77 | assert_equal environments, config.keys, config
78 | end
79 |
80 | it "use test config for development and test" do
81 | assert_equal SymmetricEncryption::Keystore.dev_config, config[:test]
82 | assert_equal SymmetricEncryption::Keystore.dev_config, config[:development]
83 | end
84 | end
85 |
86 | describe ".rotate_keys" do
87 | let :environments do
88 | %i[development test acceptance preprod production]
89 | end
90 |
91 | let :config do
92 | SymmetricEncryption::Keystore.generate_data_keys(
93 | keystore: :file,
94 | key_path: the_test_path,
95 | app_name: "tester",
96 | environments: environments,
97 | cipher_name: "aes-128-cbc"
98 | )
99 | end
100 |
101 | let :rolling_deploy do
102 | false
103 | end
104 |
105 | let :key_rotation do
106 | SymmetricEncryption::Keystore.rotate_keys!(
107 | config,
108 | environments: environments,
109 | app_name: "tester",
110 | rolling_deploy: rolling_deploy
111 | )
112 | end
113 |
114 | it "creates an encrypted key file for all non-test environments" do
115 | (environments - %i[development test]).each do |env|
116 | assert key_rotation
117 | assert key_rotation[env.to_sym], key_rotation
118 | assert key_rotation[env.to_sym][:ciphers]
119 | assert ciphers = key_rotation[env.to_sym][:ciphers], "Environment #{env} is missing ciphers: #{key_rotation[env.to_sym].inspect}"
120 | assert_equal 2, ciphers.size, "Environment #{env}: #{ciphers.inspect}"
121 | assert new_config = ciphers.first
122 | assert file_name = new_config[:key_filename], "Environment #{env} is missing key_filename: #{ciphers.inspect}"
123 | assert File.exist?(file_name)
124 | assert_equal 2, new_config[:version]
125 | end
126 | end
127 | end
128 |
129 | describe ".read_key" do
130 | let :config do
131 | {key: stored_key, iv: stored_iv}
132 | end
133 |
134 | let :config_key do
135 | SymmetricEncryption::Keystore.read_key(**config)
136 | end
137 |
138 | let :dek_file_name do
139 | "#{the_test_path}/dek_tester_dek.encrypted_key"
140 | end
141 |
142 | describe "key" do
143 | it "key" do
144 | assert_equal stored_key, config_key.key
145 | end
146 |
147 | it "iv" do
148 | assert_equal stored_iv, config_key.iv
149 | end
150 |
151 | it "cipher_name" do
152 | assert_equal "aes-256-cbc", config_key.cipher_name
153 | end
154 | end
155 |
156 | describe "encrypted_key" do
157 | let :config do
158 | {encrypted_key: key2.encrypt(stored_key), iv: stored_iv, key_encrypting_key: {key: stored_key2, iv: stored_iv2}}
159 | end
160 |
161 | it "key" do
162 | assert_equal stored_key, config_key.key
163 | end
164 |
165 | it "iv" do
166 | assert_equal stored_iv, config_key.iv
167 | end
168 |
169 | it "cipher_name" do
170 | assert_equal "aes-256-cbc", config_key.cipher_name
171 | end
172 | end
173 |
174 | describe "key_filename" do
175 | let :config do
176 | File.open(dek_file_name, "wb", 0o600) { |f| f.write(key2.encrypt(stored_key)) }
177 | {key_filename: dek_file_name, iv: stored_iv, key_encrypting_key: {key: stored_key2, iv: stored_iv2}}
178 | end
179 |
180 | it "key" do
181 | assert_equal stored_key, config_key.key
182 | end
183 |
184 | it "iv" do
185 | assert_equal stored_iv, config_key.iv
186 | end
187 |
188 | it "cipher_name" do
189 | assert_equal "aes-256-cbc", config_key.cipher_name
190 | end
191 | end
192 |
193 | describe "key_env_var" do
194 | let :env_var do
195 | "TEST_KEY"
196 | end
197 |
198 | let :config do
199 | ENV[env_var] = ::Base64.strict_encode64(key2.encrypt(stored_key))
200 | {key_env_var: env_var, iv: stored_iv, key_encrypting_key: {key: stored_key2, iv: stored_iv2}}
201 | end
202 |
203 | it "key" do
204 | assert_equal stored_key, config_key.key
205 | end
206 |
207 | it "iv" do
208 | assert_equal stored_iv, config_key.iv
209 | end
210 |
211 | it "cipher_name" do
212 | assert_equal "aes-256-cbc", config_key.cipher_name
213 | end
214 | end
215 |
216 | describe "file store with kekek" do
217 | let :kekek_file_name do
218 | "#{the_test_path}/tester_kekek.key"
219 | end
220 |
221 | let :config do
222 | File.open(dek_file_name, "wb", 0o600) { |f| f.write(key2.encrypt(stored_key)) }
223 | encrypted_key = key3.encrypt(stored_key2)
224 | File.open(kekek_file_name, "wb", 0o600) { |f| f.write(stored_key3) }
225 | {
226 | key_filename: dek_file_name,
227 | iv: stored_iv,
228 | key_encrypting_key: {
229 | encrypted_key: encrypted_key,
230 | iv: stored_iv2,
231 | key_encrypting_key: {
232 | key_filename: kekek_file_name,
233 | iv: stored_iv3
234 | }
235 | }
236 | }
237 | end
238 |
239 | it "key" do
240 | assert_equal stored_key, config_key.key
241 | end
242 |
243 | it "iv" do
244 | assert_equal stored_iv, config_key.iv
245 | end
246 |
247 | it "cipher_name" do
248 | assert_equal "aes-256-cbc", config_key.cipher_name
249 | end
250 | end
251 | end
252 | end
253 | end
254 | end
255 |
--------------------------------------------------------------------------------
/docs/v3_configuration.md:
--------------------------------------------------------------------------------
1 | ---
2 | layout: default
3 | ---
4 |
5 | ## Rails Configuration with Symmetric Encryption v3:
6 |
7 | If deploying to Heroku, see: [Heroku Configuration](heroku.html)
8 |
9 | For a standalone environment without Rails, see: [Standalone Configuration](standalone.html)
10 |
11 | ### Add to Gemfile
12 |
13 | Add the following line to your Gemfile _after_ the rails gems:
14 |
15 | ~~~ruby
16 | gem 'symmetric-encryption'
17 | ~~~
18 |
19 | Install using bundler:
20 |
21 | bundle
22 |
23 | ### Creating the configuration file
24 |
25 | Generate the configuration file and encryption keys for every environment:
26 |
27 | rails generate symmetric_encryption:config OUTPUT_PATH
28 |
29 | #### Save to version control
30 |
31 | This configuration file should be checked into the source code control system.
32 | It does Not include the Symmetric Encryption keys. They will be generated in the
33 | next step.
34 |
35 | ### Generating and securing the Symmetric Encryption keys
36 |
37 | Once development and testing is complete we need to generate secure encryption
38 | key files for production. It is recommended that the step below be run on only
39 | one of the production servers. The generated key files must then be copied to
40 | all the production web servers.
41 |
42 | #### Notes
43 |
44 | * Do not run this step more than once, otherwise new keys will be generated
45 | and any encrypted data will no longer be accessible.
46 |
47 | * Do not run this step on more than one server in each environment otherwise
48 | each server will be encrypting with it's own key and the servers will not be able
49 | to decrypt data encrypted on another server. Just copy the generated files to each
50 | server
51 |
52 | The symmetric encryption key consists of the key itself and an optional
53 | initialization vector.
54 |
55 | To generate the keys run the following command once only in each environment:
56 |
57 | Symmetric Encryption v4.0 and above:
58 |
59 | symmetric-encryption --generate
60 |
61 | Symmetric Encryption v3.0:
62 |
63 | rails generate symmetric_encryption:new_keys production
64 |
65 |
66 | Replace `production` above as necessary for each environment.
67 |
68 | Make sure that the current user has read and write access to the folder listed
69 | in the config file option key_filename.
70 |
71 | Note: Ignore the warning about the key files "not found or readable" since they
72 | are being generated
73 |
74 | Once the Symmetric Encryption keys have been generated, secure them further by
75 | making the files read-only to the Rails user and not readable by any other user.
76 | Change ownership of the keys to the rails user and only give it access to read the key files:
77 |
78 | chown rails /etc/rails/keys/*
79 | chmod 0400 /etc/rails/keys/*
80 |
81 | Change `rails` above to the userid under which your Rails processes are run
82 | and update the path to the one supplied when generating the config file or
83 | look in the config file itself
84 |
85 | When running multiple Rails servers in a particular environment copy the same
86 | key files to every server in that environment. I.e. All Rails servers in each
87 | environment must run the same encryption keys.
88 |
89 | Note: The generate step above must only be run once in each environment
90 |
91 | ## Supporting Multiple Encryption Keys
92 |
93 | According to the PCI Compliance documentation: "Cryptographic keys must be changed on an annual basis."
94 |
95 | During the transition period of moving from one encryption key to another
96 | symmetric-encryption supports multiple Symmetric Encryption keys. If decryption
97 | with the current key fails, any previous keys will also be tried automatically.
98 |
99 | By default the latest key is used for encrypting data. Another key can be specified
100 | for encryption so that old data can be looked in queries, etc.
101 |
102 | Since just the Symmetric Encryption keys are being changed, we can still continue to
103 | use the same RSA Private key for gaining access to the Symmetric Encryption Keys
104 |
105 | ### Configuring multiple Symmetric Encryption keys
106 |
107 | Create a configuration file in config/symmetric-encryption.yml per the following example:
108 |
109 | ~~~yaml
110 | #
111 | # Symmetric Encryption for Ruby
112 | #
113 | ---
114 | # For the development and test environments the test symmetric encryption keys
115 | # can be placed directly in the source code.
116 | # And therefore no RSA private key is required
117 | development: &development_defaults
118 | key: 1234567890ABCDEF
119 | iv: 1234567890ABCDEF
120 | cipher_name: aes-128-cbc
121 |
122 | test:
123 | <<: *development_defaults
124 |
125 | production:
126 | # Since the key to encrypt and decrypt with must NOT be stored along with the
127 | # source code, we only hold a RSA key that is used to unlock the file
128 | # containing the actual symmetric encryption key
129 | #
130 | # Sample RSA Key, DO NOT use this RSA key, generate a new one using
131 | # openssl genrsa 2048
132 | private_rsa_key: |
133 | -----BEGIN RSA PRIVATE KEY-----
134 | MIIEpAIBAAKCAQEAxIL9H/jYUGpA38v6PowRSRJEo3aNVXULNM/QNRpx2DTf++KH
135 | 6DcuFTFcNSSSxG9n4y7tKi755be8N0uwCCuOzvXqfWmXYjbLwK3Ib2vm0btpHyvA
136 | qxgqeJOOCxKdW/cUFLWn0tACUcEjVCNfWEGaFyvkOUuR7Ub9KfhbW9cZO3BxZMUf
137 | IPGlHl/gWyf484sXygd+S7cpDTRRzo9RjG74DwfE0MFGf9a1fTkxnSgeOJ6asTOy
138 | fp9tEToUlbglKaYGpOGHYQ9TV5ZsyJ9jRUyb4SP5wK2eK6dHTxTcHvT03kD90Hv4
139 | WeKIXv3WOjkwNEyMdpnJJfSDb5oquQvCNi7ZSQIDAQABAoIBAQCbzR7TUoBugU+e
140 | ICLvpC2wOYOh9kRoFLwlyv3QnH7WZFWRZzFJszYeJ1xr5etXQtyjCnmOkGAg+WOI
141 | k8GlOKOpAuA/PpB/leJFiYL4lBwU/PmDdTT0cdx6bMKZlNCeMW8CXGQKiFDOcMqJ
142 | 0uGtH5YD+RChPIEeFsJxnC8SyZ9/t2ra7XnMGiCZvRXIUDSEIIsRx/mOymJ7bL+h
143 | Lbp46IfXf6ZuIzwzoIk0JReV/r+wdmkAVDkrrMkCmVS4/X1wN/Tiik9/yvbsh/CL
144 | ztC55eSIEjATkWxnXfPASZN6oUfQPEveGH3HzNjdncjH/Ho8FaNMIAfFpBhhLPi9
145 | nG5sbH+BAoGBAOdoUyVoAA/QUa3/FkQaa7Ajjehe5MR5k6VtaGtcxrLiBjrNR7x+
146 | nqlZlGvWDMiCz49dgj+G1Qk1bbYrZLRX/Hjeqy5dZOGLMfgf9eKUmS1rDwAzBMcj
147 | M9jnnJEBx8HIlNzaR6wzp3GMd0rrccs660A8URvzkgo9qNbvMLq9vyUtAoGBANll
148 | SY1Iv9uaIz8klTXU9YzYtsfUmgXzw7K8StPdbEbo8F1J3JPJB4D7QHF0ObIaSWuf
149 | suZqLsvWlYGuJeyX2ntlBN82ORfvUdOrdrbDlmPyj4PfFVl0AK3U3Ai374DNrjKR
150 | hF6YFm4TLDaJhUjeV5C43kbE1N2FAMS9LYtPJ44NAoGAFDGHZ/E+aCLerddfwwun
151 | MBS6MnftcLPHTZ1RimTrNfsBXipBw1ItWEvn5s0kCm9X24PmdNK4TnhqHYaF4DL5
152 | ZjbQK1idEA2Mi8GGPIKJJ2x7P6I0HYiV4qy7fe/w1ZlCXE90B7PuPbtrQY9wO7Ll
153 | ipJ45X6I1PnyfOcckn8yafUCgYACtPAlgjJhWZn2v03cTbqA9nHQKyV/zXkyUIXd
154 | /XPLrjrP7ouAi5A8WuSChR/yx8ECRgrEM65Be3qBEtoGCB4AS1G0NcigM6qhKBFi
155 | VS0aMXr3+V8argcUIwJaWW/x+p2go48yXlJpLHPweeXe8mXEt4iM+QZte6p2yKQ4
156 | h9PGQQKBgQCqSydmXBnXGIVTp2sH/2GnpxLYnDBpcJE0tM8bJ42HEQQgRThIChsn
157 | PnGA91G9MVikYapgI0VYBHQOTsz8rTIUzsKwXG+TIaK+W84nxH5y6jUkjqwxZmAz
158 | r1URaMAun2PfAB4g2N/kEZTExgeOGqXjFhvvjdzl97ux2cTyZhaTXg==
159 | -----END RSA PRIVATE KEY-----
160 |
161 | # List Symmetric Key files in the order of current / latest first
162 | ciphers:
163 | -
164 | # Filename containing Symmetric Encryption Key encrypted using the
165 | # RSA public key derived from the private key above
166 | key_filename: /etc/rails/.rails.key
167 | iv_filename: /etc/rails/.rails.iv
168 |
169 | # Encryption cipher_name
170 | # Recommended values:
171 | # aes-256-cbc
172 | # 256 AES CBC Algorithm. Very strong
173 | # Ruby 1.8.7 MRI Approximately 100,000 encryptions or decryptions per second
174 | # JRuby 1.6.7 with Ruby 1.8.7 Approximately 22,000 encryptions or decryptions per second
175 | # aes-128-cbc
176 | # 128 AES CBC Algorithm. Less strong.
177 | # Ruby 1.8.7 MRI Approximately 100,000 encryptions or decryptions per second
178 | # JRuby 1.6.7 with Ruby 1.8.7 Approximately 22,000 encryptions or decryptions per second
179 | cipher_name: aes-256-cbc
180 |
181 | -
182 | # OPTIONAL:
183 | #
184 | # Any previous Symmetric Encryption Keys
185 | #
186 | # Only used when old data still exists that requires old decryption keys
187 | # to be used
188 | key_filename: /etc/rails/.rails_old.key
189 | iv_filename: /etc/rails/.rails_old.iv
190 | cipher_name: aes-256-cbc
191 | ~~~
192 |
193 | ### Next => [Rake Tasks](rake_tasks.html)
194 |
--------------------------------------------------------------------------------
/docs/frameworks.md:
--------------------------------------------------------------------------------
1 | ---
2 | layout: default
3 | ---
4 |
5 | ## Supported Frameworks
6 |
7 | The following frameworks are directly supported by Symmetric Encryption
8 |
9 | * Ruby on Rails
10 | * Mongoid
11 |
12 | ### Rails 5
13 |
14 | As of Symmetric Encryption v4.3, when using Rails v5 and above the recommended approach is to use the new
15 | [ActiveRecord Attributes API](https://api.rubyonrails.org/classes/ActiveRecord/Attributes/ClassMethods.html).
16 |
17 | Example: Model `Person` has an encrypted attribute called `name` of type string.
18 |
19 | ~~~ruby
20 | class Person < ActiveRecord::Base
21 | attribute :name, :encrypted
22 | end
23 | ~~~
24 |
25 | In the database migration, the `name` column should be defined as type `string` and should be large enough to hold
26 | the base64 encoded value after encryption. If the text can be very long, use the type `text`.
27 |
28 | ~~~ruby
29 | create_table :people, force: true do |t|
30 | t.string :name
31 | t.string :age
32 | t.text :address
33 | end
34 | ~~~
35 |
36 | By default when defining an attribute it will be encrypted with a new, random, initialization vectore (IV).
37 | The IV is also stored along with the encrypted value, which makes it a little larger.
38 |
39 | The default of `random_iv: true` is highly recommended for security reasons. However, we would never be able to
40 | perform a query using that field, since the random IV causes the value to change every time the same data is
41 | encrypted.
42 |
43 | As a result, the following query would never get a match:
44 |
45 | ~~~ruby
46 | Person.where(name: "Jack").count
47 | ~~~
48 |
49 | For these columns, it is necessary to add the option `random_iv: true`:
50 |
51 | ~~~ruby
52 | class Person < ActiveRecord::Base
53 | attribute :name, :encrypted, random_iv: false
54 | end
55 | ~~~
56 |
57 | Since the value stored in the database is always an encrypted string, the ultimate type of the
58 | attribute needs to be supplied:
59 |
60 | * :string => String
61 | * :integer => Integer
62 | * :float => Float
63 | * :decimal => BigDecimal
64 | * :datetime => DateTime
65 | * :time => Time
66 | * :date => Date
67 | * :boolean => true or false
68 | * :json => Uses JSON serialization, useful for hashes and arrays
69 | * :yaml => Uses YAML serialization, useful for hashes and arrays
70 |
71 | Example: The encrypted attribute `age` can be specified as an integer:
72 |
73 | ~~~ruby
74 | class Person < ActiveRecord::Base
75 | attribute :name, :encrypted, random_iv: false
76 | attribute :age, :encrypted, type: :integer
77 | attribute :address, :encrypted, compress: true
78 | end
79 | ~~~
80 |
81 | For larger encrypted attributes it is also worthwhile to compress the value after it has been encrypted,
82 | by adding the option:
83 | `compress: true`
84 |
85 | #### Note
86 |
87 | The column name in the database matches the name of the attribute in the model.
88 | This differs to using the `attr_encrypted` approach described below for use with Rails 3 and 4,
89 | which requires the encrypted column name in the database to begin with `encrypted_`.
90 |
91 | ### Rails 3 and 4
92 |
93 | Note: When using Rails 5, it is recommended to use the Active Record attribute type approach detailed above.
94 | However, the approach below using `attr_encrypted` is still fully supported.
95 |
96 | Note: As of Rails 7 this approach is no longer supported, see the Active Record attribute type approach detailed above.
97 |
98 | Example: Model `Person` has an encrypted attribute called `name` of type string.
99 |
100 | ~~~ruby
101 | class Person < ActiveRecord::Base
102 | attr_encrypted :name, random_iv: true
103 | end
104 | ~~~
105 |
106 | In the database migration, the `name` column should be defined as type `string` and should be large enough to hold
107 | the base64 encoded value after encryption. If the text can be very long, use the type `text`.
108 |
109 | ~~~ruby
110 | create_table :people, force: true do |t|
111 | t.string :encrypted_name
112 | t.string :encrypted_age
113 | t.text :encrypted_address
114 | end
115 | ~~~
116 |
117 | To perform a query using an encrypted field, use the encrypted form of the field name that starts with `encrypted_`:
118 |
119 | For example:
120 |
121 | ~~~ruby
122 | Person.where(encrypted_name: SymmetricEncryption.encrypt("Jack")).count
123 | ~~~
124 |
125 | By default when defining an attribute with `attr_encrypted` it will _not_ be encrypted with a
126 | random initialization vectore (IV). This is _not_ recommended, and `random_iv: true` should be
127 | added whenever possible for security resaons.
128 |
129 | However, we would never be able to perform a query using that field, since the random IV causes the
130 | value to change every time the same data is encrypted. As a result, the above query would never get a match.
131 |
132 | For these columns, it is necessary to use the option `random_iv: false`:
133 |
134 | ~~~ruby
135 | class Person < ActiveRecord::Base
136 | attr_encrypted :name, random_iv: false
137 | end
138 | ~~~
139 |
140 | Now the following query will find the expected record:
141 |
142 | ~~~ruby
143 | Person.where(encrypted_name: SymmetricEncryption.encrypt("Jack")).count
144 | ~~~
145 |
146 | Since the value stored in the database is always an encrypted string, the ultimate type of the
147 | attribute needs to be supplied:
148 |
149 | * :string => String
150 | * :integer => Integer
151 | * :float => Float
152 | * :decimal => BigDecimal
153 | * :datetime => DateTime
154 | * :time => Time
155 | * :date => Date
156 | * :json => Uses JSON serialization, useful for hashes and arrays
157 | * :yaml => Uses YAML serialization, useful for hashes and arrays
158 |
159 | Example: The encrypted attribute `age` can be specified as an integer:
160 |
161 | ~~~ruby
162 | class Person < ActiveRecord::Base
163 | attr_encrypted :name, random_iv: false
164 | attr_encrypted :age, random_iv: true, type: :integer
165 | attr_encrypted :address, random_iv: true, compress: true
166 | end
167 | ~~~
168 |
169 | For larger encrypted attributes it is also worthwhile to compress the value after it has been encrypted,
170 | by adding the option:
171 | `compress: true`
172 |
173 | #### Note
174 |
175 | The column name in the database differs from the name of the attribute in the model.
176 | The encrypted column name in the database must begin with `encrypted_`.
177 |
178 | #### Validations
179 |
180 | To ensure that the encrypted attribute value is encrypted, a validation can be used.
181 |
182 | Note that the validation is only applicable when using the `attr_encrypted` approach. Using the
183 | attribute type approach with Rails 5 or above does not need a validation to ensure the field is encrypted
184 | before saving.
185 |
186 | ~~~ruby
187 | class Person < ActiveRecord::Base
188 | attr_encrypted :name, random_iv: false
189 | attr_encrypted :age, random_iv: true, type: :integer
190 | attr_encrypted :address, random_iv: true, compress: true
191 |
192 | validates :encrypted_name, symmetric_encryption: true
193 | validates :encrypted_age, symmetric_encryption: true
194 | validates :encrypted_address, symmetric_encryption: true
195 | end
196 | ~~~
197 |
198 | ### Mongoid
199 |
200 | To encrypt a field in a Mongoid document, just add "encrypted: true" at the end
201 | of the field specifier. The field name must currently begin with "encrypted_"
202 |
203 | ~~~ruby
204 | # User model in Mongoid
205 | class User
206 | include Mongoid::Document
207 |
208 | field :name, type: String
209 | field :encrypted_bank_account_number, type: String, encrypted: true
210 | field :encrypted_social_security_number, type: String, encrypted: true
211 | field :encrypted_life_history, type: String, encrypted: {compress: true, random_iv: true}
212 |
213 | # Encrypted fields are _always_ stored in Mongo as a String
214 | # To get the result back as an Integer, Symmetric Encryption can do the
215 | # necessary conversions by specifying the internal type as an option
216 | # to :encrypted
217 | # #see SymmetricEncryption::COERCION_TYPES for full list of types
218 | field :encrypted_age, type: String, encrypted: {type: :integer}
219 | end
220 |
221 | # Create a new user document
222 | User.create(bank_account_number: '12345')
223 |
224 | # When finding a document, always use the encrypted form of the field name
225 | user = User.where(encrypted_bank_account_number: SymmetricEncryption.encrypt('12345')).first
226 |
227 | # Fields can be accessed using their unencrypted names
228 | puts user.bank_account_number
229 | ~~~
230 |
231 | ### Next => [Configuration](configuration.html)
232 |
--------------------------------------------------------------------------------
/test/cipher_test.rb:
--------------------------------------------------------------------------------
1 | require_relative "test_helper"
2 |
3 | # Tests for SymmetricEncryption::Cipher
4 | class CipherTest < Minitest::Test
5 | ["aes-128-cbc"].each do |cipher_name|
6 | describe "Cipher: #{cipher_name}" do
7 | describe "standalone" do
8 | it "allows setting the cipher_name" do
9 | cipher = SymmetricEncryption::Cipher.new(
10 | cipher_name: cipher_name,
11 | key: "1234567890ABCDEF",
12 | iv: "1234567890ABCDEF",
13 | encoding: :none
14 | )
15 | assert_equal cipher_name, cipher.cipher_name
16 | end
17 |
18 | it "does not require an iv" do
19 | cipher = SymmetricEncryption::Cipher.new(
20 | key: "1234567890ABCDEF",
21 | cipher_name: cipher_name,
22 | encoding: :none,
23 | always_add_header: false
24 | )
25 | assert result = cipher.encrypt("Hello World")
26 | assert_equal "Hello World", cipher.decrypt(result)
27 | end
28 |
29 | it "throw an exception on bad data" do
30 | cipher = SymmetricEncryption::Cipher.new(
31 | cipher_name: cipher_name,
32 | key: "1234567890ABCDEF",
33 | iv: "1234567890ABCDEF",
34 | encoding: :none
35 | )
36 | assert_raises OpenSSL::Cipher::CipherError do
37 | cipher.decrypt("bad data")
38 | end
39 | end
40 | end
41 |
42 | [false, true].each do |always_add_header|
43 | %i[none base64 base64strict base64urlsafe base16].each do |encoding|
44 | describe "encoding: #{encoding} with#{'out' unless always_add_header} header" do
45 | before do
46 | @social_security_number = "987654321"
47 | @encrypted_values = {
48 | "aes-128-cbc" => {
49 | base64: {
50 | header: "QEVuQwAAyTeLjsHTa8ykoO95K0KQmg==\n",
51 | no_header: "yTeLjsHTa8ykoO95K0KQmg==\n"
52 | },
53 | base64strict: {
54 | header: "QEVuQwAAyTeLjsHTa8ykoO95K0KQmg==",
55 | no_header: "yTeLjsHTa8ykoO95K0KQmg=="
56 | },
57 | base64urlsafe: {
58 | header: "QEVuQwAAyTeLjsHTa8ykoO95K0KQmg==",
59 | no_header: "yTeLjsHTa8ykoO95K0KQmg=="
60 | },
61 | base16: {
62 | header: "40456e430000c9378b8ec1d36bcca4a0ef792b42909a",
63 | no_header: "c9378b8ec1d36bcca4a0ef792b42909a"
64 | },
65 | none: {
66 | header: "@EnC\x00\x00\xC97\x8B\x8E\xC1\xD3k\xCC\xA4\xA0\xEFy+B\x90\x9A",
67 | no_header: "\xC97\x8B\x8E\xC1\xD3k\xCC\xA4\xA0\xEFy+B\x90\x9A"
68 | }
69 | }
70 | }
71 |
72 | @non_utf8 = "\xc2".force_encoding("binary")
73 | @cipher = SymmetricEncryption::Cipher.new(
74 | key: "ABCDEF1234567890",
75 | iv: "ABCDEF1234567890",
76 | cipher_name: cipher_name,
77 | encoding: encoding,
78 | always_add_header: always_add_header
79 | )
80 |
81 | h = @encrypted_values[cipher_name][encoding] if @encrypted_values[cipher_name]
82 | unless h
83 | skip "Add @encrypted_values for cipher_name: #{cipher_name} and encoding: #{encoding}, value: #{@cipher.encrypt(@social_security_number).inspect}"
84 | end
85 | @social_security_number_encrypted = h[always_add_header ? :header : :no_header]
86 |
87 | @social_security_number_encrypted.force_encoding(Encoding.find("binary")) if encoding == :none
88 | end
89 |
90 | it "encrypt simple string" do
91 | assert encrypted = @cipher.encrypt(@social_security_number)
92 | assert_equal @social_security_number_encrypted, encrypted
93 | end
94 |
95 | it "decrypt string" do
96 | assert decrypted = @cipher.decrypt(@social_security_number_encrypted)
97 | assert_equal @social_security_number, decrypted
98 | assert_equal Encoding.find("utf-8"), decrypted.encoding, decrypted
99 | end
100 |
101 | it "encrypt and decrypt string" do
102 | assert encrypted = @cipher.encrypt(@social_security_number)
103 | assert_equal @social_security_number_encrypted, encrypted
104 | assert decrypted = @cipher.decrypt(encrypted)
105 | assert_equal @social_security_number, decrypted
106 | assert_equal Encoding.find("utf-8"), decrypted.encoding, decrypted
107 | end
108 |
109 | it "return BINARY encoding for non-UTF-8 encrypted data" do
110 | assert_equal Encoding.find("binary"), @non_utf8.encoding
111 | assert_equal true, @non_utf8.valid_encoding?
112 | assert encrypted = @cipher.encrypt(@non_utf8)
113 | assert decrypted = @cipher.decrypt(encrypted)
114 | assert_equal true, decrypted.valid_encoding?
115 | assert_equal Encoding.find("binary"), decrypted.encoding, decrypted
116 | assert_equal @non_utf8, decrypted
117 | end
118 |
119 | it "return nil when encrypting nil" do
120 | assert_nil @cipher.encrypt(nil)
121 | end
122 |
123 | it "return '' when encrypting ''" do
124 | assert_equal "", @cipher.encrypt("")
125 | end
126 |
127 | it "return nil when decrypting nil" do
128 | assert_nil @cipher.decrypt(nil)
129 | end
130 |
131 | it "return '' when decrypting ''" do
132 | assert_equal "", @cipher.decrypt("")
133 | end
134 | end
135 | end
136 | end
137 |
138 | describe "with configuration" do
139 | before do
140 | @cipher = SymmetricEncryption::Cipher.new(
141 | key: "1234567890ABCDEF",
142 | iv: "1234567890ABCDEF",
143 | cipher_name: "aes-128-cbc",
144 | encoding: :none
145 | )
146 | @social_security_number = "987654321"
147 |
148 | @social_security_number_encrypted = "A\335*\314\336\250V\340\023%\000S\177\305\372\266"
149 | @social_security_number_encrypted.force_encoding("binary")
150 |
151 | @sample_data = [
152 | {text: "555052345", encrypted: ""}
153 | ]
154 | end
155 |
156 | describe "with header" do
157 | before do
158 | @social_security_number = "987654321"
159 | end
160 |
161 | it "build and parse header" do
162 | key = SymmetricEncryption::Key.new(cipher_name: "aes-128-cbc")
163 | # Test Deprecated method
164 | binary_header = SymmetricEncryption::Cipher.build_header(
165 | SymmetricEncryption.cipher.version,
166 | true,
167 | key.iv,
168 | key.key,
169 | key.cipher_name
170 | )
171 | header = SymmetricEncryption::Header.new
172 | header.parse(binary_header)
173 | assert_equal true, header.compressed?
174 | assert random_cipher = SymmetricEncryption::Cipher.new(iv: key.iv, key: key.key, cipher_name: key.cipher_name)
175 | assert_equal random_cipher.cipher_name, header.cipher_name, "Ciphers differ"
176 | assert_equal random_cipher.send(:key), header.key, "Keys differ"
177 | assert_equal random_cipher.send(:iv), header.iv, "IVs differ"
178 |
179 | string = "Hello World"
180 | cipher = SymmetricEncryption::Cipher.new(key: header.key, iv: header.iv, cipher_name: header.cipher_name)
181 | # Test Encryption
182 | assert_equal random_cipher.encrypt(string), cipher.encrypt(string), "Encrypted values differ"
183 | end
184 |
185 | it "encrypt and then decrypt without a header" do
186 | assert encrypted = @cipher.binary_encrypt(@social_security_number, header: false)
187 | assert_equal @social_security_number, @cipher.decrypt(encrypted)
188 | end
189 |
190 | it "encrypt and then decrypt using random iv" do
191 | assert encrypted = @cipher.encrypt(@social_security_number, random_iv: true)
192 | assert_equal @social_security_number, @cipher.decrypt(encrypted)
193 | end
194 |
195 | it "encrypt and then decrypt using random iv with compression" do
196 | assert encrypted = @cipher.encrypt(@social_security_number, random_iv: true, compress: true)
197 | assert_equal @social_security_number, @cipher.decrypt(encrypted)
198 | end
199 | end
200 | end
201 | end
202 | end
203 | end
204 |
--------------------------------------------------------------------------------