clandestine 0.0.1 → 0.1.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/.gitignore +2 -9
- data/.rspec +2 -0
- data/README.md +30 -18
- data/Rakefile +0 -24
- data/bin/clandestine +1 -1
- data/clandestine.gemspec +3 -1
- data/lib/clandestine.rb +1 -9
- data/lib/clandestine/clandestine_error.rb +4 -0
- data/lib/clandestine/command_line_options.rb +52 -0
- data/lib/clandestine/command_line_runner.rb +86 -0
- data/lib/clandestine/commands.rb +52 -0
- data/lib/clandestine/commands/add.rb +29 -0
- data/lib/clandestine/commands/delete.rb +25 -0
- data/lib/clandestine/commands/get.rb +37 -0
- data/lib/clandestine/commands/remove_safe.rb +18 -0
- data/lib/clandestine/commands/update.rb +32 -0
- data/lib/clandestine/crypt.rb +44 -21
- data/lib/clandestine/io.rb +32 -0
- data/lib/clandestine/password_generator.rb +18 -0
- data/lib/clandestine/safe.rb +71 -44
- data/lib/clandestine/safe_authentication.rb +18 -0
- data/lib/clandestine/safe_location.rb +3 -0
- data/lib/clandestine/version.rb +1 -1
- data/spec/commands_spec.rb +98 -0
- data/spec/crypt_spec.rb +20 -5
- data/spec/safe_authentication_spec.rb +30 -0
- data/spec/spec_helper.rb +0 -8
- metadata +50 -12
- data/lib/clandestine/config.rb +0 -25
- data/lib/clandestine/guard.rb +0 -217
- data/spec/config_spec.rb +0 -47
- data/spec/guard_spec.rb +0 -127
- data/spec/safe_spec.rb +0 -40
@@ -0,0 +1,18 @@
|
|
1
|
+
require_relative '../safe'
|
2
|
+
|
3
|
+
module Clandestine
|
4
|
+
class RemoveSafe
|
5
|
+
attr_reader :safe_password
|
6
|
+
private :safe_password
|
7
|
+
|
8
|
+
def initialize(safe_password)
|
9
|
+
@safe_password = safe_password
|
10
|
+
end
|
11
|
+
|
12
|
+
def remove
|
13
|
+
Safe.new(safe_password).open do |safe|
|
14
|
+
safe.remove
|
15
|
+
end
|
16
|
+
end
|
17
|
+
end
|
18
|
+
end
|
@@ -0,0 +1,32 @@
|
|
1
|
+
require_relative '../safe'
|
2
|
+
require_relative '../io'
|
3
|
+
require_relative '../password_generator'
|
4
|
+
|
5
|
+
module Clandestine
|
6
|
+
module Commands
|
7
|
+
class Update
|
8
|
+
attr_reader :safe_password, :key
|
9
|
+
private :safe_password, :key
|
10
|
+
|
11
|
+
def initialize(safe_password, key = nil)
|
12
|
+
@safe_password = safe_password
|
13
|
+
@key = key.to_sym if key
|
14
|
+
end
|
15
|
+
|
16
|
+
def update
|
17
|
+
Safe.new(safe_password).open do |safe|
|
18
|
+
if key
|
19
|
+
if safe[key]
|
20
|
+
safe.add(key, PasswordGenerator.random_password)
|
21
|
+
else
|
22
|
+
false
|
23
|
+
end
|
24
|
+
else
|
25
|
+
new_password = IO.get_password(true).chomp
|
26
|
+
safe.update_safe_password(new_password)
|
27
|
+
end
|
28
|
+
end
|
29
|
+
end
|
30
|
+
end
|
31
|
+
end
|
32
|
+
end
|
data/lib/clandestine/crypt.rb
CHANGED
@@ -1,29 +1,52 @@
|
|
1
|
+
require 'base64'
|
2
|
+
require 'bcrypt'
|
3
|
+
require_relative 'clandestine_error'
|
4
|
+
|
1
5
|
module Clandestine
|
2
|
-
# This module provides encrypt
|
3
|
-
# and decrypt methods using aes-256-cbc
|
4
6
|
module Crypt
|
5
|
-
|
6
|
-
|
7
|
-
Base64.encode64 aes(:encrypt, password, data)
|
7
|
+
def self.hash_password(password)
|
8
|
+
BCrypt::Password.create(password).b
|
8
9
|
end
|
9
|
-
|
10
|
-
def
|
11
|
-
|
10
|
+
|
11
|
+
def self.matches(hash, password)
|
12
|
+
BCrypt::Password.new(hash) == password
|
12
13
|
end
|
13
|
-
|
14
|
-
|
15
|
-
|
16
|
-
|
17
|
-
|
18
|
-
|
19
|
-
|
20
|
-
|
21
|
-
|
14
|
+
|
15
|
+
def self.encrypt(data, password)
|
16
|
+
cipher = aes(:encrypt)
|
17
|
+
cipher.iv = iv = cipher.random_iv
|
18
|
+
key_len = cipher.key_len
|
19
|
+
salt = OpenSSL::Random.random_bytes 16
|
20
|
+
cipher.key = key = get_key(password, key_len, salt)
|
21
|
+
encrypted = cipher.update(data) << cipher.final
|
22
|
+
Base64.encode64 encrypted << salt << iv
|
23
|
+
end
|
24
|
+
|
25
|
+
def self.decrypt(data, password)
|
26
|
+
data = Base64.decode64 data
|
27
|
+
cipher = aes(:decrypt)
|
28
|
+
cipher.iv = data.slice! -16..-1
|
29
|
+
key_len = cipher.key_len
|
30
|
+
salt = data.slice! -16..-1
|
31
|
+
cipher.key = get_key(password,key_len, salt)
|
32
|
+
cipher.update(data) << cipher.final
|
33
|
+
rescue OpenSSL::Cipher::CipherError
|
34
|
+
raise ClandestineError, 'Invalid password!'
|
35
|
+
end
|
36
|
+
|
37
|
+
private
|
38
|
+
|
39
|
+
def self.aes(type)
|
22
40
|
aes = OpenSSL::Cipher.new("AES-256-CBC")
|
23
|
-
aes.send
|
24
|
-
aes
|
25
|
-
|
26
|
-
|
41
|
+
aes.send type
|
42
|
+
aes
|
43
|
+
end
|
44
|
+
|
45
|
+
def self.get_key(password, length, salt)
|
46
|
+
iter = 20000
|
47
|
+
digest = OpenSSL::Digest::SHA256.new
|
48
|
+
OpenSSL::PKCS5.pbkdf2_hmac(password, salt, iter, length, digest)
|
27
49
|
end
|
50
|
+
|
28
51
|
end
|
29
52
|
end
|
@@ -0,0 +1,32 @@
|
|
1
|
+
require 'highline/import'
|
2
|
+
|
3
|
+
module Clandestine
|
4
|
+
class IO
|
5
|
+
def self.copy_to_clipboard(value)
|
6
|
+
::IO.popen('pbcopy', 'w') { |b| b.print "#{value}" }
|
7
|
+
say("Password on clipboard countdown: ")
|
8
|
+
10.downto(1) do |n|
|
9
|
+
sleep(1);
|
10
|
+
n == 1 ? say("1") : say("#{n} ")
|
11
|
+
end
|
12
|
+
::IO.popen('pbcopy', 'w') { |b| b.print "" }
|
13
|
+
end
|
14
|
+
|
15
|
+
def self.print_keys(contents)
|
16
|
+
puts "-------------"
|
17
|
+
puts "Safe Contents"
|
18
|
+
puts "-------------"
|
19
|
+
contents.sort.each { |key| puts key.to_s }
|
20
|
+
end
|
21
|
+
|
22
|
+
def self.get_password(safe_password = false)
|
23
|
+
message = if safe_password
|
24
|
+
"Enter the new safe password"
|
25
|
+
else
|
26
|
+
"Enter the safe password"
|
27
|
+
end
|
28
|
+
ask(message) { |q| q.echo = "*" }
|
29
|
+
end
|
30
|
+
|
31
|
+
end
|
32
|
+
end
|
@@ -0,0 +1,18 @@
|
|
1
|
+
module Clandestine
|
2
|
+
class PasswordGenerator
|
3
|
+
LETTERS = ('a'..'z').to_a + ('A'..'Z').to_a
|
4
|
+
NUMS = ('0'..'9').to_a * 5
|
5
|
+
CHARS = ['!', '@', '#', '$', '%', '^', '&', '*', '(', ')'] * 5
|
6
|
+
|
7
|
+
def self.random_password
|
8
|
+
password = ''
|
9
|
+
generator = Random.new(Random.srand)
|
10
|
+
chars = LETTERS + NUMS + CHARS
|
11
|
+
chars.shuffle!
|
12
|
+
12.times do |n|
|
13
|
+
password << chars[generator.rand(0..chars.size - 1)]
|
14
|
+
end
|
15
|
+
password
|
16
|
+
end
|
17
|
+
end
|
18
|
+
end
|
data/lib/clandestine/safe.rb
CHANGED
@@ -1,54 +1,81 @@
|
|
1
|
+
require 'pstore'
|
2
|
+
require_relative 'crypt'
|
3
|
+
require_relative 'safe_location'
|
4
|
+
require_relative 'safe_authentication'
|
5
|
+
|
1
6
|
module Clandestine
|
2
|
-
# The Safe class interacts with a file (safe),
|
3
|
-
# storing, retrieving, or removing data from it.
|
4
7
|
class Safe
|
5
|
-
|
6
|
-
|
7
|
-
|
8
|
-
|
9
|
-
|
10
|
-
|
11
|
-
@
|
12
|
-
|
13
|
-
if
|
14
|
-
|
8
|
+
|
9
|
+
attr_reader :safe, :password, :location
|
10
|
+
private :safe, :password, :location
|
11
|
+
|
12
|
+
def initialize(password)
|
13
|
+
@password = password
|
14
|
+
@location = ENV['CLANDESTINE_SAFE'] || SAFE_LOCATION
|
15
|
+
@safe = PStore.new(location)
|
16
|
+
if first_access(location)
|
17
|
+
open do |s|
|
18
|
+
safe[:safe_password] = Crypt.hash_password(IO.get_password(true))
|
19
|
+
end
|
15
20
|
end
|
16
21
|
end
|
17
|
-
|
18
|
-
|
19
|
-
|
20
|
-
|
21
|
-
File.open(@safe_location, 'w') { |f| f << nil }
|
22
|
+
|
23
|
+
def open
|
24
|
+
safe.transaction do
|
25
|
+
yield self
|
22
26
|
end
|
23
27
|
end
|
24
|
-
|
25
|
-
|
26
|
-
|
27
|
-
|
28
|
-
|
29
|
-
|
30
|
-
|
31
|
-
|
28
|
+
|
29
|
+
def contents
|
30
|
+
contents = safe.roots
|
31
|
+
contents.delete(:safe_password)
|
32
|
+
contents
|
33
|
+
end
|
34
|
+
|
35
|
+
def [](key)
|
36
|
+
return if !exists?(key)
|
37
|
+
SafeAuthentication.authenticate(safe, password)
|
38
|
+
value = safe[key]
|
39
|
+
Crypt.decrypt(value, password)
|
40
|
+
end
|
41
|
+
|
42
|
+
alias :get :[]
|
43
|
+
|
44
|
+
def []=(key, value)
|
45
|
+
SafeAuthentication.authenticate(safe, password)
|
46
|
+
safe[key] = Crypt.encrypt value, password
|
47
|
+
true
|
48
|
+
end
|
49
|
+
|
50
|
+
alias :add :[]=
|
51
|
+
|
52
|
+
def delete(key)
|
53
|
+
SafeAuthentication.authenticate(safe, password)
|
54
|
+
safe.delete(key)
|
55
|
+
true
|
56
|
+
end
|
57
|
+
|
58
|
+
def update_safe_password(new_password)
|
59
|
+
SafeAuthentication.authenticate(safe, password)
|
60
|
+
safe[:safe_password] = Crypt.hash_password(new_password)
|
61
|
+
true
|
62
|
+
end
|
63
|
+
|
64
|
+
def remove
|
65
|
+
SafeAuthentication.authenticate(safe, password)
|
66
|
+
File.delete location
|
67
|
+
true
|
68
|
+
end
|
69
|
+
|
70
|
+
def exists?(key)
|
71
|
+
SafeAuthentication.authenticate(safe, password)
|
72
|
+
!safe[key].nil?
|
32
73
|
end
|
33
|
-
|
34
|
-
|
35
|
-
|
36
|
-
|
37
|
-
|
38
|
-
end
|
39
|
-
|
40
|
-
# Unlocks the safe by decrypting the data
|
41
|
-
# currently in the safe with the specified password
|
42
|
-
# If the password is incorrect an OpenSSL::Cipher::CipherError
|
43
|
-
# exception is thrown
|
44
|
-
def unlock(password)
|
45
|
-
decrypt(IO.readlines(@safe_location).join, password) unless File.zero? @safe_location
|
46
|
-
end
|
47
|
-
|
48
|
-
# Locks the safe by encrypting the data with the
|
49
|
-
# specified password
|
50
|
-
def lock(data, password)
|
51
|
-
File.open(@safe_location, 'w') { |f| f << encrypt(data, password) }
|
74
|
+
|
75
|
+
private
|
76
|
+
|
77
|
+
def first_access(location)
|
78
|
+
!File.exists?(location) || ::IO.readlines(location).empty?
|
52
79
|
end
|
53
80
|
end
|
54
81
|
end
|
@@ -0,0 +1,18 @@
|
|
1
|
+
require_relative 'crypt'
|
2
|
+
require_relative 'clandestine_error'
|
3
|
+
|
4
|
+
module Clandestine
|
5
|
+
class SafeAuthentication
|
6
|
+
|
7
|
+
def self.authenticate(safe, password)
|
8
|
+
abort 'Invalid password!' unless authenticated?(safe, password)
|
9
|
+
true
|
10
|
+
end
|
11
|
+
|
12
|
+
private
|
13
|
+
|
14
|
+
def self.authenticated?(safe, password)
|
15
|
+
Crypt.matches(safe[:safe_password], password)
|
16
|
+
end
|
17
|
+
end
|
18
|
+
end
|
data/lib/clandestine/version.rb
CHANGED
@@ -0,0 +1,98 @@
|
|
1
|
+
require 'spec_helper'
|
2
|
+
require 'clandestine/commands'
|
3
|
+
require 'clandestine/crypt'
|
4
|
+
require 'clandestine/safe'
|
5
|
+
require 'bcrypt'
|
6
|
+
require 'pstore'
|
7
|
+
|
8
|
+
module Clandestine
|
9
|
+
describe Commands do
|
10
|
+
|
11
|
+
before :each do
|
12
|
+
@temp_dir = File.dirname(__FILE__) + "/tmp"
|
13
|
+
Dir.mkdir(@temp_dir) unless File.exists? @temp_dir
|
14
|
+
ENV['CLANDESTINE_SAFE'] = "#{@temp_dir}/pswds"
|
15
|
+
store = PStore.new "#{@temp_dir}/pswds"
|
16
|
+
store.transaction do |s|
|
17
|
+
s[:safe_password] = BCrypt::Password.create 'password'
|
18
|
+
s[:gmail] = Crypt.encrypt 'gmail_password', 'password'
|
19
|
+
end
|
20
|
+
end
|
21
|
+
|
22
|
+
after :each do
|
23
|
+
passwords = "#{@temp_dir}/pswds"
|
24
|
+
File.delete passwords if File.exists? passwords
|
25
|
+
Dir.rmdir @temp_dir if File.exists? @temp_dir
|
26
|
+
end
|
27
|
+
|
28
|
+
describe '.version' do
|
29
|
+
it 'prints version' do
|
30
|
+
Commands.version.should eq VERSION
|
31
|
+
end
|
32
|
+
end
|
33
|
+
|
34
|
+
describe '.add' do
|
35
|
+
it 'adds key and password to safe' do
|
36
|
+
PasswordGenerator.should_receive(:random_password).and_return 'twitter_password'
|
37
|
+
Commands.add 'twitter', 'password'
|
38
|
+
Safe.new('password').open do |s|
|
39
|
+
s[:twitter].should eq 'twitter_password'
|
40
|
+
end
|
41
|
+
end
|
42
|
+
end
|
43
|
+
|
44
|
+
describe '.get' do
|
45
|
+
context 'without argument' do
|
46
|
+
it 'prints accounts' do
|
47
|
+
Commands.get(nil, 'password').should eq [:gmail]
|
48
|
+
end
|
49
|
+
end
|
50
|
+
|
51
|
+
context 'with argument' do
|
52
|
+
it 'gets password for key from safe' do
|
53
|
+
IO.should_receive(:get_password).and_return "password"
|
54
|
+
Commands.get('gmail').should eq 'gmail_password'
|
55
|
+
end
|
56
|
+
end
|
57
|
+
end
|
58
|
+
|
59
|
+
describe '.delete' do
|
60
|
+
context 'with argument' do
|
61
|
+
it 'deletes key and password from safe' do
|
62
|
+
Commands.delete 'gmail', 'password'
|
63
|
+
Safe.new('password').open do |s|
|
64
|
+
s.contents.should be_empty
|
65
|
+
end
|
66
|
+
end
|
67
|
+
end
|
68
|
+
end
|
69
|
+
|
70
|
+
describe '.update' do
|
71
|
+
it 'updates password related to key' do
|
72
|
+
PasswordGenerator.should_receive(:random_password).and_return 'updated_password'
|
73
|
+
Commands.update 'gmail', 'password'
|
74
|
+
Safe.new('password').open do |s|
|
75
|
+
s[:gmail].should eq 'updated_password'
|
76
|
+
end
|
77
|
+
end
|
78
|
+
end
|
79
|
+
|
80
|
+
describe '.location' do
|
81
|
+
it 'shows current safe location' do
|
82
|
+
Commands.location.should eq "#{@temp_dir}/pswds"
|
83
|
+
end
|
84
|
+
|
85
|
+
it 'defaults to ENV["HOME"]/.pswds' do
|
86
|
+
ENV.delete('CLANDESTINE_SAFE')
|
87
|
+
Commands.location.should eq "#{ENV['HOME']}/.cls"
|
88
|
+
end
|
89
|
+
end
|
90
|
+
|
91
|
+
describe '.remove' do
|
92
|
+
it 'removes safe from file system' do
|
93
|
+
Commands.remove 'password'
|
94
|
+
File.exists?(ENV['CLANDESTINE_SAFE'] = "#{@temp_dir}/pswds").should eq false
|
95
|
+
end
|
96
|
+
end
|
97
|
+
end
|
98
|
+
end
|
data/spec/crypt_spec.rb
CHANGED
@@ -1,11 +1,26 @@
|
|
1
1
|
require 'spec_helper'
|
2
|
+
require 'clandestine/crypt'
|
2
3
|
|
3
4
|
describe Clandestine::Crypt do
|
4
|
-
|
5
|
-
|
6
|
-
|
5
|
+
|
6
|
+
describe '#encrypt' do
|
7
|
+
it 'encrypts data with password' do
|
8
|
+
encrypted = Clandestine::Crypt.encrypt("myaccountpassword", "safe_password")
|
9
|
+
encrypted.should_not eql "myaccountpassword"
|
10
|
+
encrypted.should_not eql nil
|
11
|
+
end
|
7
12
|
end
|
8
|
-
|
9
|
-
|
13
|
+
|
14
|
+
describe '#decrypt' do
|
15
|
+
it 'decrypts data with password' do
|
16
|
+
decrypted = Clandestine::Crypt.decrypt("OePyNbyDMUbza5zUVor4wWS8Tb5u26FmxGXpC9XENWeMNJYJj1WKJlDOZYxG\nXpSqHSSNaT4dhUsX+T7abgZ1qg==\n", "safe_password")
|
17
|
+
decrypted.should eql "myaccountpassword"
|
18
|
+
end
|
19
|
+
|
20
|
+
it 'does not decrypt with wrong password' do
|
21
|
+
expect {
|
22
|
+
Clandestine::Crypt.decrypt("OePyNbyDMUbza5zUVor4wWS8Tb5u26FmxGXpC9XENWeMNJYJj1WKJlDOZYxG\nXpSqHSSNaT4dhUsX+T7abgZ1qg==\n", "wrong_password")
|
23
|
+
}.to raise_error Clandestine::ClandestineError
|
24
|
+
end
|
10
25
|
end
|
11
26
|
end
|