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 oneshot => [\&cmd_oneshot, "[-c chars] [-l length]"],
55 regen => [\&cmd_regen, "[-c chars] [-l length] entry"],
56 rm => [\&cmd_rm, "entry"],
57 tee => [\&cmd_tee, "[-q] entry"],
58 tog => [\&cmd_tog, "arguments ..."],
60 pod2usage(1) unless defined $subcmd{$cmd};
61 my ($fn, $usage) = @{$subcmd{$cmd}};
70 my $prog = basename $0;
71 say STDERR "Usage: $prog $cmd $usage";
77 $f .= ".gpg" unless $f =~ m,\.gpg$,;
81 # tr -cd -- $chars < /dev/random | dd bs=$len count=1 status=none
83 my ($chars, $length) = @_;
86 open(my $fh, '<:raw', '/dev/random')
87 or die "can't open /dev/random: $!";
90 read($fh, my $t, $length * 4)
91 or die "failed to read /dev/random: $!";
98 return substr($pass, 0, $length);
102 # todo some stty black magic to avoid echo
105 die "failed to read stdin: $!" unless defined($pass);
112 my $parent = dirname $dir;
113 mkdirs($parent) unless -d $parent || $parent eq '/';
114 mkdir $dir or die "mkdir $dir: $!"
119 my ($file, $pass) = @_;
121 mkdirs(dirname $file);
123 my @args = ($gpg, @gpg_flags, '-e', '-r', recipient(),
125 open my $fh, '|-', @args;
131 open my $fh, '<', "$store/.gpg-id"
132 or die "can't open recipient file";
145 if (m,/.git$, || m,/.got$,) {
146 $File::Find::prune = 1;
150 return if defined($pattern) && ! m/$pattern/;
151 return unless -f && m,.gpg$,;
160 return sort(@entries);
165 open my $fh, '-|', ($got, @_);
171 got 'add', '-I', shift;
175 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;
220 'c=s' => sub { $chars = $_[1] },
222 'l=i' => sub { $length = $_[1] },
226 my $name = shift @ARGV;
227 my $file = name2file $name;
228 die "password already exists: $file\n" if -e $file;
230 my $pass = gen($chars, $length);
231 writepass($file, $pass);
242 # TODO: handle moving directories?
244 GetOptions('h|?' => \&usage) or usage;
250 my $pa = name2file $a;
251 my $pb = name2file $b;
253 die "source password doesn't exist" unless -f $pa;
254 die "target password exists" if -f $pb;
256 rename $pa, $pb or die "can't rename $a to $b: $!";
264 my $chars = $default_chars;
265 my $length = $default_length;
268 'c=s' => sub { $chars = $_[1] },
270 'l=i' => sub { $length = $_[1] },
274 say gen($chars, $length);
278 my $chars = $default_chars;
279 my $length = $default_length;
282 'c=s' => sub { $chars = $_[1] },
284 'l=i' => sub { $length = $_[1] },
288 my $name = shift @ARGV;
289 my $file = name2file $name;
290 die "password doesn't exist" unless -f $file;
292 my $pass = gen($chars, $length);
293 writepass($file, $pass);
296 got_ci "regen $name";
300 GetOptions('h|?' => \&usage) or usage;
303 my $name = shift @ARGV;
304 my $file = name2file $name;
318 my $name = shift @ARGV;
319 my $file = name2file $name;
321 my $pass = readpass "Enter the password: ";
322 writepass($file, $pass);
326 got_ci (-f $file ? "update $name" : "+$name");
338 B<plass> - manage passwords
342 B<plass> I<command> [-h] [arg ...]
344 Valid subcommands are: cat, find, gen, got, mv, oneshot, regen, rm,
349 B<plass> is a simple password manager. It manages passwords stored in
350 a directory tree rooted at I<~/.password-store> (or I<$PLASS_STORE>),
351 where every password is a single file encrypted with gpg2(1).
353 Passwords entries can be referenced using the path relative to the
354 store directory. The extension ".gpg" is optional.
356 The whole store is supposed to be managed by the got(1) version
359 The commands for B<plass> are as follows:
363 =item B<cat> I<entries ...>
365 Decrypt and print the passwords of the given I<entries>.
367 =item B<find> [I<pattern>]
369 Print one per line all the entries of the store, optionally filtered
370 by the given I<pattern>.
372 =item B<gen> [B<-c> I<chars>] [B<-l> I<length>] I<entry>
374 Generate and persist a password for the given I<entry> in the store.
375 B<-c> can be used to control the characters allowed in the password
376 (by default I<!-~> i.e. all the printable ASCII character) and B<-l>
377 the length (32 by default.)
379 =item B<got> I<arguments ...>
381 Execute got(1) in the password store directory with the given
384 =item B<mv> I<from> I<to>
386 Rename a password entry, doesn't work with directories. I<from> must
387 exist and I<to> mustn't.
389 =item B<oneshot> [B<-c> I<chars>] [B<-l> I<length>]
391 Like B<gen> but prints the the generated password instead of persist
394 =item B<regen> [B<-c> I<chars>] [B<-l> I<length>] I<entry>
396 Like B<gen> but re-generates a password in-place.
400 Remove the password I<entry> from the store.
402 =item B<tee> [B<-q>] I<entry>
404 Prompt for a password, persist it in the store under the given
405 I<entry> name and then print it again to standard output.
407 =item B<tog> I<arguments ...>
409 Execute tog(1) in the password store directory with the given
414 =head1 CREATING A PASSWORD STORE
416 A password store is just a normal got(1) repository with a worktree
417 checked out in I<~/.password-store> (or I<$PLASS_STORE>). The only
418 restriction is that a file called I<.gpg-id> must exist in the root of
419 the work tree for most B<plass> commands to work.
421 For example, a got repository and password store can be created as
424 $ mkdir .password-store
426 $ echo foo@example.com > .gpg-id
430 $ got import -m 'initial import' ~/.password-store
431 $ cd ~/.password-store
432 $ got checkout -E ~/git/pass.git
434 See got(1) for more information.
436 Otherwise, if a repository already exists, a password-store can be
437 checked out more simply as:
439 $ got checkout ~/git/pass.git ~/.password-store
441 To migrate from pass(1), just delete I<~/.password-store> and checkout
442 it again usign got(1).
450 Default range of characters to use to generate passwords.
454 Path to the got(1) executable.
458 Path to the gpg2(1) executable.
462 Default length for the passwords generated.
466 Path to the password-store directory tree. I<~/.password-store> by
471 Path to the tog(1) executable.
479 =item I<~/.password-store>
481 Password store used by default.
483 =item I<~/.password-store/.gpg-id>
485 File containing the gpg recipient used to encrypt the passwords.
489 =head1 ACKNOWLEDGEMENTS
491 B<plass> was heavily influenced by pass(1) in the design, but it's a
492 complete different implementation with different tools involved.
496 The B<plass> utility was written by Omar Polo <I<op@omarpolo.com>>.
500 B<plass> B<find> output format isn't designed to handle files with
501 newlines in them. Use find(1) B<-print0> or similar if it's a
504 There isn't a B<init> sub-command, the store initialisation must be