Class: Yak

Inherits:
Object
  • Object
show all
Defined in:
lib/yak.rb

Overview

Yak is a simple command line app to store and retrieve passwords securely. Retrieved passwords get copied to the clipboard by default. Config can be set in ~/.yakrc:

:session: 30

Session is the length of time in seconds that Yak will remember the master password. If using sessions is not desired, set:

:session: false

To always set the password by default, use:

:password: plain_text_password

To turn off password confirmation prompts:

:confirm_prompt: false

To set the path to the yak data file:

:data_file: /path/to/file

Using bash completion for stored keys:

:bash_completion: true    #=> completion only available during session
:bash_completion: :always #=> completion always available

Constant Summary collapse

VERSION =

Version of Yak.

"1.0.8"
DEFAULT_CONFIG =

Default config used.

{:session => 30, :bash_completion => true}
CIPHER_ERROR =

Different versions of ruby have a different namespace for CipherError

OpenSSL::Cipher::CipherError rescue OpenSSL::CipherError

Instance Attribute Summary collapse

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(user, options = {}) ⇒ Yak

Create a new Yak instance for a given user:

Yak.new "my_user"
Yak.new "my_user", :session => 10
Yak.new `whoami`.chomp, :session => false


345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
# File 'lib/yak.rb', line 345

def initialize user, options={}
  @user     = user
  @input    = HighLine.new $stdin, $stderr

  @confirm_prompt = true
  @confirm_prompt = options[:confirm_prompt] if
    options.has_key? :confirm_prompt

  @yak_dir = File.expand_path "~#{user}/.yak"
  FileUtils.mkdir @yak_dir unless File.directory? @yak_dir

  @pid_file      = File.join @yak_dir, "pid"
  @password_file = File.join @yak_dir, "password"
  @data_file     = options[:data_file] || File.join(@yak_dir, "data")

  @key_list_file  = File.join @yak_dir, "keys"
  @use_completion = options[:bash_completion]

  @session_pid = nil
  @session_pid = File.read(@pid_file).to_i if File.file? @pid_file

  @password = nil

  @cipher = OpenSSL::Cipher::Cipher.new "aes-256-cbc"

  @session_length = options.has_key?(:session) ? options[:session] : 30
end

Instance Attribute Details

#dataObject (readonly)

Returns the value of attribute data.



337
338
339
# File 'lib/yak.rb', line 337

def data
  @data
end

#use_completionObject (readonly)

Returns the value of attribute use_completion.



337
338
339
# File 'lib/yak.rb', line 337

def use_completion
  @use_completion
end

#userObject (readonly)

Returns the value of attribute user.



337
338
339
# File 'lib/yak.rb', line 337

def user
  @user
end

Class Method Details

.check_user_setup(user) ⇒ Object

Setup yak for first run if it hasn’t been.



77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
# File 'lib/yak.rb', line 77

def self.check_user_setup user
  user_config_file = yak_config_file user

  return if File.file? user_config_file

  hl = HighLine.new $stdin, $stderr
  hl.say "\n\nThanks for installing Yak!\n\n"

  setup_bash_completion

  data_file  = prompt_data_loc user, hl

  new_config = DEFAULT_CONFIG.merge(:data_file => data_file)

  make_config_file user, new_config
end

.delete_data(yak) ⇒ Object

Delete the data file of a yak instance after confirming with the user.



203
204
205
# File 'lib/yak.rb', line 203

def self.delete_data yak
  yak.delete_data_file! true
end

.list(yak, name = nil) ⇒ Object

List matched keys of a yak instance.



211
212
213
214
215
216
217
# File 'lib/yak.rb', line 211

def self.list yak, name=nil
  key_regex = /#{name || ".+"}/

  yak.data.each do |key, value|
    $stdout << "#{key}\n" if key =~ key_regex
  end
end

.load_config(user) ⇒ Object

Load the ~/.yakrc file and return.



146
147
148
149
150
# File 'lib/yak.rb', line 146

def self.load_config user
  user_config_file = yak_config_file user

  YAML.load_file user_config_file
end

.make_config_file(user, new_config = DEFAULT_CONFIG) ⇒ Object

Create a new user config file.



156
157
158
159
160
161
162
163
# File 'lib/yak.rb', line 156

def self.make_config_file user, new_config=DEFAULT_CONFIG
  user_config_file = yak_config_file user
  config_str = new_config.to_yaml

  File.open(user_config_file, "w+"){|f| f.write config_str }
  $stderr << "Created Yak config file #{user_config_file}:\n"
  $stderr << "#{config_str}---\n\n"
end

.new_password(yak, value = nil) ⇒ Object

Assign a new master password to a yak instance. Prompts the user if no value is given.



224
225
226
227
228
# File 'lib/yak.rb', line 224

def self.new_password yak, value=nil
  yak.new_password value
  yak.write_data
  yak.start_session
end

.parse_args(argv) ⇒ Object

Parse ARGV data.



260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
# File 'lib/yak.rb', line 260

def self.parse_args argv
  options = {}

  opts = OptionParser.new do |opt|
    opt.program_name = File.basename $0
    opt.version = VERSION
    opt.release = nil

    opt.banner = <<-EOF
Yak is a simple app to store and retrieve passwords securely.
Retrieved passwords get copied to the clipboard by default.

Usage:
  #{opt.program_name} [options] [key] [password]

Examples:
  #{opt.program_name} -a gmail [password]
  #{opt.program_name} gmail
  #{opt.program_name} -r gmail
  #{opt.program_name} --list
  
Options:
    EOF

    opt.on('-a', '--add KEY',
           'Add a new password for a given key') do |key|
      options[:action] = :store
      options[:key]    = key
    end

    opt.on('-r', '--remove KEY',
           'Remove the password for a given key') do |key|
      options[:action] = :remove
      options[:key]    = key
    end

    opt.on('-l', '--list [REGEX]',
           'List keys to the stdout') do |key|
      options[:action] = :list
      options[:key]    = key
    end

    opt.on('-n', '--new-password',
           'Update the password used for encryption') do |value|
      options[:action] = :new_password
    end

    opt.on('-p', '--print KEY',
           'Print the password for the given key to stdout') do |key|
      options[:action] = :print_password
      options[:key]    = key
    end

    opt.on('--delete-data',
           'Delete the data file - lose all saved info') do
      options[:action] = :delete_data
    end
  end

  opts.parse! argv

  options[:action] ||= :retrieve
  options[:key]    ||= argv.shift
  options[:value]  ||= argv.shift

  raise OptionParser::InvalidOption if
    options[:action] == :retrieve && options[:key].nil?

  options

rescue OptionParser::InvalidOption
  $stderr << "\nError: Invalid option\n\n"
  $stderr << opts.to_s
  exit 1
end

Get a password value from a yak instance and output it to the stdout.



195
196
197
# File 'lib/yak.rb', line 195

def self.print_password yak, name
  $stdout << "#{yak.retrieve(name)}\n"
end

.prompt_data_loc(user, hl) ⇒ Object

Prompt the user for the location of the data file.



122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
# File 'lib/yak.rb', line 122

def self.prompt_data_loc user, hl
  data_file_opts = []

  usrhome = File.expand_path "~#{user}/"
  dropbox = File.expand_path "~#{user}/Dropbox"

  data_file_opts << dropbox if File.directory? dropbox
  data_file_opts << usrhome if File.directory? usrhome

  data_path = hl.choose do |menu|
    menu.prompt = "Where would you like your data file to live?"
    menu.choices(*data_file_opts)
    menu.choice "other" do
      hl.ask "Enter path:"
    end
  end

  File.join data_path, ".yakdata"
end

.remove(yak, name) ⇒ Object

Remove a key/value pair from a yak instance.



169
170
171
172
# File 'lib/yak.rb', line 169

def self.remove yak, name
  yak.remove name
  yak.write_data
end

.retrieve(yak, name) ⇒ Object

Get a password value from a yak instance and copy it to the clipboard.



187
188
189
# File 'lib/yak.rb', line 187

def self.retrieve yak, name
  send_to_clipboard yak.retrieve(name)
end

.run(argv = ARGV) ⇒ Object

Run Yak with argv:

Yak.run %w{key}
Yak.run %w{--add key}
...


46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
# File 'lib/yak.rb', line 46

def self.run argv=ARGV
  trap("INT") { exit 1 }

  user = `whoami`.chomp

  check_user_setup user

  config = DEFAULT_CONFIG.merge load_config(user)

  options = parse_args argv

  yak = new user, config

  yak.start_session
  yak.connect_data

  args = [options[:action], yak, options[:key], options[:value]].compact

  self.send(*args)

rescue CIPHER_ERROR => e
  yak.end_session

  $stderr << "Bad password.\n"
  exit 1
end

.send_to_clipboard(string) ⇒ Object

Send the passed string to the keyboard. Only supports darwin (pbcopy), linux (xclip) and cygwin (putclip).



235
236
237
238
239
240
241
242
243
244
245
246
# File 'lib/yak.rb', line 235

def self.send_to_clipboard string
  copy_cmd = case RUBY_PLATFORM
             when /darwin/ then "pbcopy"
             when /linux/  then "xclip -selection clipboard"
             when /cygwin/ then "putclip"
             else
               $stderr << "No clipboard cmd for platform #{RUBY_PLATFORM}\n"
               exit 1
             end

  Session::Bash.new.execute "echo -n \"#{string}\" | #{copy_cmd}"
end

.setup_bash_completionObject

Check and setup bash completion.



98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
# File 'lib/yak.rb', line 98

def self.setup_bash_completion
  completion_dir  = "/etc/bash_completion.d"

  completion_file = File.join File.dirname(__FILE__),
    "../script/yak_completion"
  completion_file = File.expand_path completion_file

  sudo    = "sudo" unless RUBY_PLATFORM =~ /cygwin/
  success =
    `#{sudo} cp #{completion_file} #{completion_dir}/. && echo 'true'`.chomp

  if success == "true"
    $stdout << "\nCopied yak_completion to #{completion_dir}\n\n"
  else
    $stderr << "\nError: Could not copy yak_completion #{completion_dir}\n"
    $stderr << "If you would like to use yak's bash completion, "
    $stderr << "make sure to source #{completion_file} in .bashrc\n\n"
  end
end

.store(yak, name, value = nil) ⇒ Object

Add a key/value pair to a yak instance.



178
179
180
181
# File 'lib/yak.rb', line 178

def self.store yak, name, value=nil
  yak.store name, value
  yak.write_data
end

.yak_config_file(user) ⇒ Object

Get a user’s yak config file. Typically ~user/.yakrc.



252
253
254
# File 'lib/yak.rb', line 252

def self.yak_config_file user
  File.expand_path "~#{user}/.yakrc"
end

Instance Method Details

#connect_dataObject

Loads and decrypts the data file into the @data attribute.



477
478
479
480
481
482
483
484
485
486
487
488
489
490
# File 'lib/yak.rb', line 477

def connect_data
  if data_file_exists?
    data = ""
    File.open(@data_file, "rb"){|f| data << f.read }

    @data = YAML.load decrypt(data)

    write_key_list if @use_completion

  else
    @data = {}
    write_data
  end
end

#data_file_exists?Boolean

Check if the data file exists.

Returns:

  • (Boolean)


426
427
428
# File 'lib/yak.rb', line 426

def data_file_exists?
  File.file? @data_file
end

#decrypt(string, password = nil) ⇒ Object

Decrypt a string with a given password.



521
522
523
# File 'lib/yak.rb', line 521

def decrypt string, password=nil
  get_cypher_out :decrypt, string, password
end

#delete_data_file!(confirm = false) ⇒ Object

Deletes the user’s data file forever!



434
435
436
437
# File 'lib/yak.rb', line 434

def delete_data_file! confirm=false
  confirmed = confirm ? @input.agree("Delete all passwords? (y/n)") : true
  FileUtils.rm_f(@data_file) if confirmed
end

#encrypt(string, password = nil) ⇒ Object

Encrypt a string with a given password.



529
530
531
# File 'lib/yak.rb', line 529

def encrypt string, password=nil
  get_cypher_out :encrypt, string, password
end

#end_sessionObject

Stop a session.



399
400
401
402
403
# File 'lib/yak.rb', line 399

def end_session
  return unless @session_pid
  Process.kill 9, @session_pid rescue false
  remove_session_files
end

#get_password(plain_pswd = nil) ⇒ Object

Get a password from either the password file or by prompting the user if a password file is unavailable. Returns a sha1 of the password passed as an arg.



453
454
455
456
457
458
459
460
461
# File 'lib/yak.rb', line 453

def get_password plain_pswd=nil
  password = File.read @password_file if File.file?(@password_file)

  plain_pswd ||= request_password "Master Password" if !password

  password ||= Digest::SHA1.hexdigest plain_pswd

  password
end

#has_session?Boolean

Check if a session is active.

Returns:

  • (Boolean)


418
419
420
# File 'lib/yak.rb', line 418

def has_session?
  Process.kill(0, @session_pid) && @session_pid rescue false
end

#new_password(password = nil) ⇒ Object

Prompt the user for a new password (replacing and old one). Prompts for password confirmation as well.



468
469
470
471
# File 'lib/yak.rb', line 468

def new_password password=nil
  password ||= request_new_password "Set New Master Password"
  @password  = Digest::SHA1.hexdigest password
end

#remove(name) ⇒ Object

Remove a key/value pair.



496
497
498
# File 'lib/yak.rb', line 496

def remove name
  @data.delete(name)
end

#remove_session_filesObject

Deletes files used during a session.



409
410
411
412
# File 'lib/yak.rb', line 409

def remove_session_files
  FileUtils.rm_f [@password_file, @pid_file]
  FileUtils.rm_f @key_list_file unless @use_completion == :always
end

#retrieve(name) ⇒ Object

Retrieve a value for a given key.



504
505
506
# File 'lib/yak.rb', line 504

def retrieve name
  @data[name]
end

#sha_passwordObject

Get the SHA-encrypted password used for encoding data.



443
444
445
# File 'lib/yak.rb', line 443

def sha_password
  @password ||= data_file_exists? ? get_password : new_password
end

#start_sessionObject

Start a new session during which Yak will remember the user’s password.



377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
# File 'lib/yak.rb', line 377

def start_session
  return unless @session_length

  pswd = sha_password # Do stdio before writing to file!

  end_session if has_session?

  @session_pid = fork do
    sleep @session_length
    remove_session_files
  end

  File.open(@password_file, "w+"){|f| f.write pswd }
  File.open(@pid_file,      "w+"){|f| f.write @session_pid }

  Process.detach @session_pid
end

#store(name, value = nil) ⇒ Object

Add a key/value pair. If no value is passed, will prompt the user for one.



512
513
514
515
# File 'lib/yak.rb', line 512

def store name, value=nil
  value ||= request_new_password "'#{name}' Password"
  @data[name] = value
end

#write_data(password = nil) ⇒ Object

Encrypt and write the Yak data back to the data file.



537
538
539
540
541
542
# File 'lib/yak.rb', line 537

def write_data password=nil
  data = encrypt @data.to_yaml, password
  File.open(@data_file, "w+"){|f| f.write data}

  write_key_list if @use_completion == :always
end

#write_key_listObject

Write the key list file. Used for bash completion.



548
549
550
# File 'lib/yak.rb', line 548

def write_key_list
  File.open(@key_list_file, "w+"){|f| f.write @data.keys.join(" ") }
end