clandestine 0.0.1 → 0.1.0

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.
@@ -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