3 # Copyright (c) 2022 Omar Polo <op@omarpolo.com>
5 # Permission to use, copy, modify, and distribute this software for any
6 # purpose with or without fee is hereby granted, provided that the above
7 # copyright notice and this permission notice appear in all copies.
9 # THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
10 # WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
11 # MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
12 # ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
13 # WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
14 # ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
15 # OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
21 use open ":std", ":encoding(UTF-8)";
23 use Getopt::Long qw(:config bundling require_order);
28 my $store = $ENV{'PLASS_STORE'} || $ENV{'HOME'}.'/.password-store';
30 my $got = $ENV{'PLASS_GOT'} || 'got';
31 my $tog = $ENV{'PLASS_TOG'} || 'tog';
33 my $gpg = $ENV{'PLASS_GPG'} || 'gpg2';
34 my @gpg_flags = qw(--quiet --compress-algo=none --no-encrypt-to);
36 my $default_chars = $ENV{'PLASS_CHARS'} || '!-~';
37 my $default_length = $ENV{'PLASS_LENGTH'};
38 if (!defined($default_length) || $default_length lt 0) {
43 "h|?" => sub { pod2usage(0) },
46 my $cmd = shift or pod2usage(1);
49 cat => [\&cmd_cat, "entries..."],
50 find => [\&cmd_find, "[pattern]"],
51 gen => [\&cmd_gen, "[-c chars] [-l length] entry"],
52 got => [\&cmd_got, "arguments ..."],
53 mv => [\&cmd_mv, "from to"],
54 regen => [\&cmd_regen, "[-c chars] [-l length] entry"],
55 rm => [\&cmd_rm, "entry"],
56 tee => [\&cmd_tee, "[-q] entry"],
57 tog => [\&cmd_tog, "arguments ..."],
59 pod2usage(1) unless defined $subcmd{$cmd};
60 my ($fn, $usage) = @{$subcmd{$cmd}};
69 my $prog = basename $0;
70 say STDERR "Usage: $prog $cmd $usage";
76 $f .= ".gpg" unless $f =~ m,\.gpg$,;
80 # tr -cd -- $chars < /dev/random | dd bs=$len count=1 status=none
82 my ($chars, $length) = @_;
85 open(my $fh, '<:raw', '/dev/random')
86 or die "can't open /dev/random: $!";
89 read($fh, my $t, $length * 4)
90 or die "failed to read /dev/random: $!";
97 return substr($pass, 0, $length);
101 # todo some stty black magic to avoid echo
104 die "failed to read stdin: $!" unless defined($pass);
111 my $parent = dirname $dir;
112 mkdirs($parent) unless -d $parent || $parent eq '/';
113 mkdir $dir or die "mkdir $dir: $!"
118 my ($file, $pass) = @_;
120 mkdirs(dirname $file);
122 my @args = ($gpg, @gpg_flags, '-e', '-r', recipient(),
124 open my $fh, '|-', @args;
130 open my $fh, '<', "$store/.gpg-id"
131 or die "can't open recipient file";
144 if (m,/.git$, || m,/.got$,) {
145 $File::Find::prune = 1;
149 return if defined($pattern) && ! m/$pattern/;
150 return unless -f && m,.gpg$,;
159 return sort(@entries);
164 open my $fh, '-|', ($got, @_);
170 return got 'add', '-I', shift;
174 got 'remove', '-f', shift
180 die "failed to fork: $!" unless defined $pid;
184 die "failed to commit changes" if $?;
188 open (STDOUT, ">&", \*STDERR)
189 or die "can't redirect stdout to stderr";
190 exec ($got, 'commit', '-m', shift)
191 or die "failed to exec $got: $!";
198 GetOptions('h|?' => \&usage) or usage;
202 my $file = name2file(shift @ARGV);
203 system ($gpg, @gpg_flags, '-d', $file);
204 die "failed to exec $gpg: $!" if $? == -1;
209 GetOptions('h|?' => \&usage) or usage;
212 map { say $_ } passfind(shift @ARGV);
216 my $chars = $default_chars;
217 my $length = $default_length;
222 'c=s' => sub { $chars = $_[1] },
224 'l=i' => sub { $length = $_[1] },
230 my $name = shift @ARGV;
231 my $file = name2file $name;
232 die "password already exists: $file\n" if -e $file;
234 my $pass = gen($chars, $length);
237 writepass($file, $pass);
249 # TODO: handle moving directories?
251 GetOptions('h|?' => \&usage) or usage;
257 my $pa = name2file $a;
258 my $pb = name2file $b;
260 die "source password doesn't exist" unless -f $pa;
261 die "target password exists" if -f $pb;
263 rename $pa, $pb or die "can't rename $a to $b: $!";
271 my $chars = $default_chars;
272 my $length = $default_length;
275 'c=s' => sub { $chars = $_[1] },
277 'l=i' => sub { $length = $_[1] },
281 my $name = shift @ARGV;
282 my $file = name2file $name;
283 die "password doesn't exist" unless -f $file;
285 my $pass = gen($chars, $length);
286 writepass($file, $pass);
289 got_ci "regen $name";
293 GetOptions('h|?' => \&usage) or usage;
296 my $name = shift @ARGV;
297 my $file = name2file $name;
311 my $name = shift @ARGV;
312 my $file = name2file $name;
314 my $pass = readpass "Enter the password: ";
315 writepass($file, $pass);
319 got_ci (-f $file ? "update $name" : "+$name");
331 B<plass> - manage passwords
335 B<plass> I<command> [-h] [arg ...]
337 Valid subcommands are: cat, find, gen, got, mv, regen, rm, tee, tog.
341 B<plass> is a simple password manager. It manages passwords stored in
342 a directory tree rooted at I<~/.password-store> (or I<$PLASS_STORE>),
343 where every password is a single file encrypted with gpg2(1).
345 Passwords entries can be referenced using the path relative to the
346 store directory. The extension ".gpg" is optional.
348 The whole store is supposed to be managed by the got(1) version
351 The commands for B<plass> are as follows:
355 =item B<cat> I<entries ...>
357 Decrypt and print the passwords of the given I<entries>.
359 =item B<find> [I<pattern>]
361 Print one per line all the entries of the store, optionally filtered
362 by the given I<pattern>.
364 =item B<gen> [B<-nq>] [B<-c> I<chars>] [B<-l> I<length>] I<entry>
366 Generate and persist a password for the given I<entry> in the store.
367 B<-c> can be used to control the characters allowed in the password
368 (by default I<!-~> i.e. all the printable ASCII character) and B<-l>
369 the length (32 by default.)
371 Unless B<-q> is provided, plass prints the generated password.
373 If the B<-n> option is given, plass won't persist the password.
375 =item B<got> I<arguments ...>
377 Execute got(1) in the password store directory with the given
380 =item B<mv> I<from> I<to>
382 Rename a password entry, doesn't work with directories. I<from> must
383 exist and I<to> mustn't.
385 =item B<regen> [B<-c> I<chars>] [B<-l> I<length>] I<entry>
387 Like B<gen> but re-generates a password in-place.
391 Remove the password I<entry> from the store.
393 =item B<tee> [B<-q>] I<entry>
395 Prompt for a password, persist it in the store under the given
396 I<entry> name and then print it again to standard output.
398 =item B<tog> I<arguments ...>
400 Execute tog(1) in the password store directory with the given
405 =head1 CREATING A PASSWORD STORE
407 A password store is just a normal got(1) repository with a worktree
408 checked out in I<~/.password-store> (or I<$PLASS_STORE>). The only
409 restriction is that a file called I<.gpg-id> must exist in the root of
410 the work tree for most B<plass> commands to work.
412 For example, a got repository and password store can be created as
415 $ mkdir .password-store
417 $ echo foo@example.com > .gpg-id
421 $ got import -m 'initial import' ~/.password-store
422 $ cd ~/.password-store
423 $ got checkout -E ~/git/pass.git
425 See got(1) for more information.
427 Otherwise, if a repository already exists, a password-store can be
428 checked out more simply as:
430 $ got checkout ~/git/pass.git ~/.password-store
432 To migrate from pass(1), just delete I<~/.password-store> and checkout
433 it again usign got(1).
441 Default range of characters to use to generate passwords.
445 Path to the got(1) executable.
449 Path to the gpg2(1) executable.
453 Default length for the passwords generated.
457 Path to the password-store directory tree. I<~/.password-store> by
462 Path to the tog(1) executable.
470 =item I<~/.password-store>
472 Password store used by default.
474 =item I<~/.password-store/.gpg-id>
476 File containing the gpg recipient used to encrypt the passwords.
480 =head1 ACKNOWLEDGEMENTS
482 B<plass> was heavily influenced by pass(1) in the design, but it's a
483 complete different implementation with different tools involved.
487 The B<plass> utility was written by Omar Polo <I<op@omarpolo.com>>.
491 B<plass> B<find> output format isn't designed to handle files with
492 newlines in them. Use find(1) B<-print0> or similar if it's a
495 There isn't a B<init> sub-command, the store initialisation must be