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 oneshot => [\&cmd_oneshot, "[-c chars] [-l length]"],
55 regen => [\&cmd_regen, "[-c chars] [-l length] entry"],
56 rm => [\&cmd_rm, "entry"],
57 tee => [\&cmd_tee, "[-q] entry"],
58 tog => [\&cmd_tog, "arguments ..."],
59 );
60 pod2usage(1) unless defined $subcmd{$cmd};
61 my ($fn, $usage) = @{$subcmd{$cmd}};
62 chdir $store;
63 $fn->();
64 exit 0;
67 # utils
69 sub usage {
70 my $prog = basename $0;
71 say STDERR "Usage: $prog $cmd $usage";
72 exit 1;
73 }
75 sub name2file {
76 my $f = shift;
77 $f .= ".gpg" unless $f =~ m,\.gpg$,;
78 return $f;
79 }
81 # tr -cd -- $chars < /dev/random | dd bs=$len count=1 status=none
82 sub gen {
83 my ($chars, $length) = @_;
84 my $pass = "";
86 open(my $fh, '<:raw', '/dev/random')
87 or die "can't open /dev/random: $!";
88 my $l = $length;
89 while ($l gt 0) {
90 read($fh, my $t, $length * 4)
91 or die "failed to read /dev/random: $!";
92 $t =~ s/[^$chars]//g;
93 $l -= length($t);
94 $pass .= $t;
95 }
96 close($fh);
98 return substr($pass, 0, $length);
99 }
101 sub readpass {
102 # todo some stty black magic to avoid echo
103 print shift if -t;
104 my $pass = <>;
105 die "failed to read stdin: $!" unless defined($pass);
106 chomp $pass;
107 return $pass;
110 sub mkdirs {
111 my $dir = shift;
112 my $parent = dirname $dir;
113 mkdirs($parent) unless -d $parent || $parent eq '/';
114 mkdir $dir or die "mkdir $dir: $!"
115 unless -d $dir;
118 sub writepass {
119 my ($file, $pass) = @_;
121 mkdirs(dirname $file);
123 my @args = ($gpg, @gpg_flags, '-e', '-r', recipient(),
124 '-o', $file);
125 open my $fh, '|-', @args;
126 say $fh "$pass";
127 close($fh);
130 sub recipient {
131 open my $fh, '<', "$store/.gpg-id"
132 or die "can't open recipient file";
133 my $r = <$fh>;
134 chomp $r;
135 close($fh);
136 return $r;
139 sub passfind {
140 my $pattern = shift;
141 my @entries;
143 find({
144 wanted => sub {
145 if (m,/.git$, || m,/.got$,) {
146 $File::Find::prune = 1;
147 return;
150 return if defined($pattern) && ! m/$pattern/;
151 return unless -f && m,.gpg$,;
153 s,^$store/*,,;
154 s,.gpg$,,;
155 push @entries, $_;
156 },
157 no_chdir => 1,
158 follow_fast => 1,
159 }, ($store));
160 return sort(@entries);
163 sub got {
164 # discard stdout
165 open my $fh, '-|', ($got, @_);
166 close($fh);
167 exit 1 if $?;
170 sub got_add {
171 got 'add', '-I', shift;
174 sub got_rm {
175 got 'remove', '-f', shift;
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;
219 GetOptions(
220 'c=s' => sub { $chars = $_[1] },
221 'h|?' => \&usage,
222 'l=i' => sub { $length = $_[1] },
223 ) or usage;
224 usage if @ARGV ne 1;
226 my $name = shift @ARGV;
227 my $file = name2file $name;
228 die "password already exists: $file\n" if -e $file;
230 my $pass = gen($chars, $length);
231 writepass($file, $pass);
233 got_add $file;
234 got_ci "+$name";
237 sub cmd_got {
238 chdir $store;
239 exec $got, @ARGV;
242 # TODO: handle moving directories?
243 sub cmd_mv {
244 GetOptions('h|?' => \&usage) or usage;
245 usage if @ARGV ne 2;
247 my $a = shift @ARGV;
248 my $b = shift @ARGV;
250 my $pa = name2file $a;
251 my $pb = name2file $b;
253 die "source password doesn't exist" unless -f $pa;
254 die "target password exists" if -f $pb;
256 rename $pa, $pb or die "can't rename $a to $b: $!";
258 got_rm $pa;
259 got_add $pb;
260 got_ci "mv $a $b";
263 sub cmd_oneshot {
264 my $chars = $default_chars;
265 my $length = $default_length;
267 GetOptions(
268 'c=s' => sub { $chars = $_[1] },
269 'h|?' => \&usage,
270 'l=i' => sub { $length = $_[1] },
271 ) or usage;
272 usage if @ARGV ne 0;
274 say gen($chars, $length);
277 sub cmd_regen {
278 my $chars = $default_chars;
279 my $length = $default_length;
281 GetOptions(
282 'c=s' => sub { $chars = $_[1] },
283 'h|?' => \&usage,
284 'l=i' => sub { $length = $_[1] },
285 ) or usage;
286 usage if @ARGV ne 1;
288 my $name = shift @ARGV;
289 my $file = name2file $name;
290 die "password doesn't exist" unless -f $file;
292 my $pass = gen($chars, $length);
293 writepass($file, $pass);
295 got_add $file;
296 got_ci "regen $name";
299 sub cmd_rm {
300 GetOptions('h|?' => \&usage) or usage;
301 usage if @ARGV ne 1;
303 my $name = shift @ARGV;
304 my $file = name2file $name;
306 got_rm $file;
307 got_ci "-$name";
310 sub cmd_tee {
311 my $q;
312 GetOptions(
313 'h|?' => \&usage,
314 'q' => \$q,
315 ) or usage;
316 usage if @ARGV ne 1;
318 my $name = shift @ARGV;
319 my $file = name2file $name;
321 my $pass = readpass "Enter the password: ";
322 writepass($file, $pass);
323 say $pass unless $q;
325 got_add $file;
326 got_ci (-f $file ? "update $name" : "+$name");
329 sub cmd_tog {
330 chdir $store;
331 exec $tog, @ARGV;
334 __END__
336 =head1 NAME
338 B<plass> - manage passwords
340 =head1 SYNOPSIS
342 B<plass> I<command> [-h] [arg ...]
344 Valid subcommands are: cat, find, gen, got, mv, oneshot, regen, rm,
345 tee, tog.
347 =head1 DESCRIPTION
349 B<plass> is a simple password manager. It manages passwords stored in
350 a directory tree rooted at I<~/.password-store> (or I<$PLASS_STORE>),
351 where every password is a single file encrypted with gpg2(1).
353 Passwords entries can be referenced using the path relative to the
354 store directory. The extension ".gpg" is optional.
356 The whole store is supposed to be managed by the got(1) version
357 control system.
359 The commands for B<plass> are as follows:
361 =over
363 =item B<cat> I<entries ...>
365 Decrypt and print the passwords of the given I<entries>.
367 =item B<find> [I<pattern>]
369 Print one per line all the entries of the store, optionally filtered
370 by the given I<pattern>.
372 =item B<gen> [B<-c> I<chars>] [B<-l> I<length>] I<entry>
374 Generate and persist a password for the given I<entry> in the store.
375 B<-c> can be used to control the characters allowed in the password
376 (by default I<!-~> i.e. all the printable ASCII character) and B<-l>
377 the length (32 by default.)
379 =item B<got> I<arguments ...>
381 Execute got(1) in the password store directory with the given
382 I<arguments>.
384 =item B<mv> I<from> I<to>
386 Rename a password entry, doesn't work with directories. I<from> must
387 exist and I<to> mustn't.
389 =item B<oneshot> [B<-c> I<chars>] [B<-l> I<length>]
391 Like B<gen> but prints the the generated password instead of persist
392 it.
394 =item B<regen> [B<-c> I<chars>] [B<-l> I<length>] I<entry>
396 Like B<gen> but re-generates a password in-place.
398 =item B<rm> I<entry>
400 Remove the password I<entry> from the store.
402 =item B<tee> [B<-q>] I<entry>
404 Prompt for a password, persist it in the store under the given
405 I<entry> name and then print it again to standard output.
407 =item B<tog> I<arguments ...>
409 Execute tog(1) in the password store directory with the given
410 I<arguments>.
412 =back
414 =head1 CREATING A PASSWORD STORE
416 A password store is just a normal got(1) repository with a worktree
417 checked out in I<~/.password-store> (or I<$PLASS_STORE>). The only
418 restriction is that a file called I<.gpg-id> must exist in the root of
419 the work tree for most B<plass> commands to work.
421 For example, a got repository and password store can be created as
422 follows:
424 $ mkdir .password-store
425 $ cd .password-store
426 $ echo foo@example.com > .gpg-id
427 $ cd ~/git
428 $ got init pass.git
429 $ cd pass.git
430 $ got import -m 'initial import' ~/.password-store
431 $ cd ~/.password-store
432 $ got checkout -E ~/git/pass.git
434 See got(1) for more information.
436 Otherwise, if a repository already exists, a password-store can be
437 checked out more simply as:
439 $ got checkout ~/git/pass.git ~/.password-store
441 To migrate from pass(1), just delete I<~/.password-store> and checkout
442 it again usign got(1).
444 =head1 ENVIRONMENT
446 =over
448 =item PLASS_CHARS
450 Default range of characters to use to generate passwords.
452 =item PLASS_GOT
454 Path to the got(1) executable.
456 =item PLASS_GPG
458 Path to the gpg2(1) executable.
460 =item PLASS_LENGTH
462 Default length for the passwords generated.
464 =item PLASS_STORE
466 Path to the password-store directory tree. I<~/.password-store> by
467 default.
469 =item PLASS_TOG
471 Path to the tog(1) executable.
473 =back
475 =head1 FILES
477 =over
479 =item I<~/.password-store>
481 Password store used by default.
483 =item I<~/.password-store/.gpg-id>
485 File containing the gpg recipient used to encrypt the passwords.
487 =back
489 =head1 ACKNOWLEDGEMENTS
491 B<plass> was heavily influenced by pass(1) in the design, but it's a
492 complete different implementation with different tools involved.
494 =head1 AUTHORS
496 The B<plass> utility was written by Omar Polo <I<op@omarpolo.com>>.
498 =head1 CAVEATS
500 B<plass> B<find> output format isn't designed to handle files with
501 newlines in them. Use find(1) B<-print0> or similar if it's a
502 concern.
504 There isn't a B<init> sub-command, the store initialisation must be
505 performed manually.
507 =cut