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 exit 1 if $?;
169 sub got_add {
170 got 'add', '-I', shift;
173 sub got_rm {
174 got 'remove', '-f', shift;
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 die "password already exists: $file\n" if -e $file;
233 my $pass = gen($chars, $length);
235 unless ($nop) {
236 writepass($file, $pass);
237 got_add $file;
238 got_ci "+$name";
240 say $pass unless $q;
243 sub cmd_got {
244 chdir $store;
245 exec $got, @ARGV;
248 # TODO: handle moving directories?
249 sub cmd_mv {
250 GetOptions('h|?' => \&usage) or usage;
251 usage if @ARGV ne 2;
253 my $a = shift @ARGV;
254 my $b = shift @ARGV;
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: $!";
264 got_rm $pa;
265 got_add $pb;
266 got_ci "mv $a $b";
269 sub cmd_regen {
270 my $chars = $default_chars;
271 my $length = $default_length;
273 GetOptions(
274 'c=s' => sub { $chars = $_[1] },
275 'h|?' => \&usage,
276 'l=i' => sub { $length = $_[1] },
277 ) or usage;
278 usage if @ARGV ne 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);
287 got_add $file;
288 got_ci "regen $name";
291 sub cmd_rm {
292 GetOptions('h|?' => \&usage) or usage;
293 usage if @ARGV ne 1;
295 my $name = shift @ARGV;
296 my $file = name2file $name;
298 got_rm $file;
299 got_ci "-$name";
302 sub cmd_tee {
303 my $q;
304 GetOptions(
305 'h|?' => \&usage,
306 'q' => \$q,
307 ) or usage;
308 usage if @ARGV ne 1;
310 my $name = shift @ARGV;
311 my $file = name2file $name;
313 my $pass = readpass "Enter the password: ";
314 writepass($file, $pass);
315 say $pass unless $q;
317 got_add $file;
318 got_ci (-f $file ? "update $name" : "+$name");
321 sub cmd_tog {
322 chdir $store;
323 exec $tog, @ARGV;
326 __END__
328 =head1 NAME
330 B<plass> - manage passwords
332 =head1 SYNOPSIS
334 B<plass> I<command> [-h] [arg ...]
336 Valid subcommands are: cat, find, gen, got, mv, regen, rm, tee, tog.
338 =head1 DESCRIPTION
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
348 control system.
350 The commands for B<plass> are as follows:
352 =over
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
377 I<arguments>.
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.
388 =item B<rm> I<entry>
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
400 I<arguments>.
402 =back
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
412 follows:
414 $ mkdir .password-store
415 $ cd .password-store
416 $ echo foo@example.com > .gpg-id
417 $ cd ~/git
418 $ got init pass.git
419 $ cd pass.git
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).
434 =head1 ENVIRONMENT
436 =over
438 =item PLASS_CHARS
440 Default range of characters to use to generate passwords.
442 =item PLASS_GOT
444 Path to the got(1) executable.
446 =item PLASS_GPG
448 Path to the gpg2(1) executable.
450 =item PLASS_LENGTH
452 Default length for the passwords generated.
454 =item PLASS_STORE
456 Path to the password-store directory tree. I<~/.password-store> by
457 default.
459 =item PLASS_TOG
461 Path to the tog(1) executable.
463 =back
465 =head1 FILES
467 =over
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.
477 =back
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.
484 =head1 AUTHORS
486 The B<plass> utility was written by Omar Polo <I<op@omarpolo.com>>.
488 =head1 CAVEATS
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
492 concern.
494 There isn't a B<init> sub-command, the store initialisation must be
495 performed manually.
497 =cut