attr_vault 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA1:
3
+ metadata.gz: 93ec67ae603b025fd34c27c9b769cdfd6d6814c8
4
+ data.tar.gz: 3c7af4318feb415e45dba7cb176612ba9db59c28
5
+ SHA512:
6
+ metadata.gz: a77d9447d153c91af3f033e4dce072eaa08e5189d150cf2cde0715592493fafc899020e6299c0c0f7e70042a8906ceb6b77c9855dba9ce43d4a7924aab7f5fe2
7
+ data.tar.gz: a9ae10282b3f5a21ecce80f2a70602995fb13ccce26c7fb7c758446f2b54069ed108f1619f6bdb8f6e30b76e16ab69386141848b5906a89aeee7e34c13ec87b9
data/Gemfile ADDED
@@ -0,0 +1,6 @@
1
+ source 'https://rubygems.org'
2
+
3
+ ruby '2.1.2'
4
+
5
+ # Specify your gem's dependencies in attr_vault.gemspec
6
+ gemspec
data/Gemfile.lock ADDED
@@ -0,0 +1,36 @@
1
+ PATH
2
+ remote: .
3
+ specs:
4
+ attr_vault (0.0.1)
5
+ fernet (~> 2.1)
6
+
7
+ GEM
8
+ remote: https://rubygems.org/
9
+ specs:
10
+ diff-lcs (1.2.5)
11
+ fernet (2.1)
12
+ valcro (= 0.1)
13
+ pg (0.17.1)
14
+ rspec (3.1.0)
15
+ rspec-core (~> 3.1.0)
16
+ rspec-expectations (~> 3.1.0)
17
+ rspec-mocks (~> 3.1.0)
18
+ rspec-core (3.1.7)
19
+ rspec-support (~> 3.1.0)
20
+ rspec-expectations (3.1.2)
21
+ diff-lcs (>= 1.2.0, < 2.0)
22
+ rspec-support (~> 3.1.0)
23
+ rspec-mocks (3.1.3)
24
+ rspec-support (~> 3.1.0)
25
+ rspec-support (3.1.2)
26
+ sequel (4.13.0)
27
+ valcro (0.1)
28
+
29
+ PLATFORMS
30
+ ruby
31
+
32
+ DEPENDENCIES
33
+ attr_vault!
34
+ pg
35
+ rspec (~> 3.0)
36
+ sequel (~> 4.13.0)
data/LICENSE ADDED
@@ -0,0 +1,22 @@
1
+ Copyright (c) 2014 Maciek Sakrejda
2
+
3
+ MIT License
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining
6
+ a copy of this software and associated documentation files (the
7
+ "Software"), to deal in the Software without restriction, including
8
+ without limitation the rights to use, copy, modify, merge, publish,
9
+ distribute, sublicense, and/or sell copies of the Software, and to
10
+ permit persons to whom the Software is furnished to do so, subject to
11
+ the following conditions:
12
+
13
+ The above copyright notice and this permission notice shall be
14
+ included in all copies or substantial portions of the Software.
15
+
16
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
17
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
18
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
19
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
20
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
21
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
22
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
@@ -0,0 +1,23 @@
1
+ require File.expand_path('../lib/attr_vault/version', __FILE__)
2
+
3
+ Gem::Specification.new do |gem|
4
+ gem.authors = ["Maciek Sakrejda"]
5
+ gem.email = ["m.sakrejda@gmail.com"]
6
+ gem.description = %q{Encryption at rest made easy}
7
+ gem.summary = %q{Sequel plugin for encryption at rest}
8
+ gem.homepage = "https://github.com/deafbybeheading/attr_vault"
9
+
10
+ gem.files = `git ls-files`.split($\)
11
+ gem.executables = gem.files.grep(%r{^bin/}).map{ |f| File.basename(f) }
12
+ gem.test_files = gem.files.grep(%r{^(test|spec|features)/})
13
+ gem.name = "attr_vault"
14
+ gem.require_paths = ["lib"]
15
+ gem.version = AttrVault::VERSION
16
+ gem.license = "MIT"
17
+
18
+ gem.add_runtime_dependency 'fernet', '~> 2.1'
19
+
20
+ gem.add_development_dependency "rspec", '~> 3.0'
21
+ gem.add_development_dependency "pg", '~> 0'
22
+ gem.add_development_dependency "sequel", '~> 4.13'
23
+ end
@@ -0,0 +1,57 @@
1
+ module AttrVault
2
+ module Cryptor
3
+ def self.encrypt(value, key)
4
+ return [nil, nil] if value.nil?
5
+ return ['', ''] if value.empty?
6
+
7
+ secret = AttrVault::Secret.new(key)
8
+
9
+ encrypted_message, iv = Encryption.encrypt(
10
+ key: secret.encryption_key,
11
+ message: value
12
+ )
13
+
14
+ encrypted_payload = iv + encrypted_message
15
+ mac = OpenSSL::HMAC.digest('sha256', secret.signing_key, encrypted_payload)
16
+ [ encrypted_payload, mac ]
17
+ end
18
+
19
+ def self.decrypt(encrypted_payload, hmac, key)
20
+ return nil if encrypted_payload.nil? && hmac.nil?
21
+ return '' if encrypted_payload.empty? && hmac.empty?
22
+
23
+ secret = AttrVault::Secret.new(key)
24
+
25
+ expected_hmac = Encryption.hmac_digest(secret.signing_key, encrypted_payload)
26
+ unless verify_signature(expected_hmac, hmac)
27
+ raise InvalidCiphertext, "Expected hmac #{expected_hmac} for this value; got #{hmac}"
28
+ end
29
+
30
+ iv, encrypted_message = encrypted_payload[0..16], encrypted_payload[16..-1]
31
+
32
+ block_size = Encryption::AES_BLOCK_SIZE
33
+ unless (encrypted_message.size % block_size).zero?
34
+ raise InvalidCiphertext,
35
+ "Expected message size to be multiple of #{block_size}; got #{encrypted_message.size}"
36
+ end
37
+
38
+ begin
39
+ Encryption.decrypt(key: secret.encryption_key,
40
+ ciphertext: encrypted_message,
41
+ iv: iv)
42
+ rescue OpenSSL::Cipher::CipherError
43
+ raise InvalidCiphertext, "Could not decrypt field"
44
+ end
45
+ end
46
+
47
+ private
48
+
49
+ def self.verify_signature(expected, actual)
50
+ expected_bytes = expected.bytes.to_a
51
+ actual_bytes = actual.bytes.to_a
52
+ actual_bytes.inject(0) do |accum, byte|
53
+ accum |= byte ^ expected_bytes.shift
54
+ end.zero?
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,70 @@
1
+ require 'openssl'
2
+
3
+ module AttrVault
4
+ # borrowed wholesale from Fernet
5
+
6
+ # Internal: Encapsulates encryption and signing primitives
7
+ module Encryption
8
+ AES_BLOCK_SIZE = 16.freeze
9
+
10
+ # Internal: Encrypts the provided message using a AES-128-CBC cipher with a
11
+ # random IV and the provided encryption key
12
+ #
13
+ # Arguments:
14
+ #
15
+ # * message - the message to encrypt
16
+ # * key - the encryption key
17
+ # * iv - override for the random IV, only used for testing
18
+ #
19
+ # Examples
20
+ #
21
+ # ciphertext, iv = AttrVault::Encryption.encrypt(
22
+ # message: 'this is a secret', key: encryption_key
23
+ # )
24
+ #
25
+ # Returns a two-element array containing the ciphertext and the random IV
26
+ def self.encrypt(key:, message:, iv: nil)
27
+ cipher = OpenSSL::Cipher.new('AES-128-CBC')
28
+ cipher.encrypt
29
+ iv ||= cipher.random_iv
30
+ cipher.iv = iv
31
+ cipher.key = key
32
+ [cipher.update(message) + cipher.final, iv]
33
+ end
34
+
35
+ # Internal: Decrypts the provided ciphertext using a AES-128-CBC cipher with a
36
+ # the provided IV and encryption key
37
+ #
38
+ # Arguments:
39
+ #
40
+ # * ciphertext - encrypted message
41
+ # * key - encryption key used to encrypt the message
42
+ # * iv - initialization vector used in the ciphertext's cipher
43
+ #
44
+ # Examples
45
+ #
46
+ # ciphertext, iv = AttrVault::Encryption.encrypt(
47
+ # message: 'this is a secret', key: encryption_key
48
+ # )
49
+ #
50
+ # Returns a two-element array containing the ciphertext and the random IV
51
+ def self.decrypt(key:, ciphertext:, iv:)
52
+ decipher = OpenSSL::Cipher.new('AES-128-CBC')
53
+ decipher.decrypt
54
+ decipher.iv = iv
55
+ decipher.key = key
56
+ decipher.update(ciphertext) + decipher.final
57
+ end
58
+
59
+ # Internal: Creates an HMAC signature (sha256 hashing) of the given bytes
60
+ # with the provided signing key
61
+ #
62
+ # key - the signing key
63
+ # bytes - blob of bytes to sign
64
+ #
65
+ # Returns the HMAC signature as a string
66
+ def self.hmac_digest(key, bytes)
67
+ OpenSSL::HMAC.digest('sha256', key, bytes)
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,22 @@
1
+ module AttrVault
2
+ # Base class for AttrVault errors
3
+ class Error < StandardError; end
4
+ class InvalidSecret < AttrVault::Error; end
5
+ class InvalidKey < AttrVault::Error; end
6
+ class InvalidKeyring < AttrVault::Error; end
7
+ class KeyringEmpty < AttrVault::Error; end
8
+ class UnknownKey < AttrVault::Error
9
+ def initialize(key_id)
10
+ @key_id = key_id
11
+ end
12
+ def message
13
+ formatted_id = if @key_id.nil?
14
+ '<nil>'
15
+ else
16
+ @key_id
17
+ end
18
+ "No key with id #{formatted_id} found in keyring"
19
+ end
20
+ end
21
+ class InvalidCiphertext < AttrVault::Error; end
22
+ end
@@ -0,0 +1,85 @@
1
+ module AttrVault
2
+ class Key
3
+ attr_reader :id, :value, :created_at
4
+
5
+ def initialize(id, value, created_at)
6
+ if id.nil?
7
+ raise InvalidKey, "key id required"
8
+ end
9
+ if value.nil?
10
+ raise InvalidKey, "key value required"
11
+ end
12
+ if created_at.nil?
13
+ raise InvalidKey, "key created_at required"
14
+ end
15
+
16
+ @id = id
17
+ @value = value
18
+ @created_at = created_at
19
+ end
20
+
21
+ def to_json(*args)
22
+ { id: id, value: value, created_at: created_at }.to_json
23
+ end
24
+ end
25
+
26
+ class Keyring
27
+ attr_reader :keys
28
+
29
+ def self.load(keyring_data)
30
+ keyring = Keyring.new
31
+ begin
32
+ candidate_keys = JSON.parse(keyring_data)
33
+ unless candidate_keys.respond_to? :each
34
+ raise InvalidKeyring, "does not respond to each"
35
+ end
36
+ candidate_keys.each_with_index do |k|
37
+ created_at = unless k["created_at"].nil?
38
+ Time.parse(k["created_at"])
39
+ end
40
+ keyring.add_key(Key.new(k["id"], k["value"], created_at || Time.now))
41
+ end
42
+ rescue StandardError => e
43
+ raise InvalidKeyring, e.message
44
+ end
45
+ keyring
46
+ end
47
+
48
+ def initialize
49
+ @keys = []
50
+ end
51
+
52
+ def add_key(k)
53
+ @keys << k
54
+ end
55
+
56
+ def drop_key(id_or_key)
57
+ id = if id_or_key.is_a? Key
58
+ id_or_key.id
59
+ else
60
+ id_or_key
61
+ end
62
+ @keys.reject! { |k| k.id == id }
63
+ end
64
+
65
+ def fetch(id)
66
+ @keys.find { |k| k.id == id } or raise UnknownKey, id
67
+ end
68
+
69
+ def has_key?(id)
70
+ !@keys.find { |k| k.id == id }.nil?
71
+ end
72
+
73
+ def current_key
74
+ k = @keys.sort_by(&:created_at).last
75
+ if k.nil?
76
+ raise KeyringEmpty, "No keys in keyring"
77
+ end
78
+ k
79
+ end
80
+
81
+ def to_json
82
+ @keys.to_json
83
+ end
84
+ end
85
+ end
@@ -0,0 +1,48 @@
1
+ require 'base64'
2
+
3
+ module AttrVault
4
+ # borrowed wholesale from Fernet
5
+
6
+ # Internal: Encapsulates a secret key, a 32-byte sequence consisting
7
+ # of an encryption and a signing key.
8
+ class Secret
9
+ # Internal - Initialize a Secret
10
+ #
11
+ # secret - the secret, optionally encoded with either standard or
12
+ # URL safe variants of Base64 encoding
13
+ #
14
+ # Raises AttrVault::Secret::InvalidSecret if it cannot be decoded or is
15
+ # not of the expected length
16
+ def initialize(secret)
17
+ if secret.bytesize == 32
18
+ @secret = secret
19
+ else
20
+ begin
21
+ @secret = Base64.urlsafe_decode64(secret)
22
+ rescue ArgumentError
23
+ @secret = Base64.decode64(secret)
24
+ end
25
+ unless @secret.bytesize == 32
26
+ raise InvalidSecret,
27
+ "Secret must be 32 bytes, instead got #{@secret.bytesize}"
28
+ end
29
+ end
30
+ end
31
+
32
+ # Internal: Returns the portion of the secret token used for encryption
33
+ def encryption_key
34
+ @secret.slice(16, 16)
35
+ end
36
+
37
+ # Internal: Returns the portion of the secret token used for signing
38
+ def signing_key
39
+ @secret.slice(0, 16)
40
+ end
41
+
42
+ # Public: String representation of this secret, masks to avoid leaks.
43
+ def to_s
44
+ "<AttrVault::Secret [masked]>"
45
+ end
46
+ alias to_s inspect
47
+ end
48
+ end
@@ -0,0 +1,3 @@
1
+ module AttrVault
2
+ VERSION = "0.0.1"
3
+ end
data/lib/attr_vault.rb ADDED
@@ -0,0 +1,129 @@
1
+ require 'attr_vault/errors'
2
+ require 'attr_vault/keyring'
3
+ require 'attr_vault/secret'
4
+ require 'attr_vault/encryption'
5
+ require 'attr_vault/cryptor'
6
+
7
+ module AttrVault
8
+ def self.included(base)
9
+ base.extend(ClassMethods)
10
+ base.include(InstanceMethods)
11
+ end
12
+
13
+ module InstanceMethods
14
+ def before_save
15
+ keyring = self.class.vault_keys
16
+ current_key = keyring.current_key
17
+ key_id = self[self.class.vault_key_field]
18
+ record_key = self.class.vault_keys.fetch(key_id) unless key_id.nil?
19
+
20
+ @vault_dirty_attrs ||= {}
21
+ if !record_key.nil? && current_key != record_key
22
+ # If the record key is not nil and not current, flag *all*
23
+ # attrs as dirty, since we want to rewrite them all in order
24
+ # to use the latest key. Note that when the record key is nil,
25
+ # we're dealing with a new record, so there are no existing
26
+ # vault attributes to rewrite. We only write these out when
27
+ # they're set explicitly in a new record, in which case they
28
+ # will be in the dirty attrs already and are handled below.
29
+ self.class.vault_attrs.each do |attr|
30
+ next if @vault_dirty_attrs.has_key? attr.name
31
+ @vault_dirty_attrs[attr.name] = self.send(attr.name)
32
+ end
33
+ end
34
+ # If any attr has plaintext_source_field and the plaintext field
35
+ # has a value set, flag the attr as dirty using the plaintext
36
+ # source value, then nil out the plaintext field.
37
+ self.class.vault_attrs.reject { |attr| attr.plaintext_source_field.nil? }.each do |attr|
38
+ unless self[attr.plaintext_source_field].nil?
39
+ @vault_dirty_attrs[attr.name] = self[attr.plaintext_source_field]
40
+ self[attr.plaintext_source_field] = nil
41
+ end
42
+ end
43
+ self.class.vault_attrs.each do |attr|
44
+ next unless @vault_dirty_attrs.has_key? attr.name
45
+
46
+ value = @vault_dirty_attrs[attr.name]
47
+ encrypted, hmac = Cryptor.encrypt(value, current_key.value)
48
+
49
+ unless encrypted.nil?
50
+ encrypted = Sequel.blob(encrypted)
51
+ end
52
+ unless hmac.nil?
53
+ hmac = Sequel.blob(hmac)
54
+ end
55
+
56
+ self[attr.encrypted_field] = encrypted
57
+ self[attr.hmac_field] = hmac
58
+ end
59
+ self[self.class.vault_key_field] = current_key.id
60
+ @vault_dirty_attrs = {}
61
+ super
62
+ end
63
+ end
64
+
65
+ module ClassMethods
66
+ def vault_keyring(keyring_data, key_field: :key_id)
67
+ @key_field = key_field.to_sym
68
+ @keyring = Keyring.load(keyring_data)
69
+ end
70
+
71
+ def vault_attr(name, opts={})
72
+ attr = VaultAttr.new(name, opts)
73
+ self.vault_attrs << attr
74
+
75
+ define_method(name) do
76
+ # if there is a plaintext source field, use that and ignore
77
+ # the encrypted field
78
+ if !attr.plaintext_source_field.nil? && !self[attr.plaintext_source_field].nil?
79
+ return self[attr.plaintext_source_field]
80
+ end
81
+
82
+ keyring = self.class.vault_keys
83
+ key_id = self[self.class.vault_key_field]
84
+ record_key = self.class.vault_keys.fetch(key_id)
85
+
86
+ encrypted_value = self[attr.encrypted_field]
87
+ hmac = self[attr.hmac_field]
88
+ # TODO: cache decrypted value
89
+ Cryptor.decrypt(encrypted_value, hmac, record_key.value)
90
+ end
91
+
92
+ define_method("#{name}=") do |value|
93
+ @vault_dirty_attrs ||= {}
94
+ @vault_dirty_attrs[name] = value
95
+ # ensure that Sequel knows that this is in fact dirty and must
96
+ # be updated--otherwise, the object is never saved,
97
+ # #before_save is never called, and we never store the update
98
+ self.modified! attr.encrypted_field
99
+ self.modified! attr.hmac_field
100
+ end
101
+ end
102
+
103
+ def vault_attrs
104
+ @vault_attrs ||= []
105
+ end
106
+
107
+ def vault_key_field
108
+ @key_field
109
+ end
110
+
111
+ def vault_keys
112
+ @keyring
113
+ end
114
+ end
115
+
116
+ class VaultAttr
117
+ attr_reader :name, :encrypted_field, :hmac_field, :plaintext_source_field
118
+
119
+ def initialize(name,
120
+ encrypted_field: "#{name}_encrypted",
121
+ hmac_field: "#{name}_hmac",
122
+ plaintext_source_field: nil)
123
+ @name = name
124
+ @encrypted_field = encrypted_field.to_sym
125
+ @hmac_field = hmac_field.to_sym
126
+ @plaintext_source_field = plaintext_source_field.to_sym unless plaintext_source_field.nil?
127
+ end
128
+ end
129
+ end
@@ -0,0 +1,184 @@
1
+ require "spec_helper"
2
+ require "json"
3
+
4
+ module AttrVault
5
+ describe Keyring do
6
+
7
+ describe ".load" do
8
+ let(:key_data) {
9
+ [
10
+ { id: SecureRandom.uuid, value: SecureRandom.base64(32), created_at: Time.now },
11
+ { id: SecureRandom.uuid, value: SecureRandom.base64(32), created_at: Time.now }
12
+ ]
13
+ }
14
+
15
+ it "loads a valid keyring string" do
16
+ keyring = Keyring.load(key_data.to_json)
17
+ expect(keyring).to be_a Keyring
18
+ expect(keyring.keys.count).to eq 2
19
+ (0..1).each do |i|
20
+ expect(keyring.keys[i].id).to eq key_data[i][:id]
21
+ expect(keyring.keys[i].value).to eq key_data[i][:value]
22
+ expect(keyring.keys[i].created_at).to be_within(60).of(key_data[i][:created_at])
23
+ end
24
+ end
25
+
26
+ it "rejects unexpected JSON" do
27
+ expect { Keyring.load('hello') }.to raise_error(InvalidKeyring)
28
+ end
29
+
30
+ it "rejects unknown formats" do
31
+ keys = key_data.map do |k|
32
+ "<key id='#{k[:id]}' value='#{k[:value]}' created_at='#{k[:created_at]}'/>"
33
+ end
34
+ expect { Keyring.load("<keys>#{keys}</keys>") }.to raise_error(InvalidKeyring)
35
+ end
36
+
37
+ it "rejects keys with missing ids" do
38
+ key_data[0].delete :id
39
+ expect { Keyring.load(key_data) }.to raise_error(InvalidKeyring)
40
+ end
41
+
42
+ it "rejects keys with missing values" do
43
+ key_data[0].delete :value
44
+ expect { Keyring.load(key_data) }.to raise_error(InvalidKeyring)
45
+ end
46
+ end
47
+ end
48
+
49
+ describe "#keys" do
50
+ let(:keyring) { Keyring.new }
51
+ let(:k1) { Key.new(SecureRandom.uuid, SecureRandom.base64(32), Time.now) }
52
+ let(:k2) { Key.new(SecureRandom.uuid, SecureRandom.base64(32), Time.now) }
53
+
54
+ before do
55
+ keyring.add_key(k1)
56
+ keyring.add_key(k2)
57
+ end
58
+
59
+ it "lists all keys" do
60
+ expect(keyring.keys).to include(k1)
61
+ expect(keyring.keys).to include(k2)
62
+ end
63
+ end
64
+
65
+ describe "#fetch" do
66
+ let(:keyring) { Keyring.new }
67
+ let(:k1) { Key.new(SecureRandom.uuid, SecureRandom.base64(32), Time.now) }
68
+ let(:k2) { Key.new(SecureRandom.uuid, SecureRandom.base64(32), Time.now) }
69
+
70
+ before do
71
+ keyring.add_key(k1)
72
+ keyring.add_key(k2)
73
+ end
74
+
75
+ it "finds the right key by its id" do
76
+ expect(keyring.fetch(k1.id)).to be k1
77
+ expect(keyring.fetch(k2.id)).to be k2
78
+ end
79
+
80
+ it "raises for an unknown id" do
81
+ expect { keyring.fetch('867344d2-ac73-493b-9a9e-5fa688ba25ef') }
82
+ .to raise_error(UnknownKey)
83
+ end
84
+ end
85
+
86
+ describe "#has_key?" do
87
+ let(:keyring) { Keyring.new }
88
+ let(:k1) { Key.new(SecureRandom.uuid, SecureRandom.base64(32), Time.now) }
89
+ let(:k2) { Key.new(SecureRandom.uuid, SecureRandom.base64(32), Time.now) }
90
+
91
+ before do
92
+ keyring.add_key(k1)
93
+ keyring.add_key(k2)
94
+ end
95
+
96
+ it "is true if the keyring has a key with the given id" do
97
+ expect(keyring.has_key?(k1.id)).to be true
98
+ expect(keyring.has_key?(k2.id)).to be true
99
+ end
100
+
101
+ it "is false if no such key is present" do
102
+ expect(keyring.has_key?('867344d2-ac73-493b-9a9e-5fa688ba25ef')).to be false
103
+ end
104
+ end
105
+
106
+ describe "#add_key" do
107
+ let(:keyring) { Keyring.new }
108
+ let(:k1) { Key.new(SecureRandom.uuid, SecureRandom.base64(32), Time.now) }
109
+
110
+ it "adds keys" do
111
+ expect(keyring.keys).to be_empty
112
+ expect { keyring.add_key(k1) }.to change { keyring.keys.count }.by 1
113
+ expect(keyring.keys[0]).to be k1
114
+ end
115
+ end
116
+
117
+ describe "#drop_key" do
118
+ let(:keyring) { Keyring.new }
119
+ let(:k1) { Key.new(SecureRandom.uuid, SecureRandom.base64(32), Time.now) }
120
+ let(:k2) { Key.new(SecureRandom.uuid, SecureRandom.base64(32), Time.now) }
121
+
122
+ before do
123
+ keyring.add_key(k1)
124
+ keyring.add_key(k2)
125
+ end
126
+
127
+ it "drops keys by identity" do
128
+ expect(keyring.keys.count).to eq 2
129
+ expect { keyring.drop_key(k1) }.to change { keyring.keys.count }.by -1
130
+ expect(keyring.keys.count).to eq 1
131
+ expect(keyring.keys[0]).to be k2
132
+ end
133
+
134
+ it "drops keys by identifier" do
135
+ expect(keyring.keys.count).to eq 2
136
+ expect { keyring.drop_key(k1.id) }.to change { keyring.keys.count }.by -1
137
+ expect(keyring.keys.count).to eq 1
138
+ expect(keyring.keys[0]).to be k2
139
+ end
140
+ end
141
+
142
+ describe "#to_json" do
143
+ let(:keyring) { Keyring.new }
144
+ let(:k1) { Key.new(SecureRandom.uuid, SecureRandom.base64(32), Time.now) }
145
+ let(:k2) { Key.new(SecureRandom.uuid, SecureRandom.base64(32), Time.now) }
146
+
147
+ before do
148
+ keyring.add_key(k1)
149
+ keyring.add_key(k2)
150
+ end
151
+
152
+ it "serializes the keyring to an expected format" do
153
+ keyring_data = keyring.to_json
154
+ reparsed = JSON.parse(keyring_data)
155
+ expect(reparsed[0]["id"]).to eq k1.id
156
+ expect(reparsed[0]["value"]).to eq k1.value
157
+ expect(reparsed[0]["created_at"]).to eq k1.created_at.to_s
158
+
159
+ expect(reparsed[1]["id"]).to eq k2.id
160
+ expect(reparsed[1]["value"]).to eq k2.value
161
+ expect(reparsed[1]["created_at"]).to eq k2.created_at.to_s
162
+ end
163
+ end
164
+
165
+ describe "#current_key" do
166
+ let(:keyring) { Keyring.new }
167
+ let(:k1) { Key.new(SecureRandom.uuid, SecureRandom.base64(32), Time.now - 3) }
168
+ let(:k2) { Key.new(SecureRandom.uuid, SecureRandom.base64(32), Time.now) }
169
+
170
+ before do
171
+ keyring.add_key(k1)
172
+ keyring.add_key(k2)
173
+ end
174
+
175
+ it "returns the newest key" do
176
+ expect(keyring.current_key).to eq k2
177
+ end
178
+
179
+ it "raise if no keys are registered" do
180
+ other_keyring = Keyring.new
181
+ expect { other_keyring.current_key }.to raise_error(KeyringEmpty)
182
+ end
183
+ end
184
+ end
@@ -0,0 +1,34 @@
1
+ require 'spec_helper'
2
+
3
+ describe AttrVault::Secret do
4
+ it "can resolve a URL safe base64 encoded 32 byte string" do
5
+ resolves_input(Base64.urlsafe_encode64("A"*16 + "B"*16))
6
+ end
7
+
8
+ it "can resolve a base64 encoded 32 byte string" do
9
+ resolves_input(Base64.encode64("A"*16 + "B"*16))
10
+ end
11
+
12
+ it "can resolve a 32 byte string without encoding" do
13
+ resolves_input("A"*16 + "B"*16)
14
+ end
15
+
16
+ it "fails loudly when an invalid secret is provided" do
17
+ secret = Base64.urlsafe_encode64("bad")
18
+ expect do
19
+ AttrVault::Secret.new(secret)
20
+ end.to raise_error(AttrVault::InvalidSecret)
21
+ end
22
+
23
+ def resolves_input(input)
24
+ secret = AttrVault::Secret.new(input)
25
+
26
+ expect(
27
+ secret.signing_key
28
+ ).to eq("A"*16)
29
+
30
+ expect(
31
+ secret.encryption_key
32
+ ).to eq("B"*16)
33
+ end
34
+ end
@@ -0,0 +1,328 @@
1
+ require 'spec_helper'
2
+ require 'json'
3
+
4
+ describe AttrVault do
5
+ context "with a single encrypted column" do
6
+ let(:key_id) { '80a8571b-dc8a-44da-9b89-caee87e41ce2' }
7
+ let(:key_data) {
8
+ [{
9
+ id: key_id,
10
+ value: 'aFJDXs+798G7wgS/nap21LXIpm/Rrr39jIVo2m/cdj8=',
11
+ created_at: Time.now }].to_json
12
+ }
13
+ let(:item) {
14
+ # the let form can't be evaluated inside the class definition
15
+ # because Ruby scoping rules were written by H.P. Lovecraft, so
16
+ # we create a local here to work around that
17
+ k = key_data
18
+ Class.new(Sequel::Model(:items)) do
19
+ include AttrVault
20
+ vault_keyring k
21
+ vault_attr :secret
22
+ end
23
+ }
24
+
25
+ context "with a new object" do
26
+ it "does not affect other attributes" do
27
+ not_secret = 'jimi hendrix was rather talented'
28
+ s = item.create(not_secret: not_secret)
29
+ s.reload
30
+ expect(s.not_secret).to eq(not_secret)
31
+ expect(s.this.where(not_secret: not_secret).count).to eq 1
32
+ end
33
+
34
+ it "encrypts non-empty values" do
35
+ secret = 'lady gaga? also rather talented'
36
+ s = item.create(secret: secret)
37
+ s.reload
38
+ expect(s.secret).to eq(secret)
39
+ s.columns.each do |col|
40
+ expect(s.this.where(Sequel.cast(Sequel.cast(col, :text), :bytea) => secret).count).to eq 0
41
+ end
42
+ end
43
+
44
+ it "stores empty values as empty" do
45
+ secret = ''
46
+ s = item.create(secret: secret)
47
+ s.reload
48
+ expect(s.secret).to eq('')
49
+ expect(s.secret_encrypted).to eq('')
50
+ end
51
+
52
+ it "stores nil values as nil" do
53
+ s = item.create(secret: nil)
54
+ s.reload
55
+ expect(s.secret).to be_nil
56
+ expect(s.secret_encrypted).to be_nil
57
+ end
58
+
59
+ it "stores the key id" do
60
+ secret = 'it was professor plum with the wrench in the library'
61
+ s = item.create(secret: secret)
62
+ s.reload
63
+ expect(s.key_id).to eq(key_id)
64
+ end
65
+ end
66
+
67
+ context "with an existing object" do
68
+ it "does not affect other attributes" do
69
+ not_secret = 'soylent is not especially tasty'
70
+ s = item.create
71
+ s.update(not_secret: not_secret)
72
+ s.reload
73
+ expect(s.not_secret).to eq(not_secret)
74
+ expect(s.this.where(not_secret: not_secret).count).to eq 1
75
+ end
76
+
77
+ it "encrypts non-empty values" do
78
+ secret = 'soylent green is made of people'
79
+ s = item.create
80
+ s.update(secret: secret)
81
+ s.reload
82
+ expect(s.secret).to eq(secret)
83
+ s.columns.each do |col|
84
+ expect(s.this.where(Sequel.cast(Sequel.cast(col, :text), :bytea) => secret).count).to eq 0
85
+ end
86
+ end
87
+
88
+ it "stores empty values as empty" do
89
+ s = item.create(secret: "darth vader is luke's father")
90
+ s.update(secret: '')
91
+ s.reload
92
+ expect(s.secret).to eq('')
93
+ expect(s.secret_encrypted).to eq('')
94
+ end
95
+
96
+ it "leaves nil values as nil" do
97
+ s = item.create(secret: "dr. crowe was dead all along")
98
+ s.update(secret: nil)
99
+ s.reload
100
+ expect(s.secret).to be_nil
101
+ expect(s.secret_encrypted).to be_nil
102
+ end
103
+
104
+ it "stores the key id" do
105
+ secret = 'animal style'
106
+ s = item.create
107
+ s.update(secret: secret)
108
+ s.reload
109
+ expect(s.key_id).to eq(key_id)
110
+ end
111
+ end
112
+ end
113
+
114
+ context "with multiple encrypted columns" do
115
+ let(:key_data) {
116
+ [{
117
+ id: '80a8571b-dc8a-44da-9b89-caee87e41ce2',
118
+ value: 'aFJDXs+798G7wgS/nap21LXIpm/Rrr39jIVo2m/cdj8=',
119
+ created_at: Time.now }].to_json
120
+ }
121
+ let(:item) {
122
+ k = key_data
123
+ Class.new(Sequel::Model(:items)) do
124
+ include AttrVault
125
+ vault_keyring k
126
+ vault_attr :secret
127
+ vault_attr :other
128
+ end
129
+ }
130
+
131
+ it "does not clobber other attributes" do
132
+ secret1 = "superman is really mild-mannered reporter clark kent"
133
+ secret2 = "batman is really millionaire playboy bruce wayne"
134
+ s = item.create(secret: secret1)
135
+ s.reload
136
+ expect(s.secret).to eq secret1
137
+ s.update(other: secret2)
138
+ s.reload
139
+ expect(s.secret).to eq secret1
140
+ expect(s.other).to eq secret2
141
+ end
142
+ end
143
+
144
+ context "with items encrypted with an older key" do
145
+ let(:key1_id) { '80a8571b-dc8a-44da-9b89-caee87e41ce2' }
146
+ let(:key1) {
147
+ {
148
+ id: key1_id,
149
+ value: 'aFJDXs+798G7wgS/nap21LXIpm/Rrr39jIVo2m/cdj8=',
150
+ created_at: Time.new(2014, 1, 1, 0, 0, 0)
151
+ }
152
+ }
153
+
154
+ let(:key2_id) { '0a85781b-d8ac-4a4d-89b9-acee874e1ec2' }
155
+ let(:key2) {
156
+ {
157
+ id: key2_id,
158
+ value: 'hUL1orBBRckZOuSuptRXYMV9lx5Qp54zwFUVwpwTpdk=',
159
+ created_at: Time.new(2014, 2, 1, 0, 0, 0)
160
+ }
161
+ }
162
+ let(:partial_keyring) {
163
+ [key1].to_json
164
+ }
165
+
166
+ let(:full_keyring) {
167
+ [key1, key2].to_json
168
+ }
169
+ let(:item1) {
170
+ k = partial_keyring
171
+ Class.new(Sequel::Model(:items)) do
172
+ include AttrVault
173
+ vault_keyring k
174
+ vault_attr :secret
175
+ vault_attr :other
176
+ end
177
+ }
178
+ let(:item2) {
179
+ k = full_keyring
180
+ Class.new(Sequel::Model(:items)) do
181
+ include AttrVault
182
+ vault_keyring k
183
+ vault_attr :secret
184
+ vault_attr :other
185
+ end
186
+ }
187
+
188
+ it "rewrites the items using the current key" do
189
+ secret1 = 'mrs. doubtfire is really a man'
190
+ secret2 = 'tootsie? also a man'
191
+ record = item1.create(secret: secret1)
192
+ expect(record.key_id).to eq key1_id
193
+ expect(record.secret).to eq secret1
194
+
195
+ old_secret_encrypted = record.secret_encrypted
196
+ old_secret_hmac = record.secret_hmac
197
+
198
+ new_key_record = item2[record.id]
199
+ new_key_record.update(secret: secret2)
200
+ new_key_record.reload
201
+
202
+ expect(new_key_record.key_id).to eq key2_id
203
+ expect(new_key_record.secret).to eq secret2
204
+ expect(new_key_record.secret_encrypted).not_to eq old_secret_encrypted
205
+ expect(new_key_record.secret_hmac).not_to eq old_secret_hmac
206
+ end
207
+
208
+ it "rewrites the items using the current key even if they are not updated" do
209
+ secret1 = 'the planet of the apes is really earth'
210
+ secret2 = 'the answer is 42'
211
+ record = item1.create(secret: secret1)
212
+ expect(record.key_id).to eq key1_id
213
+ expect(record.secret).to eq secret1
214
+
215
+ old_secret_encrypted = record.secret_encrypted
216
+ old_secret_hmac = record.secret_hmac
217
+
218
+ new_key_record = item2[record.id]
219
+ new_key_record.update(other: secret2)
220
+ new_key_record.reload
221
+
222
+ expect(new_key_record.key_id).to eq key2_id
223
+ expect(new_key_record.secret).to eq secret1
224
+ expect(new_key_record.secret_encrypted).not_to eq old_secret_encrypted
225
+ expect(new_key_record.secret_hmac).not_to eq old_secret_hmac
226
+ expect(new_key_record.other).to eq secret2
227
+ end
228
+ end
229
+
230
+ context "with plaintext source fields" do
231
+ let(:key_id) { '80a8571b-dc8a-44da-9b89-caee87e41ce2' }
232
+ let(:key_data) {
233
+ [{
234
+ id: key_id,
235
+ value: 'aFJDXs+798G7wgS/nap21LXIpm/Rrr39jIVo2m/cdj8=',
236
+ created_at: Time.now }].to_json
237
+ }
238
+ let(:item1) {
239
+ k = key_data
240
+ Class.new(Sequel::Model(:items)) do
241
+ include AttrVault
242
+ vault_keyring k
243
+ vault_attr :secret
244
+ vault_attr :other
245
+ end
246
+ }
247
+ let(:item2) {
248
+ k = key_data
249
+ Class.new(Sequel::Model(:items)) do
250
+ include AttrVault
251
+ vault_keyring k
252
+ vault_attr :secret, plaintext_source_field: :not_secret
253
+ vault_attr :other, plaintext_source_field: :other_not_secret
254
+ end
255
+ }
256
+
257
+ it "copies a plaintext field to an encrypted field when saving the object" do
258
+ becomes_secret = 'the location of the lost continent of atlantis'
259
+ s = item1.create(not_secret: becomes_secret)
260
+ reloaded = item2[s.id]
261
+ expect(reloaded.not_secret).to eq becomes_secret
262
+ reloaded.save
263
+ reloaded.reload
264
+ expect(reloaded.not_secret).to be_nil
265
+ expect(reloaded.secret).to eq becomes_secret
266
+ end
267
+
268
+ it "supports converting multiple fields" do
269
+ becomes_secret1 = 'the location of the fountain of youth'
270
+ becomes_secret2 = 'the location of the lost city of el dorado'
271
+ s = item1.create(not_secret: becomes_secret1, other_not_secret: becomes_secret2)
272
+ reloaded = item2[s.id]
273
+ expect(reloaded.not_secret).to eq becomes_secret1
274
+ expect(reloaded.other_not_secret).to eq becomes_secret2
275
+ reloaded.save
276
+ reloaded.reload
277
+ expect(reloaded.not_secret).to be_nil
278
+ expect(reloaded.secret).to eq becomes_secret1
279
+ expect(reloaded.other_not_secret).to be_nil
280
+ expect(reloaded.other).to eq becomes_secret2
281
+ end
282
+ end
283
+
284
+ context "with renamed database fields" do
285
+ let(:key_data) {
286
+ [{
287
+ id: '80a8571b-dc8a-44da-9b89-caee87e41ce2',
288
+ value: 'aFJDXs+798G7wgS/nap21LXIpm/Rrr39jIVo2m/cdj8=',
289
+ created_at: Time.now }].to_json
290
+ }
291
+
292
+ it "supports renaming the encrypted and hmac fields" do
293
+ k = key_data
294
+ item = Class.new(Sequel::Model(:items)) do
295
+ include AttrVault
296
+ vault_keyring k
297
+ vault_attr :classified_info,
298
+ encrypted_field: :secret_encrypted,
299
+ hmac_field: :secret_hmac
300
+ end
301
+
302
+ secret = "we've secretly replaced the fine coffee they usually serve with Folgers Crystals"
303
+ s = item.create(classified_info: secret)
304
+ s.reload
305
+ expect(s.classified_info).to eq secret
306
+ expect(s.secret_encrypted).not_to eq secret
307
+ expect(s.secret_hmac).not_to be_nil
308
+ end
309
+
310
+ it "supports renaming the key id field" do
311
+ k = key_data
312
+ item = Class.new(Sequel::Model(:items)) do
313
+ include AttrVault
314
+ vault_keyring k, key_field: :alt_key_id
315
+ vault_attr :secret
316
+ end
317
+
318
+ secret = "up up down down left right left right b a"
319
+ s = item.create(secret: secret)
320
+ s.reload
321
+ expect(s.secret).to eq secret
322
+ expect(s.secret_encrypted).not_to eq secret
323
+ expect(s.secret_hmac).not_to be_nil
324
+ expect(s.alt_key_id).not_to be_nil
325
+ expect(s.key_id).to be_nil
326
+ end
327
+ end
328
+ end
@@ -0,0 +1,49 @@
1
+ # This file was generated by the `rspec --init` command. Conventionally, all
2
+ # specs live under a `spec` directory, which RSpec adds to the `$LOAD_PATH`.
3
+ # Require this file using `require "spec_helper"` to ensure that it is only
4
+ # loaded once.
5
+ #
6
+ # See http://rubydoc.info/gems/rspec-core/RSpec/Core/Configuration
7
+
8
+ require 'bundler'
9
+ require 'attr_vault'
10
+
11
+ require 'pg'
12
+ require 'sequel'
13
+
14
+ conn = Sequel.connect(ENV['DATABASE_URL'])
15
+ conn.run 'CREATE EXTENSION IF NOT EXISTS "uuid-ossp"'
16
+ conn.run 'DROP TABLE IF EXISTS items'
17
+ conn.run <<-EOF
18
+ CREATE TABLE items(
19
+ id serial primary key,
20
+ key_id uuid,
21
+ alt_key_id uuid,
22
+ secret_encrypted bytea,
23
+ secret_hmac bytea,
24
+ other_encrypted bytea,
25
+ other_hmac bytea,
26
+ not_secret text,
27
+ other_not_secret text
28
+ )
29
+ EOF
30
+
31
+ RSpec.configure do |config|
32
+ config.run_all_when_everything_filtered = true
33
+ config.filter_run :focus
34
+
35
+ config.before(:example) do
36
+ conn.run 'TRUNCATE items'
37
+ end
38
+
39
+ # Run specs in random order to surface order dependencies. If you find an
40
+ # order dependency and want to debug it, you can fix the order by providing
41
+ # the seed, which is printed after each run.
42
+ # --seed 1234
43
+ config.order = 'random'
44
+
45
+ config.expect_with :rspec do |c|
46
+ c.syntax = :expect
47
+ end
48
+ end
49
+
metadata ADDED
@@ -0,0 +1,120 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: attr_vault
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.0.1
5
+ platform: ruby
6
+ authors:
7
+ - Maciek Sakrejda
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2014-10-29 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: fernet
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '2.1'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '2.1'
27
+ - !ruby/object:Gem::Dependency
28
+ name: rspec
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '3.0'
34
+ type: :development
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '3.0'
41
+ - !ruby/object:Gem::Dependency
42
+ name: pg
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: '0'
48
+ type: :development
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: '0'
55
+ - !ruby/object:Gem::Dependency
56
+ name: sequel
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - "~>"
60
+ - !ruby/object:Gem::Version
61
+ version: '4.13'
62
+ type: :development
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - "~>"
67
+ - !ruby/object:Gem::Version
68
+ version: '4.13'
69
+ description: Encryption at rest made easy
70
+ email:
71
+ - m.sakrejda@gmail.com
72
+ executables: []
73
+ extensions: []
74
+ extra_rdoc_files: []
75
+ files:
76
+ - Gemfile
77
+ - Gemfile.lock
78
+ - LICENSE
79
+ - attr_vault.gemspec
80
+ - lib/attr_vault.rb
81
+ - lib/attr_vault/cryptor.rb
82
+ - lib/attr_vault/encryption.rb
83
+ - lib/attr_vault/errors.rb
84
+ - lib/attr_vault/keyring.rb
85
+ - lib/attr_vault/secret.rb
86
+ - lib/attr_vault/version.rb
87
+ - spec/attr_vault/keyring_spec.rb
88
+ - spec/attr_vault/secret_spec.rb
89
+ - spec/attr_vault_spec.rb
90
+ - spec/spec_helper.rb
91
+ homepage: https://github.com/deafbybeheading/attr_vault
92
+ licenses:
93
+ - MIT
94
+ metadata: {}
95
+ post_install_message:
96
+ rdoc_options: []
97
+ require_paths:
98
+ - lib
99
+ required_ruby_version: !ruby/object:Gem::Requirement
100
+ requirements:
101
+ - - ">="
102
+ - !ruby/object:Gem::Version
103
+ version: '0'
104
+ required_rubygems_version: !ruby/object:Gem::Requirement
105
+ requirements:
106
+ - - ">="
107
+ - !ruby/object:Gem::Version
108
+ version: '0'
109
+ requirements: []
110
+ rubyforge_project:
111
+ rubygems_version: 2.2.2
112
+ signing_key:
113
+ specification_version: 4
114
+ summary: Sequel plugin for encryption at rest
115
+ test_files:
116
+ - spec/attr_vault/keyring_spec.rb
117
+ - spec/attr_vault/secret_spec.rb
118
+ - spec/attr_vault_spec.rb
119
+ - spec/spec_helper.rb
120
+ has_rdoc: