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 open my $fh, '-|', ($got, @_);
165 close($fh);
166 return !$?;
169 sub got_add {
170 got 'add', '-I', shift
171 or exit 1;
174 sub got_rm {
175 got 'remove', '-f', shift
176 or exit 1;
179 sub got_ci {
180 my $pid = fork;
181 die "failed to fork: $!" unless defined $pid;
183 if ($pid ne 0) {
184 wait;
185 die "failed to commit changes" if $?;
186 return;
189 open (STDOUT, ">&", \*STDERR)
190 or die "can't redirect stdout to stderr";
191 exec ($got, 'commit', '-m', shift)
192 or die "failed to exec $got: $!";
196 # cmds
198 sub cmd_cat {
199 GetOptions('h|?' => \&usage) or usage;
200 usage unless @ARGV;
202 while (@ARGV) {
203 my $file = name2file(shift @ARGV);
204 system ($gpg, @gpg_flags, '-d', $file);
205 die "failed to exec $gpg: $!" if $? == -1;
209 sub cmd_find {
210 GetOptions('h|?' => \&usage) or usage;
211 usage if @ARGV gt 1;
213 map { say $_ } passfind(shift @ARGV);
216 sub cmd_gen {
217 my $chars = $default_chars;
218 my $length = $default_length;
220 GetOptions(
221 'c=s' => sub { $chars = $_[1] },
222 'h|?' => \&usage,
223 'l=i' => sub { $length = $_[1] },
224 ) or usage;
225 usage if @ARGV ne 1;
227 my $name = shift @ARGV;
228 my $file = name2file $name;
229 die "password already exists: $file\n" if -e $file;
231 my $pass = gen($chars, $length);
232 writepass($file, $pass);
234 got_add $file;
235 got_ci "+$name";
238 sub cmd_got {
239 chdir $store;
240 exec $got, @ARGV;
243 # TODO: handle moving directories?
244 sub cmd_mv {
245 GetOptions('h|?' => \&usage) or usage;
246 usage if @ARGV ne 2;
248 my $a = shift @ARGV;
249 my $b = shift @ARGV;
251 my $pa = name2file $a;
252 my $pb = name2file $b;
254 die "source password doesn't exist" unless -f $pa;
255 die "target password exists" if -f $pb;
257 rename $pa, $pb or die "can't rename $a to $b: $!";
259 got_rm $pa;
260 got_add $pb;
261 got_ci "mv $a $b";
264 sub cmd_oneshot {
265 my $chars = $default_chars;
266 my $length = $default_length;
268 GetOptions(
269 'c=s' => sub { $chars = $_[1] },
270 'h|?' => \&usage,
271 'l=i' => sub { $length = $_[1] },
272 ) or usage;
273 usage if @ARGV ne 0;
275 say gen($chars, $length);
278 sub cmd_regen {
279 my $chars = $default_chars;
280 my $length = $default_length;
282 GetOptions(
283 'c=s' => sub { $chars = $_[1] },
284 'h|?' => \&usage,
285 'l=i' => sub { $length = $_[1] },
286 ) or usage;
287 usage if @ARGV ne 1;
289 my $name = shift @ARGV;
290 my $file = name2file $name;
291 die "password doesn't exist" unless -f $file;
293 my $pass = gen($chars, $length);
294 writepass($file, $pass);
296 got_add $file;
297 got_ci "regen $name";
300 sub cmd_rm {
301 GetOptions('h|?' => \&usage) or usage;
302 usage if @ARGV ne 1;
304 my $name = shift @ARGV;
305 my $file = name2file $name;
307 got_rm $file;
308 got_ci "-$name";
311 sub cmd_tee {
312 my $q;
313 GetOptions(
314 'h|?' => \&usage,
315 'q' => \$q,
316 ) or usage;
317 usage if @ARGV ne 1;
319 my $name = shift @ARGV;
320 my $file = name2file $name;
322 my $pass = readpass "Enter the password: ";
323 writepass($file, $pass);
324 say $pass unless $q;
326 got_add $file;
327 got_ci (-f $file ? "update $name" : "+$name");
330 sub cmd_tog {
331 chdir $store;
332 exec $tog, @ARGV;
335 __END__
337 =head1 NAME
339 B<plass> - manage passwords
341 =head1 SYNOPSIS
343 B<plass> I<command> [-h] [arg ...]
345 Valid subcommands are: cat, find, gen, got, mv, oneshot, regen, rm,
346 tee, tog.
348 =head1 DESCRIPTION
350 B<plass> is a simple password manager. It manages passwords stored in
351 a directory tree rooted at I<~/.password-store> (or I<$PLASS_STORE>),
352 where every password is a single file encrypted with gpg2(1).
354 Passwords entries can be referenced using the path relative to the
355 store directory. The extension ".gpg" is optional.
357 The whole store is supposed to be managed by the got(1) version
358 control system.
360 The commands for B<plass> are as follows:
362 =over
364 =item B<cat> I<entries ...>
366 Decrypt and print the passwords of the given I<entries>.
368 =item B<find> [I<pattern>]
370 Print one per line all the entries of the store, optionally filtered
371 by the given I<pattern>.
373 =item B<gen> [B<-c> I<chars>] [B<-l> I<length>] I<entry>
375 Generate and persist a password for the given I<entry> in the store.
376 B<-c> can be used to control the characters allowed in the password
377 (by default I<!-~> i.e. all the printable ASCII character) and B<-l>
378 the length (32 by default.)
380 =item B<got> I<arguments ...>
382 Execute got(1) in the password store directory with the given
383 I<arguments>.
385 =item B<mv> I<from> I<to>
387 Rename a password entry, doesn't work with directories. I<from> must
388 exist and I<to> mustn't.
390 =item B<oneshot> [B<-c> I<chars>] [B<-l> I<length>]
392 Like B<gen> but prints the the generated password instead of persist
393 it.
395 =item B<regen> [B<-c> I<chars>] [B<-l> I<length>] I<entry>
397 Like B<gen> but re-generates a password in-place.
399 =item B<rm> I<entry>
401 Remove the password I<entry> from the store.
403 =item B<tee> [B<-q>] I<entry>
405 Prompt for a password, persist it in the store under the given
406 I<entry> name and then print it again to standard output.
408 =item B<tog> I<arguments ...>
410 Execute tog(1) in the password store directory with the given
411 I<arguments>.
413 =back
415 =head1 CREATING A PASSWORD STORE
417 A password store is just a normal got(1) repository with a worktree
418 checked out in I<~/.password-store> (or I<$PLASS_STORE>). The only
419 restriction is that a file called I<.gpg-id> must exist in the root of
420 the work tree for most B<plass> commands to work.
422 For example, a got repository and password store can be created as
423 follows:
425 $ mkdir .password-store
426 $ cd .password-store
427 $ echo foo@example.com > .gpg-id
428 $ cd ~/git
429 $ got init pass.git
430 $ cd pass.git
431 $ got import -m 'initial import' ~/.password-store
432 $ cd ~/.password-store
433 $ got checkout -E ~/git/pass.git
435 See got(1) for more information.
437 Otherwise, if a repository already exists, a password-store can be
438 checked out more simply as:
440 $ got checkout ~/git/pass.git ~/.password-store
442 To migrate from pass(1), just delete I<~/.password-store> and checkout
443 it again usign got(1).
445 =head1 ENVIRONMENT
447 =over
449 =item PLASS_CHARS
451 Default range of characters to use to generate passwords.
453 =item PLASS_GOT
455 Path to the got(1) executable.
457 =item PLASS_GPG
459 Path to the gpg2(1) executable.
461 =item PLASS_LENGTH
463 Default length for the passwords generated.
465 =item PLASS_STORE
467 Path to the password-store directory tree. I<~/.password-store> by
468 default.
470 =item PLASS_TOG
472 Path to the tog(1) executable.
474 =back
476 =head1 FILES
478 =over
480 =item I<~/.password-store>
482 Password store used by default.
484 =item I<~/.password-store/.gpg-id>
486 File containing the gpg recipient used to encrypt the passwords.
488 =back
490 =head1 ACKNOWLEDGEMENTS
492 B<plass> was heavily influenced by pass(1) in the design, but it's a
493 complete different implementation with different tools involved.
495 =head1 AUTHORS
497 The B<plass> utility was written by Omar Polo <I<op@omarpolo.com>>.
499 =head1 CAVEATS
501 B<plass> B<find> output format isn't designed to handle files with
502 newlines in them. Use find(1) B<-print0> or similar if it's a
503 concern.
505 There isn't a B<init> sub-command, the store initialisation must be
506 performed manually.
508 =cut