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 tog => [\&cmd_tog, "arguments ..."],
58 write => [\&cmd_write, "entry"],
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
180 system ($got, 'commit', '-m', shift);
181 die "failed to commit changes: $!" if $? == -1;
188 GetOptions('h|?' => \&usage) or usage;
192 my $file = name2file(shift @ARGV);
193 system ($gpg, @gpg_flags, '-d', $file);
194 die "failed to exec $gpg: $!" if $? == -1;
199 GetOptions('h|?' => \&usage) or usage;
202 map { say $_ } passfind(shift @ARGV);
206 my $chars = $default_chars;
207 my $length = $default_length;
210 'c=s' => sub { $chars = $_[1] },
212 'l=i' => sub { $length = $_[1] },
216 my $name = shift @ARGV;
217 my $file = name2file $name;
218 die "password already exists: $file\n" if -e $file;
220 my $pass = gen($chars, $length);
221 writepass($file, $pass);
232 # TODO: handle moving directories?
234 GetOptions('h|?' => \&usage) or usage;
240 my $pa = name2file $a;
241 my $pb = name2file $b;
243 die "source password doesn't exist" unless -f $pa;
244 die "target password exists" if -f $pb;
246 rename $pa, $pb or die "can't rename $a to $b: $!";
254 my $chars = $default_chars;
255 my $length = $default_length;
258 'c=s' => sub { $chars = $_[1] },
260 'l=i' => sub { $length = $_[1] },
264 say gen($chars, $length);
268 my $chars = $default_chars;
269 my $length = $default_length;
272 'c=s' => sub { $chars = $_[1] },
274 'l=i' => sub { $length = $_[1] },
278 my $name = shift @ARGV;
279 my $file = name2file $name;
280 die "password doesn't exist" unless -f $file;
282 my $pass = gen($chars, $length);
283 writepass($file, $pass);
286 got_ci "regen $name";
290 GetOptions('h|?' => \&usage) or usage;
293 my $name = shift @ARGV;
294 my $file = name2file $name;
306 GetOptions('h|?' => \&usage) or usage;
309 my $name = shift @ARGV;
310 my $file = name2file $name;
312 my $pass = readpass "Enter the password: ";
313 writepass($file, $pass);
323 B<plass> - manage passwords
327 B<plass> I<command> [-h] [arg ...]
329 Valid subcommands are: cat, find, gen, got, mv, oneshot, regen, rm,
334 B<plass> is a simple password manager. It manages passwords stored in
335 a directory tree rooted at I<~/.password-store> (or I<$PLASS_STORE>),
336 where every password is a single file encrypted with gpg2(1).
338 Passwords entries can be referenced using the path relative to the
339 store directory. The extension ".gpg" is optional.
341 The whole store is supposed to be managed by the got(1) version
344 The commands for B<plass> are as follows:
348 =item B<cat> I<entries ...>
350 Decrypt and print the passwords of the given I<entries>.
352 =item B<find> [I<pattern>]
354 Print one per line all the entries of the store, optionally filtered
355 by the given I<pattern>.
357 =item B<gen> [B<-c> I<chars>] [B<-l> I<length>] I<entry>
359 Generate and persist a password for the given I<entry> in the store.
360 B<-c> can be used to control the characters allowed in the password
361 (by default I<!-~> i.e. all the printable ASCII character) and B<-l>
362 the length (32 by default.)
364 =item B<got> I<arguments ...>
366 Execute got(1) in the password store directory with the given
369 =item B<mv> I<from> I<to>
371 Rename a password entry, doesn't work with directories. I<from> must
372 exist and I<to> mustn't.
374 =item B<oneshot> [B<-c> I<chars>] [B<-l> I<length>]
376 Like B<gen> but prints the the generated password instead of persist
379 =item B<regen> [B<-c> I<chars>] [B<-l> I<length>] I<entry>
381 Like B<gen> but re-generates a password in-place.
385 Remove the password I<entry> from the store.
387 =item B<tog> I<arguments ...>
389 Execute tog(1) in the password store directory with the given
392 =item B<write> I<entry>
394 Prompt for a password and persist it in the store under the given
399 =head1 CREATING A PASSWORD STORE
401 A password store is just a normal got(1) repository with a worktree
402 checked out in I<~/.password-store> (or I<$PLASS_STORE>). The only
403 restriction is that a file called I<.gpg-id> must exist in the root of
404 the work tree for most B<plass> commands to work.
406 For example, a got repository and password store can be created as
409 $ mkdir .password-store
411 $ echo foo@example.com > .gpg-id
415 $ got import -m 'initial import' ~/.password-store
416 $ cd ~/.password-store
417 $ got checkout -E ~/git/pass.git
419 See got(1) for more information.
421 Otherwise, if a repository already exists, a password-store can be
422 checked out more simply as:
424 $ got checkout ~/git/pass.git ~/.password-store
426 To migrate from pass(1), just delete I<~/.password-store> and checkout
427 it again usign got(1).
435 Default range of characters to use to generate passwords.
439 Path to the got(1) executable.
443 Path to the gpg2(1) executable.
447 Default length for the passwords generated.
451 Path to the password-store directory tree. I<~/.password-store> by
456 Path to the tog(1) executable.
464 =item I<~/.password-store>
466 Password store used by default.
468 =item I<~/.password-store/.gpg-id>
470 File containing the gpg recipient used to encrypt the passwords.
474 =head1 ACKNOWLEDGEMENTS
476 B<plass> was heavily influenced by pass(1) in the design, but it's a
477 complete different implementation with different tools involved.
481 The B<plass> utility was written by Omar Polo <I<op@omarpolo.com>>.
485 B<plass> B<find> output format isn't designed to handle files with
486 newlines in them. Use find(1) B<-print0> or similar if it's a
489 There isn't a B<init> sub-command, the store initialisation must be