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);
164 open my $fh, '-|', ($got, @_);
170 got 'add', '-I', shift
175 got 'remove', '-f', shift
181 die "failed to fork: $!" unless defined $pid;
185 die "failed to commit changes" if $?;
189 open (STDOUT, ">&", \*STDERR)
190 or die "can't redirect stdout to stderr";
191 exec ($got, 'commit', '-m', shift)
192 or die "failed to exec $got: $!";
199 GetOptions('h|?' => \&usage) or usage;
203 my $file = name2file(shift @ARGV);
204 system ($gpg, @gpg_flags, '-d', $file);
205 die "failed to exec $gpg: $!" if $? == -1;
210 GetOptions('h|?' => \&usage) or usage;
213 map { say $_ } passfind(shift @ARGV);
217 my $chars = $default_chars;
218 my $length = $default_length;
221 'c=s' => sub { $chars = $_[1] },
223 'l=i' => sub { $length = $_[1] },
227 my $name = shift @ARGV;
228 my $file = name2file $name;
229 die "password already exists: $file\n" if -e $file;
231 my $pass = gen($chars, $length);
232 writepass($file, $pass);
243 # TODO: handle moving directories?
245 GetOptions('h|?' => \&usage) or usage;
251 my $pa = name2file $a;
252 my $pb = name2file $b;
254 die "source password doesn't exist" unless -f $pa;
255 die "target password exists" if -f $pb;
257 rename $pa, $pb or die "can't rename $a to $b: $!";
265 my $chars = $default_chars;
266 my $length = $default_length;
269 'c=s' => sub { $chars = $_[1] },
271 'l=i' => sub { $length = $_[1] },
275 say gen($chars, $length);
279 my $chars = $default_chars;
280 my $length = $default_length;
283 'c=s' => sub { $chars = $_[1] },
285 'l=i' => sub { $length = $_[1] },
289 my $name = shift @ARGV;
290 my $file = name2file $name;
291 die "password doesn't exist" unless -f $file;
293 my $pass = gen($chars, $length);
294 writepass($file, $pass);
297 got_ci "regen $name";
301 GetOptions('h|?' => \&usage) or usage;
304 my $name = shift @ARGV;
305 my $file = name2file $name;
319 my $name = shift @ARGV;
320 my $file = name2file $name;
322 my $pass = readpass "Enter the password: ";
323 writepass($file, $pass);
327 got_ci (-f $file ? "update $name" : "+$name");
339 B<plass> - manage passwords
343 B<plass> I<command> [-h] [arg ...]
345 Valid subcommands are: cat, find, gen, got, mv, oneshot, regen, rm,
350 B<plass> is a simple password manager. It manages passwords stored in
351 a directory tree rooted at I<~/.password-store> (or I<$PLASS_STORE>),
352 where every password is a single file encrypted with gpg2(1).
354 Passwords entries can be referenced using the path relative to the
355 store directory. The extension ".gpg" is optional.
357 The whole store is supposed to be managed by the got(1) version
360 The commands for B<plass> are as follows:
364 =item B<cat> I<entries ...>
366 Decrypt and print the passwords of the given I<entries>.
368 =item B<find> [I<pattern>]
370 Print one per line all the entries of the store, optionally filtered
371 by the given I<pattern>.
373 =item B<gen> [B<-c> I<chars>] [B<-l> I<length>] I<entry>
375 Generate and persist a password for the given I<entry> in the store.
376 B<-c> can be used to control the characters allowed in the password
377 (by default I<!-~> i.e. all the printable ASCII character) and B<-l>
378 the length (32 by default.)
380 =item B<got> I<arguments ...>
382 Execute got(1) in the password store directory with the given
385 =item B<mv> I<from> I<to>
387 Rename a password entry, doesn't work with directories. I<from> must
388 exist and I<to> mustn't.
390 =item B<oneshot> [B<-c> I<chars>] [B<-l> I<length>]
392 Like B<gen> but prints the the generated password instead of persist
395 =item B<regen> [B<-c> I<chars>] [B<-l> I<length>] I<entry>
397 Like B<gen> but re-generates a password in-place.
401 Remove the password I<entry> from the store.
403 =item B<tee> [B<-q>] I<entry>
405 Prompt for a password, persist it in the store under the given
406 I<entry> name and then print it again to standard output.
408 =item B<tog> I<arguments ...>
410 Execute tog(1) in the password store directory with the given
415 =head1 CREATING A PASSWORD STORE
417 A password store is just a normal got(1) repository with a worktree
418 checked out in I<~/.password-store> (or I<$PLASS_STORE>). The only
419 restriction is that a file called I<.gpg-id> must exist in the root of
420 the work tree for most B<plass> commands to work.
422 For example, a got repository and password store can be created as
425 $ mkdir .password-store
427 $ echo foo@example.com > .gpg-id
431 $ got import -m 'initial import' ~/.password-store
432 $ cd ~/.password-store
433 $ got checkout -E ~/git/pass.git
435 See got(1) for more information.
437 Otherwise, if a repository already exists, a password-store can be
438 checked out more simply as:
440 $ got checkout ~/git/pass.git ~/.password-store
442 To migrate from pass(1), just delete I<~/.password-store> and checkout
443 it again usign got(1).
451 Default range of characters to use to generate passwords.
455 Path to the got(1) executable.
459 Path to the gpg2(1) executable.
463 Default length for the passwords generated.
467 Path to the password-store directory tree. I<~/.password-store> by
472 Path to the tog(1) executable.
480 =item I<~/.password-store>
482 Password store used by default.
484 =item I<~/.password-store/.gpg-id>
486 File containing the gpg recipient used to encrypt the passwords.
490 =head1 ACKNOWLEDGEMENTS
492 B<plass> was heavily influenced by pass(1) in the design, but it's a
493 complete different implementation with different tools involved.
497 The B<plass> utility was written by Omar Polo <I<op@omarpolo.com>>.
501 B<plass> B<find> output format isn't designed to handle files with
502 newlines in them. Use find(1) B<-print0> or similar if it's a
505 There isn't a B<init> sub-command, the store initialisation must be