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 die "\"@_\" failed" if $? != 0;
169 sub got_add {
170 got 'add', '-I', shift;
173 sub got_rm {
174 got 'remove', '-f', shift;
177 sub got_ci {
178 system ($got, 'commit', '-m', shift);
179 die "failed to commit changes: $!" if $? == -1;
183 # cmds
185 sub cmd_cat {
186 GetOptions('h|?' => \&usage) or usage;
187 usage unless @ARGV;
189 while (@ARGV) {
190 my $file = name2file(shift @ARGV);
191 system ($gpg, @gpg_flags, '-d', $file);
192 die "failed to exec $gpg: $!" if $? == -1;
196 sub cmd_find {
197 GetOptions('h|?' => \&usage) or usage;
198 usage if @ARGV gt 1;
200 map { say $_ } passfind(shift @ARGV);
203 sub cmd_gen {
204 my $chars = $default_chars;
205 my $length = $default_length;
207 GetOptions(
208 'c=s' => sub { $chars = $_[1] },
209 'h|?' => \&usage,
210 'l=i' => sub { $length = $_[1] },
211 ) or usage;
212 usage if @ARGV ne 1;
214 my $name = shift @ARGV;
215 my $file = name2file $name;
216 die "password already exists: $file\n" if -e $file;
218 my $pass = gen($chars, $length);
219 writepass($file, $pass);
221 got_add $file;
222 got_ci "+$name";
225 sub cmd_got {
226 chdir $store;
227 exec $got, @ARGV;
230 # TODO: handle moving directories?
231 sub cmd_mv {
232 GetOptions('h|?' => \&usage) or usage;
233 usage if @ARGV ne 2;
235 my $a = shift @ARGV;
236 my $b = shift @ARGV;
238 my $pa = name2file $a;
239 my $pb = name2file $b;
241 die "source password doesn't exist" unless -f $pa;
242 die "target password exists" if -f $pb;
244 rename $pa, $pb or die "can't rename $a to $b: $!";
246 got_rm $pa;
247 got_add $pb;
248 got_ci "mv $a $b";
251 sub cmd_oneshot {
252 my $chars = $default_chars;
253 my $length = $default_length;
255 GetOptions(
256 'c=s' => sub { $chars = $_[1] },
257 'h|?' => \&usage,
258 'l=i' => sub { $length = $_[1] },
259 ) or usage;
260 usage if @ARGV ne 0;
262 say gen($chars, $length);
265 sub cmd_regen {
266 my $chars = $default_chars;
267 my $length = $default_length;
269 GetOptions(
270 'c=s' => sub { $chars = $_[1] },
271 'h|?' => \&usage,
272 'l=i' => sub { $length = $_[1] },
273 ) or usage;
274 usage if @ARGV ne 1;
276 my $name = shift @ARGV;
277 my $file = name2file $name;
278 die "password doesn't exist" unless -f $file;
280 my $pass = gen($chars, $length);
281 writepass($file, $pass);
283 got_add $file;
284 got_ci "regen $name";
287 sub cmd_rm {
288 GetOptions('h|?' => \&usage) or usage;
289 usage if @ARGV ne 1;
291 my $name = shift @ARGV;
292 my $file = name2file $name;
294 got_rm $file;
295 got_ci "-$name";
298 sub cmd_tog {
299 chdir $store;
300 exec $tog, @ARGV;
303 sub cmd_write {
304 GetOptions('h|?' => \&usage) or usage;
305 usage if @ARGV ne 1;
307 my $name = shift @ARGV;
308 my $file = name2file $name;
310 my $pass = readpass "Enter the password: ";
311 writepass($file, $pass);
313 got_add $file;
314 got_ci "+$name";
317 __END__
319 =head1 NAME
321 B<plass> - manage passwords
323 =head1 SYNOPSIS
325 B<plass> I<command> [-h] [arg ...]
327 Valid subcommands are: cat, find, gen, got, mv, oneshot, regen, rm,
328 tog, write.
330 =head1 DESCRIPTION
332 B<plass> is a simple password manager. It manages passwords stored in
333 a directory tree rooted at I<~/.password-store> (or I<$PLASS_STORE>),
334 where every password is a single file encrypted with gpg2(1).
336 Passwords entries can be referenced using the path relative to the
337 store directory. The extension ".gpg" is optional.
339 The whole store is supposed to be managed by the got(1) version
340 control system.
342 The commands for B<plass> are as follows:
344 =over
346 =item B<cat> I<entries ...>
348 Decrypt and print the passwords of the given I<entries>.
350 =item B<find> [I<pattern>]
352 Print one per line all the entries of the store, optionally filtered
353 by the given I<pattern>.
355 =item B<gen> [B<-c> I<chars>] [B<-l> I<length>] I<entry>
357 Generate and persist a password for the given I<entry> in the store.
358 B<-c> can be used to control the characters allowed in the password
359 (by default I<!-~> i.e. all the printable ASCII character) and B<-l>
360 the length (32 by default.)
362 =item B<got> I<arguments ...>
364 Execute got(1) in the password store directory with the given
365 I<arguments>.
367 =item B<mv> I<from> I<to>
369 Rename a password entry, doesn't work with directories. I<from> must
370 exist and I<to> mustn't.
372 =item B<oneshot> [B<-c> I<chars>] [B<-l> I<length>]
374 Like B<gen> but prints the the generated password instead of persist
375 it.
377 =item B<regen> [B<-c> I<chars>] [B<-l> I<length>] I<entry>
379 Like B<gen> but re-generates a password in-place.
381 =item B<rm> I<entry>
383 Remove the password I<entry> from the store.
385 =item B<tog> I<arguments ...>
387 Execute tog(1) in the password store directory with the given
388 I<arguments>.
390 =item B<write> I<entry>
392 Prompt for a password and persist it in the store under the given
393 I<entry> name.
395 =back
397 =head1 CREATING A PASSWORD STORE
399 A password store is just a normal got(1) repository with a worktree
400 checked out in I<~/.password-store> (or I<$PLASS_STORE>). The only
401 restriction is that a file called I<.gpg-id> must exist in the root of
402 the work tree for most B<plass> commands to work.
404 For example, a got repository and password store can be created as
405 follows:
407 $ mkdir .password-store
408 $ cd .password-store
409 $ echo foo@example.com > .gpg-id
410 $ cd ~/git
411 $ got init pass.git
412 $ cd pass.git
413 $ got import -m 'initial import' ~/.password-store
414 $ cd ~/.password-store
415 $ got checkout -E ~/git/pass.git
417 See got(1) for more information.
419 Otherwise, if a repository already exists, a password-store can be
420 checked out more simply as:
422 $ got checkout ~/git/pass.git ~/.password-store
424 To migrate from pass(1), just delete I<~/.password-store> and checkout
425 it again usign got(1).
427 =head1 ENVIRONMENT
429 =over
431 =item PLASS_CHARS
433 Default range of characters to use to generate passwords.
435 =item PLASS_GOT
437 Path to the got(1) executable.
439 =item PLASS_GPG
441 Path to the gpg2(1) executable.
443 =item PLASS_LENGTH
445 Default length for the passwords generated.
447 =item PLASS_STORE
449 Path to the password-store directory tree. I<~/.password-store> by
450 default.
452 =item PLASS_TOG
454 Path to the tog(1) executable.
456 =back
458 =head1 FILES
460 =over
462 =item I<~/.password-store>
464 Password store used by default.
466 =item I<~/.password-store/.gpg-id>
468 File containing the gpg recipient used to encrypt the passwords.
470 =back
472 =head1 ACKNOWLEDGEMENTS
474 B<plass> was heavily influenced by pass(1) in the design, but it's a
475 complete different implementation with different tools involved.
477 =head1 AUTHORS
479 The B<plass> utility was written by Omar Polo <I<op@omarpolo.com>>.
481 =head1 CAVEATS
483 B<plass> B<find> output format isn't designed to handle files with
484 newlines in them. Use find(1) B<-print0> or similar if it's a
485 concern.
487 There isn't a B<init> sub-command, the store initialisation must be
488 performed manually.
490 =cut