donjon 0.0.1
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.gitignore +17 -0
- data/.rspec +6 -0
- data/.ruby-version +1 -0
- data/Gemfile +4 -0
- data/Guardfile +10 -0
- data/LICENSE.txt +22 -0
- data/README.md +37 -0
- data/Rakefile +1 -0
- data/bin/dj +6 -0
- data/donjon.gemspec +28 -0
- data/lib/core_ext/assert.rb +6 -0
- data/lib/core_ext/io_get_password.rb +24 -0
- data/lib/donjon.rb +2 -0
- data/lib/donjon/cli.rb +4 -0
- data/lib/donjon/commands/base.rb +66 -0
- data/lib/donjon/commands/config.rb +41 -0
- data/lib/donjon/commands/user.rb +36 -0
- data/lib/donjon/commands/vault.rb +28 -0
- data/lib/donjon/configurator.rb +166 -0
- data/lib/donjon/database.rb +59 -0
- data/lib/donjon/encrypted_file.rb +87 -0
- data/lib/donjon/repository.rb +6 -0
- data/lib/donjon/settings.rb +60 -0
- data/lib/donjon/shell.rb +19 -0
- data/lib/donjon/user.rb +61 -0
- data/lib/donjon/version.rb +3 -0
- data/spec/donjon/database_spec.rb +76 -0
- data/spec/donjon/encrypted_file_spec.rb +110 -0
- data/spec/donjon/repository_spec.rb +24 -0
- data/spec/donjon/user_spec.rb +98 -0
- data/spec/spec_helper.rb +78 -0
- data/spec/support/keys.rb +7 -0
- data/spec/support/repos.rb +22 -0
- metadata +183 -0
@@ -0,0 +1,59 @@
|
|
1
|
+
require 'donjon/encrypted_file'
|
2
|
+
require 'json'
|
3
|
+
|
4
|
+
module Donjon
|
5
|
+
class Database
|
6
|
+
def initialize(actor:)
|
7
|
+
@actor = actor
|
8
|
+
end
|
9
|
+
|
10
|
+
def [](key)
|
11
|
+
file = _file(key)
|
12
|
+
return unless file.readable?
|
13
|
+
_key, value = _unpack(file.read)
|
14
|
+
assert(key == _key, "bad stored data for #{key}!")
|
15
|
+
return value
|
16
|
+
end
|
17
|
+
|
18
|
+
def []=(key, value)
|
19
|
+
_file(key).write(_pack(key, value))
|
20
|
+
end
|
21
|
+
|
22
|
+
def each
|
23
|
+
parent = @actor.repo.join('data')
|
24
|
+
return unless parent.exist?
|
25
|
+
parent.children.each do |child|
|
26
|
+
path = "data/#{child.basename}"
|
27
|
+
file = EncryptedFile.new(path: path, actor: @actor)
|
28
|
+
next unless file.readable?
|
29
|
+
yield *_unpack(file.read)
|
30
|
+
end
|
31
|
+
end
|
32
|
+
|
33
|
+
def update
|
34
|
+
each do |key, value|
|
35
|
+
self[key] = value
|
36
|
+
end
|
37
|
+
nil
|
38
|
+
end
|
39
|
+
|
40
|
+
|
41
|
+
private
|
42
|
+
|
43
|
+
def _pack(key, value)
|
44
|
+
JSON.dump([key, value])
|
45
|
+
end
|
46
|
+
|
47
|
+
def _unpack(data)
|
48
|
+
JSON.parse(data)
|
49
|
+
end
|
50
|
+
|
51
|
+
def _hash(key)
|
52
|
+
OpenSSL::Digest::SHA256.hexdigest(key)
|
53
|
+
end
|
54
|
+
|
55
|
+
def _file(key)
|
56
|
+
EncryptedFile.new(path: "data/#{_hash(key)}", actor: @actor)
|
57
|
+
end
|
58
|
+
end
|
59
|
+
end
|
@@ -0,0 +1,87 @@
|
|
1
|
+
require 'openssl'
|
2
|
+
require 'gibberish'
|
3
|
+
require 'core_ext/assert'
|
4
|
+
require 'digest'
|
5
|
+
|
6
|
+
module Donjon
|
7
|
+
class EncryptedFile
|
8
|
+
def initialize(path:, actor:)
|
9
|
+
@path = path
|
10
|
+
@actor = actor
|
11
|
+
end
|
12
|
+
|
13
|
+
def exist?
|
14
|
+
_base_path.exist?
|
15
|
+
end
|
16
|
+
|
17
|
+
def readable?
|
18
|
+
_path_for(@actor).exist?
|
19
|
+
end
|
20
|
+
|
21
|
+
def read
|
22
|
+
path = _path_for(@actor)
|
23
|
+
exist? or raise 'file does not exist'
|
24
|
+
path.exist? or raise 'you were not granted access'
|
25
|
+
data = path.binread
|
26
|
+
_decrypt_from(@actor, data)
|
27
|
+
end
|
28
|
+
|
29
|
+
def write(data)
|
30
|
+
if data.nil?
|
31
|
+
_base_path.rmtree if exist?
|
32
|
+
return
|
33
|
+
end
|
34
|
+
|
35
|
+
User.each(@actor.repo) do |user|
|
36
|
+
payload = _encrypt_for(user, data)
|
37
|
+
path = _path_for(user)
|
38
|
+
path.parent.mkpath
|
39
|
+
path.binwrite(payload)
|
40
|
+
end
|
41
|
+
end
|
42
|
+
|
43
|
+
private
|
44
|
+
|
45
|
+
# random bytes added to the data to encrypt to obfuscate it
|
46
|
+
PADDING = 256
|
47
|
+
|
48
|
+
def _decrypt_from(user, data)
|
49
|
+
encrypted_key = data[0...256]
|
50
|
+
encrypted_data = data[256..-1]
|
51
|
+
|
52
|
+
# _log_key "before decrypt", encrypted_key
|
53
|
+
decrypted_pw = user.key.private_decrypt(encrypted_key)
|
54
|
+
# _log_key "decrypted", decrypted_pw
|
55
|
+
|
56
|
+
assert(decrypted_pw.size == 32)
|
57
|
+
payload = Gibberish::AES.new(decrypted_pw).decrypt(encrypted_data, binary: true)
|
58
|
+
payload[0...-PADDING]
|
59
|
+
end
|
60
|
+
|
61
|
+
def _encrypt_for(user, data)
|
62
|
+
payload = data + OpenSSL::Random.random_bytes(PADDING)
|
63
|
+
password = OpenSSL::Random.random_bytes(32)
|
64
|
+
encrypted_data = Gibberish::AES.new(password).encrypt(payload, binary: true)
|
65
|
+
|
66
|
+
# _log_key "before crypto", password
|
67
|
+
encrypted_key = user.key.public_encrypt(password)
|
68
|
+
# _log_key "encrypted", encrypted_key
|
69
|
+
|
70
|
+
assert(encrypted_key.size == 256)
|
71
|
+
encrypted_key + encrypted_data
|
72
|
+
end
|
73
|
+
|
74
|
+
def _log_key(message, key)
|
75
|
+
puts "#{message}: #{key.bytesize} bytes"
|
76
|
+
puts key.bytes.map { |b| "%02x" % b }.join(":")
|
77
|
+
end
|
78
|
+
|
79
|
+
def _base_path
|
80
|
+
@actor.repo.join(@path)
|
81
|
+
end
|
82
|
+
|
83
|
+
def _path_for(user)
|
84
|
+
_base_path.join("#{user.name}.db")
|
85
|
+
end
|
86
|
+
end
|
87
|
+
end
|
@@ -0,0 +1,60 @@
|
|
1
|
+
require 'yaml'
|
2
|
+
|
3
|
+
module Donjon
|
4
|
+
class Settings
|
5
|
+
attr_reader :path
|
6
|
+
|
7
|
+
def initialize(path = nil)
|
8
|
+
@path = path || _default_path
|
9
|
+
@data = nil
|
10
|
+
end
|
11
|
+
|
12
|
+
def configured?
|
13
|
+
user_name && private_key && vault_path
|
14
|
+
end
|
15
|
+
|
16
|
+
def method_missing(method_name, *args, &block)
|
17
|
+
if method_name.to_s.end_with?('=')
|
18
|
+
set(method_name.to_s.chop, *args)
|
19
|
+
else
|
20
|
+
get(method_name.to_s, *args)
|
21
|
+
end
|
22
|
+
end
|
23
|
+
|
24
|
+
def respond_to?(method_name)
|
25
|
+
!!(method_name.to_s =~ /[a-z][a-z_]*=?/)
|
26
|
+
end
|
27
|
+
|
28
|
+
private
|
29
|
+
|
30
|
+
def get(key)
|
31
|
+
@data ||= _load
|
32
|
+
@data[key]
|
33
|
+
end
|
34
|
+
|
35
|
+
def set(key, value)
|
36
|
+
@data ||= _load
|
37
|
+
@data[key] = value
|
38
|
+
_save(@data)
|
39
|
+
value
|
40
|
+
end
|
41
|
+
|
42
|
+
def _load
|
43
|
+
@path.exist? ? YAML.load_file(@path) : {}
|
44
|
+
end
|
45
|
+
|
46
|
+
def _save(data)
|
47
|
+
@data['timestamp'] = Time.now
|
48
|
+
@path.parent.mkpath
|
49
|
+
@path.write data.to_yaml
|
50
|
+
end
|
51
|
+
|
52
|
+
def _fallback_path
|
53
|
+
Pathname.new('~').join('.donjonrc').expand_path
|
54
|
+
end
|
55
|
+
|
56
|
+
def _default_path
|
57
|
+
ENV.fetch('DONJONRC', _fallback_path)
|
58
|
+
end
|
59
|
+
end
|
60
|
+
end
|
data/lib/donjon/shell.rb
ADDED
@@ -0,0 +1,19 @@
|
|
1
|
+
require 'thor'
|
2
|
+
require 'delegate'
|
3
|
+
require 'singleton'
|
4
|
+
|
5
|
+
module Donjon
|
6
|
+
class Shell < SimpleDelegator
|
7
|
+
include Singleton
|
8
|
+
|
9
|
+
def initialize
|
10
|
+
shell = if $stdout.tty?
|
11
|
+
Thor::Shell::Color.new
|
12
|
+
else
|
13
|
+
Thor::Shell::Basic.new
|
14
|
+
end
|
15
|
+
super(shell)
|
16
|
+
end
|
17
|
+
end
|
18
|
+
end
|
19
|
+
|
data/lib/donjon/user.rb
ADDED
@@ -0,0 +1,61 @@
|
|
1
|
+
require 'yaml'
|
2
|
+
require 'core_ext/assert'
|
3
|
+
|
4
|
+
module Donjon
|
5
|
+
class User
|
6
|
+
attr_reader :name, :key, :repo
|
7
|
+
|
8
|
+
def initialize(name:, key:, repo:)
|
9
|
+
assert(key.n.num_bits == 2048)
|
10
|
+
|
11
|
+
@name = name
|
12
|
+
@key = key
|
13
|
+
@repo = repo
|
14
|
+
end
|
15
|
+
|
16
|
+
def save
|
17
|
+
_path_for(@name, @repo).tap do |path|
|
18
|
+
path.parent.mkpath
|
19
|
+
path.write @key.public_key.to_pem
|
20
|
+
end
|
21
|
+
self
|
22
|
+
end
|
23
|
+
|
24
|
+
private
|
25
|
+
|
26
|
+
module SharedMethods
|
27
|
+
private
|
28
|
+
|
29
|
+
def _path_for(name, repo)
|
30
|
+
repo.join("users/#{name}.pub")
|
31
|
+
end
|
32
|
+
end
|
33
|
+
extend SharedMethods
|
34
|
+
include SharedMethods
|
35
|
+
|
36
|
+
module ClassMethods
|
37
|
+
def find(name:, repo:)
|
38
|
+
path = _path_for(name, repo)
|
39
|
+
return unless path.exist?
|
40
|
+
key = OpenSSL::PKey::RSA.new(path.read)
|
41
|
+
new(name: name, key: key, repo: repo)
|
42
|
+
end
|
43
|
+
|
44
|
+
def each(repo, &block)
|
45
|
+
container = repo.join('users')
|
46
|
+
return unless container.exist?
|
47
|
+
container.children.each do |child|
|
48
|
+
next unless child.extname == '.pub'
|
49
|
+
name = child.basename.to_s.chomp('.pub')
|
50
|
+
key = OpenSSL::PKey::RSA.new(child.read)
|
51
|
+
block.call new(name: name, key: key, repo: repo)
|
52
|
+
end
|
53
|
+
end
|
54
|
+
end
|
55
|
+
extend ClassMethods
|
56
|
+
end
|
57
|
+
|
58
|
+
|
59
|
+
|
60
|
+
end
|
61
|
+
|
@@ -0,0 +1,76 @@
|
|
1
|
+
require 'spec_helper'
|
2
|
+
require 'spec/support/keys'
|
3
|
+
require 'spec/support/repos'
|
4
|
+
require 'donjon/database'
|
5
|
+
require 'donjon/user'
|
6
|
+
|
7
|
+
describe Donjon::Database do
|
8
|
+
let_repo :repo
|
9
|
+
|
10
|
+
let(:actor) {
|
11
|
+
Donjon::User.new(name: 'alice', key: random_key, repo: repo).save
|
12
|
+
}
|
13
|
+
|
14
|
+
let(:other_user) {
|
15
|
+
Donjon::User.new(name: 'bob', key: random_key, repo: repo).save
|
16
|
+
}
|
17
|
+
|
18
|
+
let(:options) {{
|
19
|
+
actor: actor
|
20
|
+
}}
|
21
|
+
|
22
|
+
subject { described_class.new(**options) }
|
23
|
+
|
24
|
+
describe '#initialize' do
|
25
|
+
it 'passes with valid options' do
|
26
|
+
expect { subject }.not_to raise_error
|
27
|
+
end
|
28
|
+
|
29
|
+
it 'requires :actor' do
|
30
|
+
options.delete :actor
|
31
|
+
expect { subject }.to raise_error
|
32
|
+
end
|
33
|
+
end
|
34
|
+
|
35
|
+
describe '#[]=' do
|
36
|
+
it 'passes' do
|
37
|
+
expect {
|
38
|
+
subject['foo'] = 'bar'
|
39
|
+
}.not_to raise_error
|
40
|
+
end
|
41
|
+
end
|
42
|
+
|
43
|
+
describe '#[]' do
|
44
|
+
it 'returns nil is nothing saved' do
|
45
|
+
expect( subject['foo1'] ).to be_nil
|
46
|
+
end
|
47
|
+
|
48
|
+
it 'returns nil if nil saved' do
|
49
|
+
subject['foo2'] = nil
|
50
|
+
expect( subject['foo2'] ).to be_nil
|
51
|
+
end
|
52
|
+
|
53
|
+
it 'returns previously save values' do
|
54
|
+
subject['foo3'] = 'bar3'
|
55
|
+
expect( subject['foo3'] ).to eq('bar3')
|
56
|
+
end
|
57
|
+
|
58
|
+
it 'returns nil when the key is not readable' do
|
59
|
+
subject['foo4'] = 'bar4'
|
60
|
+
other_db = described_class.new(actor: other_user)
|
61
|
+
expect(other_db['foo4']).to be_nil
|
62
|
+
end
|
63
|
+
end
|
64
|
+
|
65
|
+
describe '#update' do
|
66
|
+
it 'makes keys reable for other users' do
|
67
|
+
subject['foo'] = 'bar'
|
68
|
+
other_db = described_class.new(actor: other_user)
|
69
|
+
subject.update
|
70
|
+
expect(other_db['foo']).to eq('bar')
|
71
|
+
end
|
72
|
+
end
|
73
|
+
|
74
|
+
describe '#each' do
|
75
|
+
end
|
76
|
+
end
|
@@ -0,0 +1,110 @@
|
|
1
|
+
require 'spec_helper'
|
2
|
+
require 'donjon/encrypted_file'
|
3
|
+
require 'donjon/user'
|
4
|
+
require 'spec/support/repos'
|
5
|
+
require 'spec/support/keys'
|
6
|
+
|
7
|
+
describe Donjon::EncryptedFile do
|
8
|
+
let_repo :repo
|
9
|
+
|
10
|
+
let(:actor) {
|
11
|
+
Donjon::User.new(key: random_key, name: 'alice', repo: repo)
|
12
|
+
}
|
13
|
+
|
14
|
+
let(:other_user) {
|
15
|
+
Donjon::User.new(key: random_key, name: 'bob', repo: repo)
|
16
|
+
}
|
17
|
+
|
18
|
+
let(:options) {{
|
19
|
+
path: 'foo', actor: actor
|
20
|
+
}}
|
21
|
+
|
22
|
+
subject { described_class.new(**options) }
|
23
|
+
|
24
|
+
describe '#initialize' do
|
25
|
+
it 'passes with valid options' do
|
26
|
+
expect { subject }.not_to raise_error
|
27
|
+
end
|
28
|
+
|
29
|
+
it 'requires :path' do
|
30
|
+
options.delete :path
|
31
|
+
expect { subject }.to raise_error
|
32
|
+
end
|
33
|
+
|
34
|
+
it 'requires :actor' do
|
35
|
+
options.delete :actor
|
36
|
+
expect { subject }.to raise_error
|
37
|
+
end
|
38
|
+
end
|
39
|
+
|
40
|
+
describe '#write' do
|
41
|
+
before { actor.save }
|
42
|
+
|
43
|
+
it 'passes' do
|
44
|
+
expect { subject.write('foo') }.not_to raise_error
|
45
|
+
end
|
46
|
+
|
47
|
+
it 'passes with large data' do
|
48
|
+
expect { subject.write('foo' * 1024) }.not_to raise_error
|
49
|
+
end
|
50
|
+
|
51
|
+
it 'works twice' do
|
52
|
+
expect {
|
53
|
+
2.times { subject.write 'foo' }
|
54
|
+
}.not_to raise_error
|
55
|
+
end
|
56
|
+
end
|
57
|
+
|
58
|
+
describe '#read' do
|
59
|
+
before do
|
60
|
+
actor.save
|
61
|
+
other_user.save
|
62
|
+
|
63
|
+
described_class.
|
64
|
+
new(actor: actor, path: options[:path]).
|
65
|
+
write('hello, world!')
|
66
|
+
end
|
67
|
+
|
68
|
+
|
69
|
+
it 'returns decrypted contents' do
|
70
|
+
expect(subject.read).to eq('hello, world!')
|
71
|
+
end
|
72
|
+
|
73
|
+
it 'works for other users' do
|
74
|
+
data = described_class.
|
75
|
+
new(actor: other_user, path: options[:path]).
|
76
|
+
read
|
77
|
+
expect(data).to eq('hello, world!')
|
78
|
+
end
|
79
|
+
end
|
80
|
+
|
81
|
+
describe '#readable?' do
|
82
|
+
let(:other_file) {
|
83
|
+
described_class.new(actor: other_user, path: options[:path])
|
84
|
+
}
|
85
|
+
|
86
|
+
before { actor.save }
|
87
|
+
|
88
|
+
it 'is false for non-existing files' do
|
89
|
+
expect(subject).not_to be_readable
|
90
|
+
end
|
91
|
+
|
92
|
+
it 'is true for files I wrote' do
|
93
|
+
subject.write 'foo'
|
94
|
+
expect(subject).to be_readable
|
95
|
+
end
|
96
|
+
|
97
|
+
it 'is false for users added after I wrote' do
|
98
|
+
subject.write 'foo'
|
99
|
+
expect(other_file).not_to be_readable
|
100
|
+
end
|
101
|
+
|
102
|
+
it 'is true for users added before I wrote' do
|
103
|
+
other_user.save
|
104
|
+
subject.write 'foo'
|
105
|
+
expect(other_file).to be_readable
|
106
|
+
end
|
107
|
+
end
|
108
|
+
end
|
109
|
+
|
110
|
+
|