Blob


1 #!/usr/bin/env perl
2 #
3 # Copyright (c) 2022 Omar Polo <op@omarpolo.com>
4 #
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.
8 #
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.
17 use strict;
18 use warnings;
19 use v5.32;
21 use open ":std", ":encoding(UTF-8)";
23 use Getopt::Long qw(:config bundling require_order);
24 use Pod::Usage;
25 use File::Basename;
26 use File::Find;
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) {
39 $default_length = 32;
40 }
42 GetOptions(
43 "h|?" => sub { pod2usage(0) },
44 ) or pod2usage(1);
46 my $cmd = shift or pod2usage(1);
48 my %subcmd = (
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 ..."],
58 );
59 pod2usage(1) unless defined $subcmd{$cmd};
60 my ($fn, $usage) = @{$subcmd{$cmd}};
61 chdir $store;
62 $fn->();
63 exit 0;
66 # utils
68 sub usage {
69 my $prog = basename $0;
70 say STDERR "Usage: $prog $cmd $usage";
71 exit 1;
72 }
74 sub name2file {
75 my $f = shift;
76 $f .= ".gpg" unless $f =~ m,\.gpg$,;
77 return $f;
78 }
80 # tr -cd -- $chars < /dev/random | dd bs=$len count=1 status=none
81 sub gen {
82 my ($chars, $length) = @_;
83 my $pass = "";
85 open(my $fh, '<:raw', '/dev/random')
86 or die "can't open /dev/random: $!";
87 my $l = $length;
88 while ($l gt 0) {
89 read($fh, my $t, $length * 4)
90 or die "failed to read /dev/random: $!";
91 $t =~ s/[^$chars]//g;
92 $l -= length($t);
93 $pass .= $t;
94 }
95 close($fh);
97 return substr($pass, 0, $length);
98 }
100 sub readpass {
101 # todo some stty black magic to avoid echo
102 print shift if -t;
103 my $pass = <>;
104 die "failed to read stdin: $!" unless defined($pass);
105 chomp $pass;
106 return $pass;
109 sub mkdirs {
110 my $dir = shift;
111 my $parent = dirname $dir;
112 mkdirs($parent) unless -d $parent || $parent eq '/';
113 mkdir $dir or die "mkdir $dir: $!"
114 unless -d $dir;
117 sub writepass {
118 my ($file, $pass) = @_;
120 mkdirs(dirname $file);
122 my @args = ($gpg, @gpg_flags, '-e', '-r', recipient(),
123 '-o', $file);
124 open my $fh, '|-', @args;
125 say $fh "$pass";
126 close($fh);
129 sub recipient {
130 open my $fh, '<', "$store/.gpg-id"
131 or die "can't open recipient file";
132 my $r = <$fh>;
133 chomp $r;
134 close($fh);
135 return $r;
138 sub passfind {
139 my $pattern = shift;
140 my @entries;
142 find({
143 wanted => sub {
144 if (m,/.git$, || m,/.got$,) {
145 $File::Find::prune = 1;
146 return;
149 return if defined($pattern) && ! m/$pattern/;
150 return unless -f && m,.gpg$,;
152 s,^$store/*,,;
153 s,.gpg$,,;
154 push @entries, $_;
155 },
156 no_chdir => 1,
157 follow_fast => 1,
158 }, ($store));
159 return sort(@entries);
162 sub got {
163 # discard stdout
164 open my $fh, '-|', ($got, @_);
165 close($fh);
166 return !$?;
169 sub got_add {
170 return got 'add', '-I', shift;
173 sub got_rm {
174 got 'remove', '-f', shift
175 or exit(1);
178 sub got_ci {
179 my $pid = fork;
180 die "failed to fork: $!" unless defined $pid;
182 if ($pid ne 0) {
183 wait;
184 die "failed to commit changes" if $?;
185 return;
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: $!";
195 # cmds
197 sub cmd_cat {
198 GetOptions('h|?' => \&usage) or usage;
199 usage unless @ARGV;
201 while (@ARGV) {
202 my $file = name2file(shift @ARGV);
203 system ($gpg, @gpg_flags, '-d', $file);
204 die "failed to exec $gpg: $!" if $? == -1;
208 sub cmd_find {
209 GetOptions('h|?' => \&usage) or usage;
210 usage if @ARGV gt 1;
212 map { say $_ } passfind(shift @ARGV);
215 sub cmd_gen {
216 my $chars = $default_chars;
217 my $length = $default_length;
218 my $nop;
219 my $q;
221 GetOptions(
222 'c=s' => sub { $chars = $_[1] },
223 'h|?' => \&usage,
224 'l=i' => sub { $length = $_[1] },
225 'n' => \$nop,
226 'q' => \$q,
227 ) or usage;
228 usage if @ARGV ne 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);
236 unless ($nop) {
237 writepass($file, $pass);
238 got_add $file;
239 got_ci "+$name";
241 say $pass unless $q;
244 sub cmd_got {
245 chdir $store;
246 exec $got, @ARGV;
249 # TODO: handle moving directories?
250 sub cmd_mv {
251 GetOptions('h|?' => \&usage) or usage;
252 usage if @ARGV ne 2;
254 my $a = shift @ARGV;
255 my $b = shift @ARGV;
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: $!";
265 got_rm $pa;
266 got_add $pb;
267 got_ci "mv $a $b";
270 sub cmd_regen {
271 my $chars = $default_chars;
272 my $length = $default_length;
274 GetOptions(
275 'c=s' => sub { $chars = $_[1] },
276 'h|?' => \&usage,
277 'l=i' => sub { $length = $_[1] },
278 ) or usage;
279 usage if @ARGV ne 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);
288 got_add $file;
289 got_ci "regen $name";
292 sub cmd_rm {
293 GetOptions('h|?' => \&usage) or usage;
294 usage if @ARGV ne 1;
296 my $name = shift @ARGV;
297 my $file = name2file $name;
299 got_rm $file;
300 got_ci "-$name";
303 sub cmd_tee {
304 my $q;
305 GetOptions(
306 'h|?' => \&usage,
307 'q' => \$q,
308 ) or usage;
309 usage if @ARGV ne 1;
311 my $name = shift @ARGV;
312 my $file = name2file $name;
314 my $pass = readpass "Enter the password: ";
315 writepass($file, $pass);
316 say $pass unless $q;
318 got_add $file;
319 got_ci (-f $file ? "update $name" : "+$name");
322 sub cmd_tog {
323 chdir $store;
324 exec $tog, @ARGV;
327 __END__
329 =head1 NAME
331 B<plass> - manage passwords
333 =head1 SYNOPSIS
335 B<plass> I<command> [-h] [arg ...]
337 Valid subcommands are: cat, find, gen, got, mv, regen, rm, tee, tog.
339 =head1 DESCRIPTION
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
349 control system.
351 The commands for B<plass> are as follows:
353 =over
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
378 I<arguments>.
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.
389 =item B<rm> I<entry>
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
401 I<arguments>.
403 =back
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
413 follows:
415 $ mkdir .password-store
416 $ cd .password-store
417 $ echo foo@example.com > .gpg-id
418 $ cd ~/git
419 $ got init pass.git
420 $ cd pass.git
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).
435 =head1 ENVIRONMENT
437 =over
439 =item PLASS_CHARS
441 Default range of characters to use to generate passwords.
443 =item PLASS_GOT
445 Path to the got(1) executable.
447 =item PLASS_GPG
449 Path to the gpg2(1) executable.
451 =item PLASS_LENGTH
453 Default length for the passwords generated.
455 =item PLASS_STORE
457 Path to the password-store directory tree. I<~/.password-store> by
458 default.
460 =item PLASS_TOG
462 Path to the tog(1) executable.
464 =back
466 =head1 FILES
468 =over
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.
478 =back
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.
485 =head1 AUTHORS
487 The B<plass> utility was written by Omar Polo <I<op@omarpolo.com>>.
489 =head1 CAVEATS
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
493 concern.
495 There isn't a B<init> sub-command, the store initialisation must be
496 performed manually.
498 =cut