clandestine 0.0.1 → 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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
@@ -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
- def encrypt(data, password)
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 decrypt(data, password)
11
- aes(:decrypt, password, Base64.decode64(data))
10
+
11
+ def self.matches(hash, password)
12
+ BCrypt::Password.new(hash) == password
12
13
  end
13
-
14
- # Uses AES-256-CBC to encrypt data
15
- # Key length is 32 bytes
16
- # IV length is 16 bytes
17
- # TODO Use random IV and include it in
18
- # encrypted data rather than using the same
19
- # IV every time.
20
- def aes(mode, password, data)
21
- sha256 = Digest::SHA2.new
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(mode)
24
- aes.key = sha256.digest(password)
25
- aes.iv = '0123456789012345'
26
- '' << aes.update(data) << aes.final
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
@@ -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
- include Clandestine::Crypt
6
- include Clandestine::Config
7
-
8
- # Default initialization of safe
9
- # in PASSWORD_SAFE location
10
- def initialize(safe_location = Clandestine::Config::CONFIG_PATH)
11
- @safe_location = safe_location
12
- File.new @safe_location, 'w' unless File.exists?(@safe_location) || File.symlink?(@safe_location)
13
- if File.symlink?(@safe_location) && !File.exists?(File.readlink @safe_location)
14
- FileUtils.rm_f File.readlink @safe_location
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
- # Empties safe by writing nil to file
19
- def empty_safe
20
- if File.exists? @safe_location
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
- # Checks for contents in safe
26
- def empty?
27
- if File.symlink? @safe_location
28
- IO.readlines(File.readlink @safe_location).empty? if File.exists? File.readlink @safe_location
29
- else
30
- IO.readlines(@safe_location).empty?
31
- end
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
- # Removes safe from file system
35
- def self_destruct
36
- FileUtils.rm_f File.readlink @safe_location if File.symlink? @safe_location
37
- FileUtils.rm_f @safe_location
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
@@ -0,0 +1,3 @@
1
+ module Clandestine
2
+ SAFE_LOCATION = "#{ENV['HOME']}/.cls"
3
+ end
@@ -1,3 +1,3 @@
1
1
  module Clandestine
2
- VERSION = "0.0.1"
2
+ VERSION = "0.1.0"
3
3
  end
@@ -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
- include Clandestine::Crypt
5
- it "should encrypt data" do
6
- encrypt("this is a test",'password').should_not eql "this is a test"
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
- it "should decrypt data" do
9
- decrypt(encrypt("this is a test",'password'), 'password').should eql "this is a test"
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