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 writepass {
111 my ($file, $pass) = @_;
113 my @args = ($gpg, @gpg_flags, '-e', '-r', recipient(),
114 '-o', $file);
115 open my $fh, '|-', @args;
116 say $fh "$pass";
117 close($fh);
120 sub recipient {
121 open my $fh, '<', "$store/.gpg-id"
122 or die "can't open recipient file";
123 my $r = <$fh>;
124 chomp $r;
125 close($fh);
126 return $r;
129 sub passfind {
130 my $pattern = shift;
131 my @entries;
133 find({
134 wanted => sub {
135 if (m,/.git$, || m,/.got$,) {
136 $File::Find::prune = 1;
137 return;
140 return if defined($pattern) && ! m/$pattern/;
141 return unless -f && m,.gpg$,;
143 s,^$store/*,,;
144 s,.gpg$,,;
145 push @entries, $_;
146 },
147 no_chdir => 1,
148 follow_fast => 1,
149 }, ($store));
150 return sort(@entries);
153 sub got {
154 open my $fh, '-|', ($got, @_);
155 close($fh);
156 die "\"@_\" failed" if $? != 0;
159 sub got_add {
160 got 'add', '-I', shift;
163 sub got_rm {
164 got 'remove', '-f', shift;
167 sub got_ci {
168 system ($got, 'commit', '-m', shift);
169 die "failed to commit changes: $!" if $? == -1;
173 # cmds
175 sub cmd_cat {
176 GetOptions('h|?' => \&usage) or usage;
177 usage unless @ARGV;
179 while (@ARGV) {
180 my $file = name2file(shift @ARGV);
181 system ($gpg, @gpg_flags, '-d', $file);
182 die "failed to exec $gpg: $!" if $? == -1;
186 sub cmd_find {
187 GetOptions('h|?' => \&usage) or usage;
188 usage if @ARGV gt 1;
190 map { say $_ } passfind(shift @ARGV);
193 sub cmd_gen {
194 my $chars = $default_chars;
195 my $length = $default_length;
197 GetOptions(
198 'c=s' => sub { $chars = $_[1] },
199 'h|?' => \&usage,
200 'l=i' => sub { $length = $_[1] },
201 ) or usage;
202 usage if @ARGV ne 1;
204 my $name = shift @ARGV;
205 my $file = name2file $name;
206 die "password already exists: $file\n" if -e $file;
208 my $pass = gen($chars, $length);
209 writepass($file, $pass);
211 got_add $file;
212 got_ci "+$name";
215 sub cmd_got {
216 chdir $store;
217 exec $got, @ARGV;
220 # TODO: handle moving directories?
221 sub cmd_mv {
222 GetOptions('h|?' => \&usage) or usage;
223 usage if @ARGV ne 2;
225 my $a = shift @ARGV;
226 my $b = shift @ARGV;
228 my $pa = name2file $a;
229 my $pb = name2file $b;
231 die "source password doesn't exist" unless -f $pa;
232 die "target password exists" if -f $pb;
234 rename $pa, $pb or die "can't rename $a to $b: $!";
236 got_rm $pa;
237 got_add $pb;
238 got_ci "mv $a $b";
241 sub cmd_oneshot {
242 my $chars = $default_chars;
243 my $length = $default_length;
245 GetOptions(
246 'c=s' => sub { $chars = $_[1] },
247 'h|?' => \&usage,
248 'l=i' => sub { $length = $_[1] },
249 ) or usage;
250 usage if @ARGV ne 0;
252 say gen($chars, $length);
255 sub cmd_regen {
256 my $chars = $default_chars;
257 my $length = $default_length;
259 GetOptions(
260 'c=s' => sub { $chars = $_[1] },
261 'h|?' => \&usage,
262 'l=i' => sub { $length = $_[1] },
263 ) or usage;
264 usage if @ARGV ne 1;
266 my $name = shift @ARGV;
267 my $file = name2file $name;
268 die "password doesn't exist" unless -f $file;
270 my $pass = gen($chars, $length);
271 writepass($file, $pass);
273 got_add $file;
274 got_ci "regen $name";
277 sub cmd_rm {
278 GetOptions('h|?' => \&usage) or usage;
279 usage if @ARGV ne 1;
281 my $name = shift @ARGV;
282 my $file = name2file $name;
284 got_rm $file;
285 got_ci "-$name";
288 sub cmd_tog {
289 chdir $store;
290 exec $tog, @ARGV;
293 sub cmd_write {
294 GetOptions('h|?' => \&usage) or usage;
295 usage if @ARGV ne 1;
297 my $name = shift @ARGV;
298 my $file = name2file $name;
300 my $p1 = readpass "Enter the password: ";
301 my $p2 = readpass "Retype the password: ";
302 die "Passwords don't match\n" if $p1 ne $p2;
304 writepass($file, $p1);
306 got_add $file;
307 got_ci "+$name";
310 __END__
312 =head1 NAME
314 B<plass> - manage passwords
316 =head1 SYNOPSIS
318 B<plass> I<command> [-h] [arg ...]
320 Valid subcommands are: cat, find, gen, got, mv, oneshot, regen, rm,
321 tog, write.
323 =head1 DESCRIPTION
325 B<plass> is a simple password manager. It manages passwords stored in
326 a directory tree rooted at I<~/.password-store> (or I<$PLASS_STORE>),
327 where every password is a single file encrypted with gpg2(1).
329 Passwords entries can be referenced using the path relative to the
330 store directory. The extension ".gpg" is optional.
332 The whole store is supposed to be managed by the got(1) version
333 control system.
335 The commands for B<plass> are as follows:
337 =over
339 =item B<cat> I<entries ...>
341 Decrypt and print the passwords of the given I<entries>.
343 =item B<find> [I<pattern>]
345 Print one per line all the entries of the store, optionally filtered
346 by the given I<pattern>.
348 =item B<gen> [B<-c> I<chars>] [B<-l> I<length>] I<entry>
350 Generate and persist a password for the given I<entry> in the store.
351 B<-c> can be used to control the characters allowed in the password
352 (by default I<!-~> i.e. all the printable ASCII character) and B<-l>
353 the length (32 by default.)
355 =item B<got> I<arguments ...>
357 Execute got(1) in the password store directory with the given
358 I<arguments>.
360 =item B<mv> I<from> I<to>
362 Rename a password entry, doesn't work with directories. I<from> must
363 exist and I<to> mustn't.
365 =item B<oneshot> [B<-c> I<chars>] [B<-l> I<length>]
367 Like B<gen> but prints the the generated password instead of persist
368 it.
370 =item B<regen> [B<-c> I<chars>] [B<-l> I<length>] I<entry>
372 Like B<gen> but re-generates a password in-place.
374 =item B<rm> I<entry>
376 Remove the password I<entry> from the store.
378 =item B<tog> I<arguments ...>
380 Execute tog(1) in the password store directory with the given
381 I<arguments>.
383 =item B<write> I<entry>
385 Prompt for a password and persist it in the store under the given
386 I<entry> name.
388 =back
390 =head1 CREATING A PASSWORD STORE
392 A password store is just a normal got(1) repository with a worktree
393 checked out in I<~/.password-store> (or I<$PLASS_STORE>). The only
394 restriction is that a file called I<.gpg-id> must exist in the root of
395 the work tree for most B<plass> commands to work.
397 For example, a got repository and password store can be created as
398 follows:
400 $ mkdir .password-store
401 $ cd .password-store
402 $ echo foo@example.com > .gpg-id
403 $ cd ~/git
404 $ got init pass.git
405 $ cd pass.git
406 $ got import -m 'initial import' ~/.password-store
407 $ cd ~/.password-store
408 $ got checkout -E ~/git/pass.git
410 See got(1) for more information.
412 Otherwise, if a repository already exists, a password-store can be
413 checked out more simply as:
415 $ got checkout ~/git/pass.git ~/.password-store
417 To migrate from pass(1), just delete I<~/.password-store> and checkout
418 it again usign got(1).
420 =head1 ENVIRONMENT
422 =over
424 =item PLASS_CHARS
426 Default range of characters to use to generate passwords.
428 =item PLASS_GOT
430 Path to the got(1) executable.
432 =item PLASS_GPG
434 Path to the gpg2(1) executable.
436 =item PLASS_LENGTH
438 Default length for the passwords generated.
440 =item PLASS_STORE
442 Path to the password-store directory tree. I<~/.password-store> by
443 default.
445 =item PLASS_TOG
447 Path to the tog(1) executable.
449 =back
451 =head1 FILES
453 =over
455 =item I<~/.password-store>
457 Password store used by default.
459 =item I<~/.password-store/.gpg-id>
461 File containing the gpg recipient used to encrypt the passwords.
463 =back
465 =head1 ACKNOWLEDGEMENTS
467 B<plass> was heavily influenced by pass(1) in the design, but it's a
468 complete different implementation with different tools involved.
470 =head1 AUTHORS
472 The B<plass> utility was written by Omar Polo <I<op@omarpolo.com>>.
474 =head1 CAVEATS
476 B<plass> B<find> output format isn't designed to handle files with
477 newlines in them. Use find(1) B<-print0> or similar if it's a
478 concern.
480 There isn't a B<init> sub-command, the store initialisation must be
481 performed manually.
483 =cut