attr_vault 0.0.1

Sign up to get free protection for your applications and to get access to all the features.
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: