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 // 'find';
48 my %subcmd = (
49 cat => [\&cmd_cat, "entries..."],
50 find => [\&cmd_find, "[pattern]"],
51 gen => [\&cmd_gen, "[-nq] [-c chars] [-l length] entry"],
52 got => [\&cmd_got, "args ..."],
53 mv => [\&cmd_mv, "from to"],
54 rm => [\&cmd_rm, "entries..."],
55 tee => [\&cmd_tee, "[-q] entry"],
56 tog => [\&cmd_tog, "args ..."],
57 );
58 pod2usage(1) unless defined $subcmd{$cmd};
59 my ($fn, $usage) = @{$subcmd{$cmd}};
60 chdir $store;
61 $fn->();
62 exit 0;
65 # utils
67 sub usage {
68 my $prog = basename $0;
69 say STDERR "Usage: $prog $cmd $usage";
70 exit 1;
71 }
73 sub name2file {
74 my $f = shift;
75 $f .= ".gpg" unless $f =~ m,\.gpg$,;
76 return $f;
77 }
79 # tr -cd -- $chars < /dev/random | dd bs=$len count=1 status=none
80 sub gen {
81 my ($chars, $length) = @_;
82 my $pass = "";
84 open(my $fh, '<:raw', '/dev/random')
85 or die "can't open /dev/random: $!";
86 my $l = $length;
87 while ($l gt 0) {
88 read($fh, my $t, $length * 4)
89 or die "failed to read /dev/random: $!";
90 $t =~ s/[^$chars]//g;
91 $l -= length($t);
92 $pass .= $t;
93 }
94 close($fh);
96 return substr($pass, 0, $length);
97 }
99 sub readpass {
100 # todo some stty black magic to avoid echo
101 print shift if -t;
102 my $pass = <>;
103 die "failed to read stdin: $!" unless defined($pass);
104 chomp $pass;
105 return $pass;
108 sub mkdirs {
109 my $dir = shift;
110 my $parent = dirname $dir;
111 mkdirs($parent) unless -d $parent || $parent eq '/';
112 mkdir $dir or die "mkdir $dir: $!"
113 unless -d $dir;
116 sub writepass {
117 my ($file, $pass) = @_;
119 mkdirs(dirname $file);
121 my @args = ($gpg, @gpg_flags, '-e', '-r', recipient(),
122 '-o', $file);
123 open my $fh, '|-', @args;
124 say $fh "$pass";
125 close($fh);
128 sub recipient {
129 open my $fh, '<', "$store/.gpg-id"
130 or die "can't open recipient file";
131 my $r = <$fh>;
132 chomp $r;
133 close($fh);
134 return $r;
137 sub passfind {
138 my $pattern = shift;
139 my @entries;
141 find({
142 wanted => sub {
143 if (m,/.git$, || m,/.got$,) {
144 $File::Find::prune = 1;
145 return;
148 return if defined($pattern) && ! m/$pattern/;
149 return unless -f && m,.gpg$,;
151 s,^$store/*,,;
152 s,.gpg$,,;
153 push @entries, $_;
154 },
155 no_chdir => 1,
156 follow_fast => 1,
157 }, ($store));
158 return sort(@entries);
161 sub got {
162 # discard stdout
163 open my $fh, '-|', ($got, @_);
164 close($fh);
165 return !$?;
168 sub got_add {
169 return got 'add', '-I', shift;
172 sub got_rm {
173 got 'remove', '-f', shift
174 or exit(1);
177 sub got_ci {
178 my $pid = fork;
179 die "failed to fork: $!" unless defined $pid;
181 if ($pid ne 0) {
182 wait;
183 die "failed to commit changes" if $?;
184 return;
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: $!";
194 # cmds
196 sub cmd_cat {
197 GetOptions('h|?' => \&usage) or usage;
198 usage unless @ARGV;
200 while (@ARGV) {
201 my $file = name2file(shift @ARGV);
202 system ($gpg, @gpg_flags, '-d', $file);
203 die "failed to exec $gpg: $!" if $? == -1;
207 sub cmd_find {
208 GetOptions('h|?' => \&usage) or usage;
209 usage if @ARGV gt 1;
211 map { say $_ } passfind(shift @ARGV);
214 sub cmd_gen {
215 my $chars = $default_chars;
216 my $length = $default_length;
217 my $nop;
218 my $q;
220 GetOptions(
221 'c=s' => sub { $chars = $_[1] },
222 'h|?' => \&usage,
223 'l=i' => sub { $length = $_[1] },
224 'n' => \$nop,
225 'q' => \$q,
226 ) or usage;
227 usage if @ARGV ne 1;
229 my $name = shift @ARGV;
230 my $file = name2file $name;
231 my $renamed = -f $file;
233 my $pass = gen($chars, $length);
235 unless ($nop) {
236 writepass($file, $pass);
237 got_add $file;
238 got_ci($renamed ? "update $name" : "+$name");
240 say $pass unless $q;
243 sub cmd_got {
244 exec $got, @ARGV;
247 # TODO: handle moving directories?
248 sub cmd_mv {
249 GetOptions('h|?' => \&usage) or usage;
250 usage if @ARGV ne 2;
252 my $a = shift @ARGV;
253 my $b = shift @ARGV;
255 my $pa = name2file $a;
256 my $pb = name2file $b;
258 die "source password doesn't exist" unless -f $pa;
259 die "target password exists" if -f $pb;
261 rename $pa, $pb or die "can't rename $a to $b: $!";
263 got_rm $pa;
264 got_add $pb or die "can't add $pb\n";
265 got_ci "mv $a $b";
268 sub cmd_rm {
269 GetOptions('h|?' => \&usage) or usage;
270 usage unless @ARGV;
272 while (@ARGV) {
273 my $name = shift @ARGV;
274 my $file = name2file $name;
276 got_rm $file;
277 got_ci "-$name";
281 sub cmd_tee {
282 my $q;
283 GetOptions(
284 'h|?' => \&usage,
285 'q' => \$q,
286 ) or usage;
287 usage if @ARGV ne 1;
289 my $name = shift @ARGV;
290 my $file = name2file $name;
292 my $pass = readpass "Enter the password: ";
293 writepass($file, $pass);
295 got_add $file;
296 got_ci (-f $file ? "update $name" : "+$name");
297 say $pass unless $q;
300 sub cmd_tog {
301 exec $tog, @ARGV;
304 __END__
306 =head1 NAME
308 B<plass> - manage passwords
310 =head1 SYNOPSIS
312 B<plass> I<command> [-h] [arg ...]
314 Valid subcommands are: cat, find, gen, got, mv, rm, tee, tog.
316 If no I<command> is given, B<find> is assumed.
318 =head1 DESCRIPTION
320 B<plass> is a simple password manager. It manages passwords stored in
321 a directory tree rooted at I<~/.password-store> (or I<$PLASS_STORE>),
322 where every password is a single file encrypted with gpg2(1).
324 Passwords entries can be referenced using the path relative to the
325 store directory. The extension ".gpg" is optional.
327 The whole store is supposed to be managed by the got(1) version
328 control system.
330 The commands for B<plass> are as follows:
332 =over
334 =item B<cat> I<entries ...>
336 Decrypt and print the passwords of the given I<entries>.
338 =item B<find> [I<pattern>]
340 Print one per line all the entries of the store, optionally filtered
341 by the given I<pattern>.
343 =item B<gen> [B<-nq>] [B<-c> I<chars>] [B<-l> I<length>] I<entry>
345 Generate and persist a password for the given I<entry> in the store.
346 B<-c> can be used to control the characters allowed in the password
347 (by default I<!-~> i.e. all the printable ASCII character) and B<-l>
348 the length (32 by default.)
350 Unless B<-q> is provided, plass prints the generated password.
352 If the B<-n> option is given, plass won't persist the password.
354 =item B<got> I<arguments ...>
356 Execute got(1) in the password store directory with the given
357 I<arguments>.
359 =item B<mv> I<from> I<to>
361 Rename a password entry, doesn't work with directories. I<from> must
362 exist and I<to> mustn't.
364 =item B<rm> I<entries...>
366 Remove the password I<entry> from the store.
368 =item B<tee> [B<-q>] I<entry>
370 Prompt for a password, persist it in the store under the given
371 I<entry> name and then print it again to standard output.
373 =item B<tog> I<arguments ...>
375 Execute tog(1) in the password store directory with the given
376 I<arguments>.
378 =back
380 =head1 CREATING A PASSWORD STORE
382 A password store is just a normal got(1) repository with a worktree
383 checked out in I<~/.password-store> (or I<$PLASS_STORE>). The only
384 restriction is that a file called I<.gpg-id> must exist in the root of
385 the work tree for most B<plass> commands to work.
387 For example, a got repository and password store can be created as
388 follows:
390 $ mkdir .password-store
391 $ cd .password-store
392 $ echo foo@example.com > .gpg-id
393 $ cd ~/git
394 $ got init pass.git
395 $ got import -r pass.git -m 'initial import' ~/.password-store
396 $ cd ~/.password-store
397 $ got checkout -E ~/git/pass.git .
399 See got(1) for more information.
401 Otherwise, if a repository already exists, a password-store can be
402 checked out more simply as:
404 $ got checkout ~/git/pass.git ~/.password-store
406 To migrate from pass(1), just delete I<~/.password-store> and checkout
407 it again using got(1).
409 =head1 ENVIRONMENT
411 =over
413 =item PLASS_CHARS
415 Default range of characters to use to generate passwords.
417 =item PLASS_GOT
419 Path to the got(1) executable.
421 =item PLASS_GPG
423 Path to the gpg2(1) executable.
425 =item PLASS_LENGTH
427 Default length for the passwords generated.
429 =item PLASS_STORE
431 Path to the password-store directory tree. I<~/.password-store> by
432 default.
434 =item PLASS_TOG
436 Path to the tog(1) executable.
438 =back
440 =head1 FILES
442 =over
444 =item I<~/.password-store>
446 Password store used by default.
448 =item I<~/.password-store/.gpg-id>
450 File containing the gpg recipient used to encrypt the passwords.
452 =back
454 =head1 ACKNOWLEDGEMENTS
456 B<plass> was heavily influenced by pass(1) in the design, but it's a
457 complete different implementation with different tools involved.
459 =head1 AUTHORS
461 The B<plass> utility was written by Omar Polo <I<op@omarpolo.com>>.
463 =head1 CAVEATS
465 B<plass> B<find> output format isn't designed to handle files with
466 newlines in them. Use find(1) B<-print0> or similar if it's a
467 concern.
469 There isn't a B<init> sub-command, the store initialisation must be
470 performed manually.
472 =cut