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 got 'add', '-I', shift;
174 got 'remove', '-f', shift;
179 die "failed to fork: $!" unless defined $pid;
183 die "failed to commit changes" if $?;
187 open (STDOUT, ">&", \*STDERR)
188 or die "can't redirect stdout to stderr";
189 exec ($got, 'commit', '-m', shift)
190 or die "failed to exec $got: $!";
197 GetOptions('h|?' => \&usage) or usage;
201 my $file = name2file(shift @ARGV);
202 system ($gpg, @gpg_flags, '-d', $file);
203 die "failed to exec $gpg: $!" if $? == -1;
208 GetOptions('h|?' => \&usage) or usage;
211 map { say $_ } passfind(shift @ARGV);
215 my $chars = $default_chars;
216 my $length = $default_length;
221 'c=s' => sub { $chars = $_[1] },
223 'l=i' => sub { $length = $_[1] },
229 my $name = shift @ARGV;
230 my $file = name2file $name;
231 die "password already exists: $file\n" if -e $file;
233 my $pass = gen($chars, $length);
236 writepass($file, $pass);
248 # TODO: handle moving directories?
250 GetOptions('h|?' => \&usage) or usage;
256 my $pa = name2file $a;
257 my $pb = name2file $b;
259 die "source password doesn't exist" unless -f $pa;
260 die "target password exists" if -f $pb;
262 rename $pa, $pb or die "can't rename $a to $b: $!";
270 my $chars = $default_chars;
271 my $length = $default_length;
274 'c=s' => sub { $chars = $_[1] },
276 'l=i' => sub { $length = $_[1] },
280 my $name = shift @ARGV;
281 my $file = name2file $name;
282 die "password doesn't exist" unless -f $file;
284 my $pass = gen($chars, $length);
285 writepass($file, $pass);
288 got_ci "regen $name";
292 GetOptions('h|?' => \&usage) or usage;
295 my $name = shift @ARGV;
296 my $file = name2file $name;
310 my $name = shift @ARGV;
311 my $file = name2file $name;
313 my $pass = readpass "Enter the password: ";
314 writepass($file, $pass);
318 got_ci (-f $file ? "update $name" : "+$name");
330 B<plass> - manage passwords
334 B<plass> I<command> [-h] [arg ...]
336 Valid subcommands are: cat, find, gen, got, mv, regen, rm, tee, tog.
340 B<plass> is a simple password manager. It manages passwords stored in
341 a directory tree rooted at I<~/.password-store> (or I<$PLASS_STORE>),
342 where every password is a single file encrypted with gpg2(1).
344 Passwords entries can be referenced using the path relative to the
345 store directory. The extension ".gpg" is optional.
347 The whole store is supposed to be managed by the got(1) version
350 The commands for B<plass> are as follows:
354 =item B<cat> I<entries ...>
356 Decrypt and print the passwords of the given I<entries>.
358 =item B<find> [I<pattern>]
360 Print one per line all the entries of the store, optionally filtered
361 by the given I<pattern>.
363 =item B<gen> [B<-nq>] [B<-c> I<chars>] [B<-l> I<length>] I<entry>
365 Generate and persist a password for the given I<entry> in the store.
366 B<-c> can be used to control the characters allowed in the password
367 (by default I<!-~> i.e. all the printable ASCII character) and B<-l>
368 the length (32 by default.)
370 Unless B<-q> is provided, plass prints the generated password.
372 If the B<-n> option is given, plass won't persist the password.
374 =item B<got> I<arguments ...>
376 Execute got(1) in the password store directory with the given
379 =item B<mv> I<from> I<to>
381 Rename a password entry, doesn't work with directories. I<from> must
382 exist and I<to> mustn't.
384 =item B<regen> [B<-c> I<chars>] [B<-l> I<length>] I<entry>
386 Like B<gen> but re-generates a password in-place.
390 Remove the password I<entry> from the store.
392 =item B<tee> [B<-q>] I<entry>
394 Prompt for a password, persist it in the store under the given
395 I<entry> name and then print it again to standard output.
397 =item B<tog> I<arguments ...>
399 Execute tog(1) in the password store directory with the given
404 =head1 CREATING A PASSWORD STORE
406 A password store is just a normal got(1) repository with a worktree
407 checked out in I<~/.password-store> (or I<$PLASS_STORE>). The only
408 restriction is that a file called I<.gpg-id> must exist in the root of
409 the work tree for most B<plass> commands to work.
411 For example, a got repository and password store can be created as
414 $ mkdir .password-store
416 $ echo foo@example.com > .gpg-id
420 $ got import -m 'initial import' ~/.password-store
421 $ cd ~/.password-store
422 $ got checkout -E ~/git/pass.git
424 See got(1) for more information.
426 Otherwise, if a repository already exists, a password-store can be
427 checked out more simply as:
429 $ got checkout ~/git/pass.git ~/.password-store
431 To migrate from pass(1), just delete I<~/.password-store> and checkout
432 it again usign got(1).
440 Default range of characters to use to generate passwords.
444 Path to the got(1) executable.
448 Path to the gpg2(1) executable.
452 Default length for the passwords generated.
456 Path to the password-store directory tree. I<~/.password-store> by
461 Path to the tog(1) executable.
469 =item I<~/.password-store>
471 Password store used by default.
473 =item I<~/.password-store/.gpg-id>
475 File containing the gpg recipient used to encrypt the passwords.
479 =head1 ACKNOWLEDGEMENTS
481 B<plass> was heavily influenced by pass(1) in the design, but it's a
482 complete different implementation with different tools involved.
486 The B<plass> utility was written by Omar Polo <I<op@omarpolo.com>>.
490 B<plass> B<find> output format isn't designed to handle files with
491 newlines in them. Use find(1) B<-print0> or similar if it's a
494 There isn't a B<init> sub-command, the store initialisation must be