donjon 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 +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
|
+
|