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 tog => [\&cmd_tog, "arguments ..."],
58 write => [\&cmd_write, "entry"],
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 system ($got, 'commit', '-m', shift);
181 die "failed to commit changes: $!" if $? == -1;
185 # cmds
187 sub cmd_cat {
188 GetOptions('h|?' => \&usage) or usage;
189 usage unless @ARGV;
191 while (@ARGV) {
192 my $file = name2file(shift @ARGV);
193 system ($gpg, @gpg_flags, '-d', $file);
194 die "failed to exec $gpg: $!" if $? == -1;
198 sub cmd_find {
199 GetOptions('h|?' => \&usage) or usage;
200 usage if @ARGV gt 1;
202 map { say $_ } passfind(shift @ARGV);
205 sub cmd_gen {
206 my $chars = $default_chars;
207 my $length = $default_length;
209 GetOptions(
210 'c=s' => sub { $chars = $_[1] },
211 'h|?' => \&usage,
212 'l=i' => sub { $length = $_[1] },
213 ) or usage;
214 usage if @ARGV ne 1;
216 my $name = shift @ARGV;
217 my $file = name2file $name;
218 die "password already exists: $file\n" if -e $file;
220 my $pass = gen($chars, $length);
221 writepass($file, $pass);
223 got_add $file;
224 got_ci "+$name";
227 sub cmd_got {
228 chdir $store;
229 exec $got, @ARGV;
232 # TODO: handle moving directories?
233 sub cmd_mv {
234 GetOptions('h|?' => \&usage) or usage;
235 usage if @ARGV ne 2;
237 my $a = shift @ARGV;
238 my $b = shift @ARGV;
240 my $pa = name2file $a;
241 my $pb = name2file $b;
243 die "source password doesn't exist" unless -f $pa;
244 die "target password exists" if -f $pb;
246 rename $pa, $pb or die "can't rename $a to $b: $!";
248 got_rm $pa;
249 got_add $pb;
250 got_ci "mv $a $b";
253 sub cmd_oneshot {
254 my $chars = $default_chars;
255 my $length = $default_length;
257 GetOptions(
258 'c=s' => sub { $chars = $_[1] },
259 'h|?' => \&usage,
260 'l=i' => sub { $length = $_[1] },
261 ) or usage;
262 usage if @ARGV ne 0;
264 say gen($chars, $length);
267 sub cmd_regen {
268 my $chars = $default_chars;
269 my $length = $default_length;
271 GetOptions(
272 'c=s' => sub { $chars = $_[1] },
273 'h|?' => \&usage,
274 'l=i' => sub { $length = $_[1] },
275 ) or usage;
276 usage if @ARGV ne 1;
278 my $name = shift @ARGV;
279 my $file = name2file $name;
280 die "password doesn't exist" unless -f $file;
282 my $pass = gen($chars, $length);
283 writepass($file, $pass);
285 got_add $file;
286 got_ci "regen $name";
289 sub cmd_rm {
290 GetOptions('h|?' => \&usage) or usage;
291 usage if @ARGV ne 1;
293 my $name = shift @ARGV;
294 my $file = name2file $name;
296 got_rm $file;
297 got_ci "-$name";
300 sub cmd_tog {
301 chdir $store;
302 exec $tog, @ARGV;
305 sub cmd_write {
306 GetOptions('h|?' => \&usage) or usage;
307 usage if @ARGV ne 1;
309 my $name = shift @ARGV;
310 my $file = name2file $name;
312 my $pass = readpass "Enter the password: ";
313 writepass($file, $pass);
315 got_add $file;
316 got_ci "+$name";
319 __END__
321 =head1 NAME
323 B<plass> - manage passwords
325 =head1 SYNOPSIS
327 B<plass> I<command> [-h] [arg ...]
329 Valid subcommands are: cat, find, gen, got, mv, oneshot, regen, rm,
330 tog, write.
332 =head1 DESCRIPTION
334 B<plass> is a simple password manager. It manages passwords stored in
335 a directory tree rooted at I<~/.password-store> (or I<$PLASS_STORE>),
336 where every password is a single file encrypted with gpg2(1).
338 Passwords entries can be referenced using the path relative to the
339 store directory. The extension ".gpg" is optional.
341 The whole store is supposed to be managed by the got(1) version
342 control system.
344 The commands for B<plass> are as follows:
346 =over
348 =item B<cat> I<entries ...>
350 Decrypt and print the passwords of the given I<entries>.
352 =item B<find> [I<pattern>]
354 Print one per line all the entries of the store, optionally filtered
355 by the given I<pattern>.
357 =item B<gen> [B<-c> I<chars>] [B<-l> I<length>] I<entry>
359 Generate and persist a password for the given I<entry> in the store.
360 B<-c> can be used to control the characters allowed in the password
361 (by default I<!-~> i.e. all the printable ASCII character) and B<-l>
362 the length (32 by default.)
364 =item B<got> I<arguments ...>
366 Execute got(1) in the password store directory with the given
367 I<arguments>.
369 =item B<mv> I<from> I<to>
371 Rename a password entry, doesn't work with directories. I<from> must
372 exist and I<to> mustn't.
374 =item B<oneshot> [B<-c> I<chars>] [B<-l> I<length>]
376 Like B<gen> but prints the the generated password instead of persist
377 it.
379 =item B<regen> [B<-c> I<chars>] [B<-l> I<length>] I<entry>
381 Like B<gen> but re-generates a password in-place.
383 =item B<rm> I<entry>
385 Remove the password I<entry> from the store.
387 =item B<tog> I<arguments ...>
389 Execute tog(1) in the password store directory with the given
390 I<arguments>.
392 =item B<write> I<entry>
394 Prompt for a password and persist it in the store under the given
395 I<entry> name.
397 =back
399 =head1 CREATING A PASSWORD STORE
401 A password store is just a normal got(1) repository with a worktree
402 checked out in I<~/.password-store> (or I<$PLASS_STORE>). The only
403 restriction is that a file called I<.gpg-id> must exist in the root of
404 the work tree for most B<plass> commands to work.
406 For example, a got repository and password store can be created as
407 follows:
409 $ mkdir .password-store
410 $ cd .password-store
411 $ echo foo@example.com > .gpg-id
412 $ cd ~/git
413 $ got init pass.git
414 $ cd pass.git
415 $ got import -m 'initial import' ~/.password-store
416 $ cd ~/.password-store
417 $ got checkout -E ~/git/pass.git
419 See got(1) for more information.
421 Otherwise, if a repository already exists, a password-store can be
422 checked out more simply as:
424 $ got checkout ~/git/pass.git ~/.password-store
426 To migrate from pass(1), just delete I<~/.password-store> and checkout
427 it again usign got(1).
429 =head1 ENVIRONMENT
431 =over
433 =item PLASS_CHARS
435 Default range of characters to use to generate passwords.
437 =item PLASS_GOT
439 Path to the got(1) executable.
441 =item PLASS_GPG
443 Path to the gpg2(1) executable.
445 =item PLASS_LENGTH
447 Default length for the passwords generated.
449 =item PLASS_STORE
451 Path to the password-store directory tree. I<~/.password-store> by
452 default.
454 =item PLASS_TOG
456 Path to the tog(1) executable.
458 =back
460 =head1 FILES
462 =over
464 =item I<~/.password-store>
466 Password store used by default.
468 =item I<~/.password-store/.gpg-id>
470 File containing the gpg recipient used to encrypt the passwords.
472 =back
474 =head1 ACKNOWLEDGEMENTS
476 B<plass> was heavily influenced by pass(1) in the design, but it's a
477 complete different implementation with different tools involved.
479 =head1 AUTHORS
481 The B<plass> utility was written by Omar Polo <I<op@omarpolo.com>>.
483 =head1 CAVEATS
485 B<plass> B<find> output format isn't designed to handle files with
486 newlines in them. Use find(1) B<-print0> or similar if it's a
487 concern.
489 There isn't a B<init> sub-command, the store initialisation must be
490 performed manually.
492 =cut