├── .gitignore ├── README.asciidoc ├── TODO └── pws /.gitignore: -------------------------------------------------------------------------------- 1 | *~ 2 | -------------------------------------------------------------------------------- /README.asciidoc: -------------------------------------------------------------------------------- 1 | PWS(1) 2 | ====== 3 | :doctype: manpage 4 | 5 | NAME 6 | ---- 7 | pws - password store management 8 | 9 | SYNOPSIS 10 | -------- 11 | *pws* 'COMMAND' ['OPTIONS'] 12 | 13 | DESCRIPTION 14 | ----------- 15 | 16 | The pws tool allows you to store passwords (or anything else, really) in 17 | a set of encrypted files. Each file can be encrypted to a different set 18 | of users. pws helps you with the bookkeeping of which keys to encrypt 19 | each file to and provides a convinient wrapper to edit protected files. 20 | 21 | In the intended use the directory with the encrypted passwords would be 22 | under SCM control and shared with other people who need access. 23 | 24 | initialization 25 | -------------- 26 | 27 | First you need a file where your users and group are defined in. This 28 | file is named .users. Lines consist of assignments of the form 29 | = 30 | and 31 | @ = |@ [, |@ ...] 32 | 33 | Lines starting with a # are comments and thus get ignored. 34 | 35 | -------------------------------- 36 | % cat .users 37 | # This file needs to be gpg signed by a key whose fingerprint 38 | # is listed in ~/.pws-trusted-users 39 | 40 | formorer = 6E3966C1E1D15DB973D05B491E45F8CA9DE23B16 41 | weasel = 25FC1614B8F87B52FF2F99B962AF4031C82E0039 42 | @admins = formorer, weasel 43 | 44 | zobel = 6B1856428E41EC893D5DBDBB53B1AC6DB11B627B 45 | maxx = 30DC1D281D7932F55E673ABB28EEB35A3E8DCCC0 46 | @vienna = zobel, maxx 47 | 48 | @all = @admins, @vienna 49 | 50 | # gpg --clearsign .users && mv .users.asc .users 51 | -------------------------------- 52 | 53 | The .users file is designed to live in a SCM repository, such as git, 54 | alongside all the other encrypted files. In order to prevent 55 | unauthorized tampering with the .users file - for tricking somebody to 56 | re-encrypt data to the wrong key - the .users file needs to be 57 | PGP-clearsigned with a key from a whitelist. 58 | 59 | This whitelist lives in ~/.pws-trusted-users, and simply takes one 60 | key fingerprint per line: 61 | 62 | --------------------------------- 63 | % cat ~/.pws-trusted-users 64 | #formorer 65 | 6E3966C1E1D15DB973D05B491E45F8CA9DE23B16 66 | --------------------------------- 67 | 68 | Currently this whitelist is the same for any pws repositories a user 69 | might have. A patch to remove this limitation would be nice. 70 | 71 | listing files 72 | ----------------- 73 | This gives a listing of secure and other files. 74 | ----------------------------- 75 | % pws ls 76 | ----------------------------- 77 | 78 | adding a new file 79 | ----------------- 80 | 81 | ----------------------------- 82 | % pws ed -n file 83 | ----------------------------- 84 | 85 | editing files 86 | ------------- 87 | 88 | Every file needs a header like: 89 | 90 | ------------------------------ 91 | access: @admins, maxx 92 | ------------------------------ 93 | 94 | You can edit the encrypted file with the pws tool: +pws ed file+. 95 | 96 | reencrypting a file 97 | ------------------- 98 | If your .users has changed, and new users should get access to existing files, 99 | you can use the reencrypt command. 100 | 101 | ----------------------------- 102 | % pws rc file 103 | ----------------------------- 104 | 105 | showing keys from a file 106 | ------------------------ 107 | If you store YAML files in PWS, you can request a single key at a time: 108 | 109 | ------------------------------ 110 | % pws get users.yaml.asc /gannet/root/password 111 | PASSWORD-IN-YAML-KEY 112 | ------------------------------ 113 | 114 | 115 | updating the keyring 116 | -------------------- 117 | 118 | If available as .keyring pws instructs GnuPG to use this keyring in 119 | addition to the user's default keyrings. This allows sharing of the 120 | keyring in the repository. Use +pws update-keyring+ to 121 | update/initialize this keyring. 122 | 123 | 124 | AUTHOR 125 | ------ 126 | Peter Palfrader 127 | 128 | -------------------------------------------------------------------------------- /TODO: -------------------------------------------------------------------------------- 1 | - make an update command that re-encrypts all the files if group stuff has changed for them 2 | -------------------------------------------------------------------------------- /pws: -------------------------------------------------------------------------------- 1 | #!/usr/bin/ruby 2 | 3 | # password store management tool 4 | 5 | # Copyright (c) 2008, 2009 Peter Palfrader 6 | # 7 | # Permission is hereby granted, free of charge, to any person obtaining 8 | # a copy of this software and associated documentation files (the 9 | # "Software"), to deal in the Software without restriction, including 10 | # without limitation the rights to use, copy, modify, merge, publish, 11 | # distribute, sublicense, and/or sell copies of the Software, and to 12 | # permit persons to whom the Software is furnished to do so, subject to 13 | # the following conditions: 14 | # 15 | # The above copyright notice and this permission notice shall be 16 | # included in all copies or substantial portions of the Software. 17 | # 18 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 19 | # EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 20 | # MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 21 | # NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 22 | # LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 23 | # OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 24 | # WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 25 | 26 | require 'optparse' 27 | require 'thread' 28 | require 'tempfile' 29 | 30 | require 'yaml' 31 | Thread.abort_on_exception = true 32 | 33 | GNUPG = "/usr/bin/gpg" 34 | GROUP_PATTERN = "@[a-zA-Z0-9@.\-]+" 35 | USER_PATTERN = "[a-zA-Z0-9:@.\-]+" 36 | $program_name = File.basename($0, '.*') 37 | $DEBUG = false 38 | 39 | $editor = ENV['EDITOR'] 40 | if $editor == nil 41 | %w{/usr/bin/sensible-editor /usr/bin/editor /usr/bin/vi}.each do |editor| 42 | if FileTest.executable?(editor) 43 | $editor = editor 44 | break 45 | end 46 | end 47 | end 48 | 49 | class GnuPG 50 | @@my_keys = nil 51 | @@my_fprs = nil 52 | @@keyid_fpr_mapping = {} 53 | 54 | def GnuPG.readwrite3(intxt, infd, stdoutfd, stderrfd, statusfd=nil) 55 | outtxt, stderrtxt, statustxt = '' 56 | thread_in = Thread.new { 57 | infd.print intxt 58 | infd.close 59 | } 60 | thread_out = Thread.new { 61 | outtxt = stdoutfd.read 62 | stdoutfd.close 63 | } 64 | thread_err = Thread.new { 65 | errtxt = stderrfd.read 66 | stderrfd.close 67 | } 68 | thread_status = Thread.new { 69 | statustxt = statusfd.read 70 | statusfd.close 71 | } if (statusfd) 72 | 73 | thread_in.join 74 | thread_out.join 75 | thread_err.join 76 | thread_status.join if thread_status 77 | 78 | return outtxt, stderrtxt, statustxt 79 | end 80 | 81 | def GnuPG.open3call(cmd, intxt, args, require_success = false, do_status=true) 82 | inR, inW = IO.pipe 83 | outR, outW = IO.pipe 84 | errR, errW = IO.pipe 85 | statR, statW = IO.pipe if do_status 86 | opt_statusfd, opt_output, arg_input = nil, nil, nil 87 | inO = $stdin 88 | outO = $stdout 89 | errO = $stderr 90 | 91 | pid = Kernel.fork do 92 | opt_output = "/dev/fd/#{outW.fileno}" 93 | arg_input = "/dev/fd/#{inR.fileno}" unless intxt.nil? or intxt.empty? 94 | File.unlink(*([arg_input,opt_output].compact.select{ |f| File.file?(f) })) 95 | opt_statusfd = "--status-fd=#{statW.fileno}" if do_status 96 | opt_output = "--output=#{opt_output}" 97 | fullcmd = ([cmd, opt_statusfd, opt_output]+args+[arg_input]).compact 98 | errO.puts "DebugCommand: #{fullcmd.map{|s| "'"+s+"'" }.join(" ")}" if $DEBUG 99 | inW.close 100 | outR.close 101 | errR.close 102 | statR.close if do_status 103 | #STDIN.reopen(inR) 104 | STDOUT.reopen(outW) 105 | STDERR.reopen(errW) 106 | begin 107 | exec(*fullcmd) 108 | rescue Exception => e 109 | outW.puts("[PWSEXECERROR]: #{e}") 110 | exit(1) 111 | end 112 | raise ("Calling gnupg failed") 113 | end 114 | inR.close 115 | outW.close 116 | errW.close 117 | if do_status 118 | statW.close 119 | (outtxt, stderrtxt, statustxt) = readwrite3(intxt, inW, outR, errR, statR); 120 | else 121 | (outtxt, stderrtxt) = readwrite3(intxt, inW, outR, errR); 122 | end 123 | wpid, status = Process.waitpid2 pid 124 | throw "Unexpected pid: #{pid} vs #{wpid}" unless pid == wpid 125 | throw "Process has not exited!?" unless status.exited? 126 | throw "#{cmd} call did not exit sucessfully" if (require_success and status.exitstatus != 0) 127 | if m=/^\[PWSEXECERROR\]: (.*)/.match(outtxt) then 128 | STDERR.puts "Could not run GnuPG: #{m[1]}" 129 | exit(1) 130 | end 131 | if do_status 132 | return outtxt, stderrtxt, statustxt, status.exitstatus 133 | else 134 | return outtxt, stderrtxt, status.exitstatus 135 | end 136 | ensure 137 | $stdin = inO 138 | $stdout = outO 139 | $stderr = errO 140 | end 141 | 142 | def GnuPG.gpgcall(intxt, args, require_success = false) 143 | return open3call(GNUPG, intxt, args, require_success) 144 | end 145 | 146 | def GnuPG.init_keys() 147 | return if @@my_keys 148 | (outtxt, stderrtxt, statustxt) = GnuPG.gpgcall('', %w{--fast-list-mode --with-colons --with-fingerprint --list-secret-keys}, true) 149 | @@my_keys = [] 150 | @@my_fprs = [] 151 | outtxt.split("\n").each do |line| 152 | parts = line.split(':') 153 | if (parts[0] == "ssb" or parts[0] == "sec") 154 | @@my_keys.push parts[4] 155 | elsif (parts[0] == "fpr") 156 | @@my_fprs.push parts[9] 157 | end 158 | end 159 | end 160 | # This is for my private keys, so we can tell if a file is encrypted to us 161 | def GnuPG.get_my_keys() 162 | init_keys 163 | @@my_keys 164 | end 165 | # And this is for my private keys also, so we can tell if we are encrypting to ourselves 166 | def GnuPG.get_my_fprs() 167 | init_keys 168 | @@my_fprs 169 | end 170 | 171 | # This maps public keyids to fingerprints, so we can figure 172 | # out if a file that is encrypted to a bunch of keys is 173 | # encrypted to the fingerprints it should be encrypted to 174 | def GnuPG.get_fpr_from_keyid(keyid) 175 | fpr = @@keyid_fpr_mapping[keyid] 176 | # this can be null, if we tried to find the fpr but failed to find the key in our keyring 177 | unless fpr 178 | STDERR.puts "Warning: No key found for keyid #{keyid}" 179 | end 180 | return fpr 181 | end 182 | def GnuPG.get_fprs_from_keyids(keyids) 183 | learn_fingerprints_from_keyids(keyids) 184 | return keyids.collect{ |k| get_fpr_from_keyid(k) or "unknown" } 185 | end 186 | 187 | # this is to load the keys we will soon be asking about into 188 | # our keyid-fpr-mapping hash 189 | def GnuPG.learn_fingerprints_from_keyids(keyids) 190 | need_to_learn = [] 191 | need_to_learn = keyids.reject{ |k| @@keyid_fpr_mapping.has_key?(k) unless k.nil? } unless keyids.nil? 192 | if need_to_learn.size > 0 193 | # we can't use --fast-list-mode here because GnuPG is broken 194 | # and does not show elmo's fingerprint in a call like 195 | # gpg --with-colons --fast-list-mode --with-fingerprint --list-key D7C3F131AB2A91F5 196 | args = %w{--with-colons --with-fingerprint --list-keys} 197 | args.push "--keyring=./.keyring" if FileTest.exists?(".keyring") 198 | args.concat need_to_learn 199 | (outtxt, stderrtxt, statustxt) = GnuPG.gpgcall('', args, true) 200 | 201 | pub = nil 202 | fpr = nil 203 | outtxt.split("\n").each do |line| 204 | parts = line.split(':') 205 | if (parts[0] == "pub") 206 | pub = parts[4] 207 | elsif (parts[0] == "fpr") 208 | fpr = parts[9] 209 | @@keyid_fpr_mapping[pub] = fpr 210 | elsif (parts[0] == "sub") 211 | @@keyid_fpr_mapping[parts[4]] = fpr 212 | end 213 | end 214 | end 215 | need_to_learn.reject{ |k| @@keyid_fpr_mapping.has_key?(k) }.each { |k| @@keyid_fpr_mapping[k] = nil } 216 | end 217 | end 218 | 219 | def read_input(query, default_yes=true) 220 | if default_yes 221 | append = '[Y/n]' 222 | else 223 | append = '[y/N]' 224 | end 225 | 226 | while true 227 | print "#{query} #{append} " 228 | begin 229 | i = STDIN.readline.chomp.downcase 230 | rescue EOFError 231 | return default_yes 232 | end 233 | if i=="" 234 | return default_yes 235 | elsif i=="y" 236 | return true 237 | elsif i=="n" 238 | return false 239 | end 240 | end 241 | end 242 | 243 | class GroupConfig 244 | def initialize 245 | parse_file 246 | expand_groups 247 | end 248 | 249 | def verify(content) 250 | begin 251 | f = File.open(ENV['HOME']+'/.pws-trusted-users') 252 | rescue Exception => e 253 | STDERR.puts e 254 | exit(1) 255 | end 256 | 257 | trusted = [] 258 | f.readlines.each do |line| 259 | line.chomp! 260 | next if line =~ /^$/ 261 | next if line =~ /^#/ 262 | 263 | trusted.push line 264 | end 265 | 266 | args = [] 267 | args.push "--keyring=./.keyring" if FileTest.exists?(".keyring") 268 | args.push '--decrypt' 269 | args.push '--yes' 270 | (outtxt, stderrtxt, statustxt, exitstatus) = GnuPG.gpgcall(content, args) 271 | goodsig = false 272 | validsig = nil 273 | statustxt.split("\n").each do |line| 274 | if m = /^\[GNUPG:\] GOODSIG/.match(line) 275 | goodsig = true 276 | elsif m = /^\[GNUPG:\] VALIDSIG \S+ \S+ \S+ \S+ \S+ \S+ \S+ \S+ \S+ ([0-9A-F]+)/.match(line) 277 | validsig = m[1] 278 | end 279 | end 280 | 281 | if not goodsig 282 | STDERR.puts ".users file is not signed properly. GnuPG said on stdout:" 283 | STDERR.puts outtxt 284 | STDERR.puts "and on stderr:" 285 | STDERR.puts stderrtxt 286 | STDERR.puts "and via statusfd:" 287 | STDERR.puts statustxt 288 | exit(1) 289 | end 290 | 291 | if not trusted.include?(validsig) 292 | STDERR.puts ".users file is signed by #{validsig} which is not in ~/.pws-trusted-users" 293 | exit(1) 294 | end 295 | 296 | if not exitstatus==0 297 | STDERR.puts "gpg verify failed for .users file" 298 | exit(1) 299 | end 300 | 301 | return outtxt 302 | end 303 | 304 | def parse_file 305 | begin 306 | f = File.open('.users') 307 | rescue Exception => e 308 | STDERR.puts e 309 | exit(1) 310 | end 311 | 312 | users = f.read 313 | f.close 314 | 315 | users = verify(users) 316 | 317 | @users = {} 318 | @groups = {} 319 | 320 | lno = 0 321 | users.split("\n").each do |line| 322 | lno = lno+1 323 | next if line =~ /^$/ 324 | next if line =~ /^#/ 325 | if false 326 | # nothing 327 | elsif (m = /^(#{GROUP_PATTERN})\s*=\s*(.*)$/.match line) 328 | group = m[1] 329 | members = m[2].strip 330 | if @groups.has_key?(group) 331 | STDERR.puts "Group #{group} redefined at line #{lno}!" 332 | exit(1) 333 | end 334 | members = members.split(/[\t ,]+/) 335 | @groups[group] = { "members" => members } 336 | elsif (m = /^(#{USER_PATTERN})\s*=\s*([0-9A-Fa-f]{40})\s*$/.match line) 337 | user = m[1] 338 | fpr = m[2] 339 | if @users.has_key?(user) 340 | STDERR.puts "User #{user} redefined at line #{lno}!" 341 | exit(1) 342 | end 343 | @users[user] = fpr 344 | end 345 | end 346 | end 347 | 348 | def is_group(name) 349 | return (name =~ /^@/) 350 | end 351 | def check_exists(x, whence, fatal=true) 352 | ok=true 353 | if is_group(x) 354 | ok=false unless (@groups.has_key?(x)) 355 | else 356 | ok=false unless @users.has_key?(x) 357 | end 358 | unless ok 359 | STDERR.puts( (fatal ? "Error: " : "Warning: ") + "#{whence} contains unknown member #{x}") 360 | exit(1) if fatal 361 | end 362 | return ok 363 | end 364 | def expand_groups 365 | @groups.each_pair do |groupname, group| 366 | group['members'].each do |member| 367 | check_exists(member, "Group #{groupname}") 368 | end 369 | group['members_to_do'] = group['members'].clone 370 | end 371 | 372 | while true 373 | had_progress = false 374 | all_expanded = true 375 | @groups.each_pair do |groupname, group| 376 | group['keys'] = [] unless group['keys'] 377 | 378 | still_contains_groups = false 379 | group['members_to_do'].clone.each do |member| 380 | if is_group(member) 381 | if @groups[member]['members_to_do'].size == 0 382 | group['keys'].concat @groups[member]['keys'] 383 | group['members_to_do'].delete(member) 384 | had_progress = true 385 | else 386 | still_contains_groups = true 387 | end 388 | else 389 | group['keys'].push @users[member] 390 | group['members_to_do'].delete(member) 391 | had_progress = true 392 | end 393 | end 394 | all_expanded = false if still_contains_groups 395 | end 396 | break if all_expanded 397 | unless had_progress 398 | cyclic_groups = @groups.keys.reject{|name| @groups[name]['members_to_do'].size == 0}.join(", ") 399 | STDERR.puts "Cyclic group memberships in #{cyclic_groups}?" 400 | exit(1) 401 | end 402 | end 403 | end 404 | 405 | def expand_targets(targets) 406 | fprs = [] 407 | ok = true 408 | targets.each do |t| 409 | unless check_exists(t, "access line", false) 410 | ok = false 411 | next 412 | end 413 | if is_group(t) 414 | fprs.concat @groups[t]['keys'] 415 | else 416 | fprs.push @users[t] 417 | end 418 | end 419 | return ok, fprs.uniq 420 | end 421 | 422 | def get_users() 423 | return @users 424 | end 425 | end 426 | 427 | class EncryptedData 428 | attr_reader :accessible, :encrypted, :readable, :readers 429 | 430 | def EncryptedData.determine_readable(readers) 431 | GnuPG.get_my_keys.each do |keyid| 432 | return true if readers.include?(keyid) 433 | end 434 | return false 435 | end 436 | 437 | def EncryptedData.list_readers(statustxt) 438 | readers = [] 439 | statustxt.split("\n").each do |line| 440 | m = /^\[GNUPG:\] ENC_TO ([0-9A-F]+)/.match line 441 | next unless m 442 | readers.push m[1] 443 | end 444 | return readers 445 | end 446 | 447 | def EncryptedData.targets(text) 448 | text.split("\n").each do |line| 449 | if /^(#|---)/.match line 450 | next 451 | end 452 | m = /^access: "?((?:(?:#{GROUP_PATTERN}|#{USER_PATTERN}),?\s*)+)"?/.match line 453 | return [] unless m 454 | return m[1].strip.split(/[\t ,]+/) 455 | end 456 | end 457 | 458 | 459 | def initialize(encrypted_content, label) 460 | @ignore_decrypt_errors = false 461 | @label = label 462 | @readers = [] 463 | 464 | @encrypted_content = encrypted_content 465 | (outtxt, stderrtxt, statustxt) = GnuPG.gpgcall(@encrypted_content, %w{--with-colons --no-options --no-default-keyring --secret-keyring=/dev/null --keyring=/dev/null}) 466 | @encrypted = !(statustxt =~ /\[GNUPG:\] NODATA/) 467 | if @encrypted 468 | @readers = EncryptedData.list_readers(statustxt) 469 | @readable = EncryptedData.determine_readable(@readers) 470 | end 471 | end 472 | 473 | def decrypt 474 | (outtxt, stderrtxt, statustxt, exitstatus) = GnuPG.gpgcall(@encrypted_content, %w{--decrypt --yes --no-batch}) 475 | if !@ignore_decrypt_errors and exitstatus != 0 476 | proceed = read_input("Warning: gpg returned non-zero exit status #{exitstatus} when decrypting #{@label}. Proceed?", false) 477 | exit(0) unless proceed 478 | elsif !@ignore_decrypt_errors and outtxt.length == 0 479 | proceed = read_input("Warning: #{@label} decrypted to an empty file. Proceed?") 480 | exit(0) unless proceed 481 | end 482 | 483 | return outtxt 484 | end 485 | 486 | def encrypt(content, recipients) 487 | args = recipients.collect{ |r| "--recipient=#{r}"} 488 | args.push "--trust-model=always" 489 | args.push "--keyring=./.keyring" if FileTest.exists?(".keyring") 490 | args.push "--armor" 491 | args.push "--encrypt" 492 | args.push "--yes" 493 | (outtxt, stderrtxt, statustxt, exitstatus) = GnuPG.gpgcall(content, args) 494 | 495 | invalid = [] 496 | statustxt.split("\n").each do |line| 497 | m = /^\[GNUPG:\] INV_RECP \S+ ([0-9A-F]+)/.match line 498 | next unless m 499 | invalid.push m[1] 500 | end 501 | if invalid.size > 0 502 | again = read_input("Warning: the following recipients are invalid: #{invalid.join(", ")}. Try again (or proceed)?") 503 | return false if again 504 | end 505 | if outtxt.length == 0 506 | tryagain = read_input("Error: #{@label} encrypted to an empty file. Edit again (or exit)?") 507 | return false if tryagain 508 | exit(0) 509 | end 510 | if exitstatus != 0 511 | proceed = read_input("Warning: gpg returned non-zero exit status #{exitstatus} when encrypting #{@label}. Said:\n#{stderrtxt}\n#{statustxt}\n\nProceed (or try again)?") 512 | return false unless proceed 513 | end 514 | 515 | return true, outtxt 516 | end 517 | 518 | 519 | def determine_encryption_targets(content) 520 | targets = EncryptedData.targets(content) 521 | if targets.size == 0 522 | tryagain = read_input("Warning: Did not find targets to encrypt to in header. Try again (or exit)?", true) 523 | return false if tryagain 524 | exit(0) 525 | end 526 | 527 | ok, expanded = @groupconfig.expand_targets(targets) 528 | if (expanded.size == 0) 529 | tryagain = read_input("Errors in access header. Edit again (or exit)?", true) 530 | return false if tryagain 531 | exit(0) 532 | elsif (not ok) 533 | tryagain = read_input("Warnings in access header. Edit again (or continue)?", true) 534 | return false if tryagain 535 | end 536 | 537 | to_me = false 538 | GnuPG.get_my_fprs.each do |fpr| 539 | if expanded.include?(fpr) 540 | to_me = true 541 | break 542 | end 543 | end 544 | unless to_me 545 | tryagain = read_input("File is not being encrypted to you. Edit again (or continue)?", true) 546 | return false if tryagain 547 | end 548 | 549 | return true, expanded 550 | end 551 | 552 | end 553 | 554 | class EncryptedFile < EncryptedData 555 | def initialize(filename, new=false) 556 | @groupconfig = GroupConfig.new 557 | @new = new 558 | if @new 559 | @readers = [] 560 | end 561 | 562 | @filename = filename 563 | unless FileTest.readable?(filename) 564 | @accessible = false 565 | return 566 | end 567 | @accessible = true 568 | 569 | @filename = filename 570 | 571 | encrypted_content = File.read(filename) 572 | super(encrypted_content, filename) 573 | end 574 | 575 | def write_back(content, targets) 576 | ok, encrypted = encrypt(content, targets) 577 | return false unless ok 578 | 579 | File.open(@filename,"w").write(encrypted) 580 | return true 581 | end 582 | end 583 | 584 | class Ls 585 | def help(parser, code=0, io=STDOUT) 586 | io.puts "Usage: #{$program_name} ls [ ...]" 587 | io.puts parser.summarize 588 | io.puts "Lists the contents of the given directory/directories, or the current" 589 | io.puts "directory if none is given. For each file show whether it is PGP-encrypted" 590 | io.puts "file, and if yes whether we can read it." 591 | exit(code) 592 | end 593 | 594 | def ls_dir(dirname) 595 | begin 596 | dir = Dir.open(dirname) 597 | rescue Exception => e 598 | STDERR.puts e 599 | return 600 | end 601 | puts "#{dirname}:" 602 | Dir.chdir(dirname) do 603 | unless FileTest.exists?(".users") 604 | STDERR.puts "The .users file does not exists here. This is not a password store, is it?" 605 | exit(1) 606 | end 607 | dir.sort.each do |filename| 608 | next if (filename =~ /^\./) and not (@all >= 3) 609 | stat = File::Stat.new(filename) 610 | if stat.symlink? 611 | puts "(sym) #{filename}" if (@all >= 2) 612 | elsif stat.directory? 613 | puts "(dir) #{filename}" if (@all >= 2) 614 | elsif !stat.file? 615 | puts "(other) #{filename}" if (@all >= 2) 616 | elsif stat.zero? 617 | puts "(empty) #{filename}" if (@all >= 2) 618 | else 619 | f = EncryptedFile.new(filename) 620 | if !f.accessible 621 | puts "(!perm) #{filename}" 622 | elsif !f.encrypted 623 | puts "(file) #{filename}" if (@all >= 2) 624 | elsif f.readable 625 | puts "(ok) #{filename}" 626 | else 627 | puts "(locked) #{filename}" if (@all >= 1) 628 | end 629 | end 630 | end 631 | end 632 | end 633 | 634 | def initialize() 635 | @all = 0 636 | ARGV.options do |opts| 637 | opts.on_tail("-h", "--help" , "Display this help screen") { help(opts) } 638 | opts.on_tail("-a", "--all" , "Show all files (use up to 3 times to show even more than all)") { @all = @all+1 } 639 | opts.on_tail("-d", "--debug" , "Enable debug output") { |debug| $DEBUG=debug } 640 | opts.parse! 641 | end 642 | 643 | dirs = ARGV 644 | dirs.push('.') unless dirs.size > 0 645 | dirs.each { |dir| ls_dir(dir) } 646 | end 647 | end 648 | 649 | class Ed 650 | def help(parser, code=0, io=STDOUT) 651 | io.puts "Usage: #{$program_name} ed " 652 | io.puts parser.summarize 653 | io.puts "Decrypts the file, spawns an editor, and encrypts it again" 654 | exit(code) 655 | end 656 | 657 | def edit(filename) 658 | encrypted_file = EncryptedFile.new(filename, @new) 659 | if !@new and !encrypted_file.readable && !@force 660 | STDERR.puts "#{filename} is probably not readable" 661 | exit(1) 662 | end 663 | 664 | encrypted_to = GnuPG.get_fprs_from_keyids(encrypted_file.readers).sort 665 | 666 | content = encrypted_file.decrypt 667 | original_content = content 668 | while true 669 | oldsize = content.length 670 | tempfile = Tempfile.open('pws') 671 | tempfile.puts content 672 | tempfile.flush 673 | system($editor, tempfile.path) 674 | status = $? 675 | throw "Process has not exited!?" unless status.exited? 676 | unless status.exitstatus == 0 677 | proceed = read_input("Warning: Editor did not exit successfully (exit code #{status.exitstatus}. Proceed?") 678 | exit(0) unless proceed 679 | end 680 | 681 | # some editors do not write new content in place, but instead 682 | # make a new file and more it in the old file's place. 683 | begin 684 | reopened = File.open(tempfile.path, "r+") 685 | rescue Exception => e 686 | STDERR.puts e 687 | exit(1) 688 | end 689 | content = reopened.read 690 | 691 | # zero the file, well, both of them. 692 | newsize = content.length 693 | clearsize = (newsize > oldsize) ? newsize : oldsize 694 | 695 | [tempfile, reopened].each do |f| 696 | f.seek(0, IO::SEEK_SET) 697 | f.print "\0"*clearsize 698 | f.fsync 699 | end 700 | reopened.close 701 | tempfile.close(true) 702 | 703 | if content.length == 0 704 | proceed = read_input("Warning: Content is now empty. Proceed?") 705 | exit(0) unless proceed 706 | end 707 | 708 | ok, targets = encrypted_file.determine_encryption_targets(content) 709 | next unless ok 710 | 711 | if (original_content == content) 712 | if (targets.sort == encrypted_to) 713 | proceed = read_input("Nothing changed. Re-encrypt anyway?", false) 714 | exit(0) unless proceed 715 | else 716 | STDERR.puts("Info: Content not changed but re-encrypting anyway because the list of keys changed") 717 | end 718 | end 719 | 720 | success = encrypted_file.write_back(content, targets) 721 | break if success 722 | end 723 | end 724 | 725 | def initialize() 726 | ARGV.options do |opts| 727 | opts.on_tail("-h", "--help" , "Display this help screen") { help(opts) } 728 | opts.on_tail("-n", "--new" , "Edit new file") { |new| @new=new } 729 | opts.on_tail("-f", "--force" , "Spawn an editor even if the file is probably not readable") { |force| @force=force } 730 | opts.on_tail("-d", "--debug" , "Enable debug output") { |debug| $DEBUG=debug } 731 | opts.parse! 732 | end 733 | help(ARGV.options, 1, STDERR) if ARGV.length != 1 734 | filename = ARGV.shift 735 | 736 | if @new 737 | if FileTest.exists?(filename) 738 | STDERR.puts "#{filename} does exist" 739 | exit(1) 740 | end 741 | else 742 | if !FileTest.exists?(filename) 743 | STDERR.puts "#{filename} does not exist" 744 | exit(1) 745 | elsif !FileTest.file?(filename) 746 | STDERR.puts "#{filename} is not a regular file" 747 | exit(1) 748 | elsif !FileTest.readable?(filename) 749 | STDERR.puts "#{filename} is not accessible (unix perms)" 750 | exit(1) 751 | end 752 | end 753 | 754 | dirname = File.dirname(filename) 755 | basename = File.basename(filename) 756 | Dir.chdir(dirname) { 757 | edit(basename) 758 | } 759 | end 760 | end 761 | 762 | class Reencrypt < Ed 763 | def help(parser, code=0, io=STDOUT) 764 | io.puts "Usage: #{$program_name} rc " 765 | io.puts parser.summarize 766 | io.puts "Reencrypts the file (useful for changed user lists or keys)" 767 | exit(code) 768 | end 769 | def initialize() 770 | $editor = '/bin/true' 771 | super 772 | end 773 | end 774 | 775 | class Get 776 | def help(parser, code=0, io=STDOUT) 777 | io.puts "Usage: #{$program_name} get " 778 | io.puts parser.summarize 779 | io.puts "Decrypts the file, fetches a key and outputs it to stdout." 780 | io.puts "The file must be in YAML format." 781 | io.puts "query is a query, formatted like /host/users/root" 782 | exit(code) 783 | end 784 | 785 | def get(filename, what) 786 | encrypted_file = EncryptedFile.new(filename, @new) 787 | if !encrypted_file.readable 788 | STDERR.puts "#{filename} is probably not readable" 789 | exit(1) 790 | end 791 | 792 | begin 793 | yaml = YAML::load(encrypted_file.decrypt) 794 | rescue Psych::SyntaxError, ArgumentError => e 795 | STDERR.puts "Could not parse YAML: #{e.message}" 796 | exit(1) 797 | end 798 | 799 | require 'pp' 800 | 801 | a = what.split("/")[1..-1] 802 | hit = yaml 803 | if a.nil? 804 | # q = /, so print top level keys 805 | puts "Keys:" 806 | hit.keys.each do |k| 807 | puts "- #{k}" 808 | end 809 | return 810 | end 811 | a.each do |k| 812 | hit = hit[k] 813 | end 814 | if hit.nil? 815 | STDERR.puts("No such key or invalid lookup expression") 816 | elsif hit.respond_to?(:keys) 817 | puts "Keys:" 818 | hit.keys.each do |k| 819 | puts "- #{k}" 820 | end 821 | else 822 | puts hit 823 | end 824 | end 825 | 826 | def initialize() 827 | ARGV.options do |opts| 828 | opts.on_tail("-h", "--help" , "Display this help screen") { help(opts) } 829 | opts.on_tail("-d", "--debug" , "Enable debug output") { |debug| $DEBUG=debug } 830 | opts.parse! 831 | end 832 | help(ARGV.options, 1, STDERR) if ARGV.length != 2 833 | filename = ARGV.shift 834 | what = ARGV.shift 835 | 836 | if !FileTest.exists?(filename) 837 | STDERR.puts "#{filename} does not exist" 838 | exit(1) 839 | elsif !FileTest.file?(filename) 840 | STDERR.puts "#{filename} is not a regular file" 841 | exit(1) 842 | elsif !FileTest.readable?(filename) 843 | STDERR.puts "#{filename} is not accessible (unix perms)" 844 | exit(1) 845 | end 846 | 847 | dirname = File.dirname(filename) 848 | basename = File.basename(filename) 849 | Dir.chdir(dirname) { 850 | get(basename, what) 851 | } 852 | end 853 | end 854 | 855 | class KeyringUpdater 856 | def help(parser, code=0, io=STDOUT) 857 | io.puts "Usage: #{$program_name} update-keyring []" 858 | io.puts parser.summarize 859 | io.puts "Updates the local .keyring file" 860 | exit(code) 861 | end 862 | 863 | def initialize() 864 | ARGV.options do |opts| 865 | opts.on_tail("-h", "--help" , "Display this help screen") { help(opts) } 866 | opts.on_tail("-d", "--debug" , "Enable debug output") { |debug| $DEBUG=debug } 867 | opts.parse! 868 | end 869 | help(ARGV.options, 1, STDERR) if ARGV.length > 1 870 | keyserver = ARGV.shift 871 | keyserver = 'keys.gnupg.net' unless keyserver 872 | 873 | groupconfig = GroupConfig.new 874 | users = groupconfig.get_users() 875 | args = %w{--with-colons --no-options --no-default-keyring --keyring=./.keyring} 876 | 877 | system('touch', '.keyring') 878 | users.each_pair() do |uid, keyid| 879 | cmd = args.clone() 880 | cmd << "--keyserver=#{keyserver}" 881 | cmd << "--recv-keys" 882 | cmd << keyid 883 | puts "Fetching key for #{uid}" 884 | (outtxt, stderrtxt, statustxt) = GnuPG.gpgcall('', cmd) 885 | unless (statustxt =~ /^\[GNUPG:\] IMPORT_OK /) 886 | STDERR.puts "Warning: did not find IMPORT_OK token in status output" 887 | STDERR.puts "gpg exited with exit code #{ecode})" 888 | STDERR.puts "Command was gpg #{cmd.join(' ')}" 889 | STDERR.puts "stdout was #{outtxt}" 890 | STDERR.puts "stderr was #{stderrtxt}" 891 | STDERR.puts "statustxt was #{statustxt}" 892 | end 893 | 894 | cmd = args.clone() 895 | cmd << '--batch' << '--edit' << keyid << 'minimize' << 'save' 896 | (outtxt, stderrtxt, statustxt, ecode) = GnuPG.gpgcall('', cmd) 897 | end 898 | 899 | 900 | end 901 | end 902 | 903 | class GitDiff 904 | def help(parser, code=0, io=STDOUT) 905 | io.puts "Usage: #{$program_name} gitdiff " 906 | io.puts parser.summarize 907 | io.puts "Shows a diff between the version of in your directory and the" 908 | io.puts "version in git at (or HEAD). Requires that your tree be git" 909 | io.puts "managed, obviously." 910 | exit(code) 911 | end 912 | 913 | def check_readable(e, label) 914 | if !e.readable && !@force 915 | STDERR.puts "#{label} is probably not readable." 916 | exit(1) 917 | end 918 | end 919 | 920 | def get_file_at_commit() 921 | label = @commit+':'+@filename 922 | (encrypted_content, stderrtxt, exitcode) = GnuPG.open3call('git', '', ['show', label], require_success=true, do_status=false) 923 | data = EncryptedData.new(encrypted_content, label) 924 | check_readable(data, label) 925 | return data.decrypt 926 | end 927 | 928 | def get_file_current() 929 | data = EncryptedFile.new(@filename) 930 | check_readable(data, @filename) 931 | return data.decrypt 932 | end 933 | 934 | def diff() 935 | old = get_file_at_commit() 936 | cur = get_file_current() 937 | 938 | t1 = Tempfile.open('pws') 939 | t1.puts old 940 | t1.flush 941 | 942 | t2 = Tempfile.open('pws') 943 | t2.puts cur 944 | t2.flush 945 | 946 | system("diff", "-u", t1.path, t2.path) 947 | 948 | t1.seek(0, IO::SEEK_SET) 949 | t1.print "\0"*old.length 950 | t1.fsync 951 | t1.close(true) 952 | 953 | t2.seek(0, IO::SEEK_SET) 954 | t2.print "\0"*cur.length 955 | t2.fsync 956 | t2.close(true) 957 | end 958 | 959 | def initialize() 960 | ARGV.options do |opts| 961 | opts.on_tail("-h", "--help" , "Display this help screen") { help(opts) } 962 | opts.on_tail("-f", "--force" , "Do it even if the file is probably not readable") { |force| @force=force } 963 | opts.on_tail("-d", "--debug" , "Enable debug output") { |debug| $DEBUG=debug } 964 | opts.parse! 965 | end 966 | 967 | if ARGV.length == 1 968 | @commit = 'HEAD' 969 | @filename = ARGV.shift 970 | elsif ARGV.length == 2 971 | @commit = ARGV.shift 972 | @filename = ARGV.shift 973 | else 974 | help(ARGV.options, 1, STDERR) 975 | end 976 | 977 | diff() 978 | end 979 | end 980 | 981 | 982 | def help(code=0, io=STDOUT) 983 | io.puts "Usage: #{$program_name} ed" 984 | io.puts "Usage: #{$program_name} rc" 985 | io.puts " #{$program_name} ls" 986 | io.puts " #{$program_name} get" 987 | io.puts " #{$program_name} gitdiff" 988 | io.puts " #{$program_name} update-keyring" 989 | io.puts " #{$program_name} help" 990 | io.puts "Call #{$program_name} --help for additional options/parameters" 991 | exit(code) 992 | end 993 | 994 | 995 | def parse_command 996 | case ARGV.shift 997 | when 'ls' then Ls.new 998 | when 'ed' then Ed.new 999 | when 'rc' then Reencrypt.new 1000 | when 'gitdiff' then GitDiff.new 1001 | when 'get' then Get.new 1002 | when 'update-keyring' then KeyringUpdater.new 1003 | when 'help' then 1004 | case ARGV.length 1005 | when 0 then help 1006 | when 1 then 1007 | ARGV.push "--help" 1008 | parse_command 1009 | else help(1, STDERR) 1010 | end 1011 | else 1012 | help(1, STDERR) 1013 | end 1014 | end 1015 | 1016 | parse_command 1017 | 1018 | # vim:set shiftwidth=2: 1019 | # vim:set et: 1020 | # vim:set ts=2: 1021 | --------------------------------------------------------------------------------