#!/usr/bin/env perl # # Copyright (c) 2022 Omar Polo # # Permission to use, copy, modify, and distribute this software for any # purpose with or without fee is hereby granted, provided that the above # copyright notice and this permission notice appear in all copies. # # THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES # WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF # MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR # ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES # WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN # ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF # OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. use strict; use warnings; use v5.32; use open ":std", ":encoding(UTF-8)"; use Getopt::Long qw(:config bundling require_order); use Pod::Usage; use File::Basename; use File::Find; my $store = $ENV{'PLASS_STORE'} // $ENV{'HOME'}.'/.password-store'; my $got = $ENV{'PLASS_GOT'} // 'got'; my $tog = $ENV{'PLASS_TOG'} // 'tog'; my $gpg = $ENV{'PLASS_GPG'} // 'gpg2'; my @gpg_flags = qw(--quiet --compress-algo=none --no-encrypt-to); my $default_chars = $ENV{'PLASS_CHARS'} // '!-~'; my $default_length = $ENV{'PLASS_LENGTH'}; if (!defined($default_length) || $default_length lt 0) { $default_length = 32; } GetOptions( "h|?" => sub { pod2usage(0) }, ) or pod2usage(1); my $cmd = shift // 'find'; my %subcmd = ( cat => [\&cmd_cat, "entries..."], find => [\&cmd_find, "[pattern]"], gen => [\&cmd_gen, "[-nq] [-c chars] [-l length] entry"], got => [\&cmd_got, "args ..."], mv => [\&cmd_mv, "from to"], rm => [\&cmd_rm, "entry"], tee => [\&cmd_tee, "[-q] entry"], tog => [\&cmd_tog, "args ..."], ); pod2usage(1) unless defined $subcmd{$cmd}; my ($fn, $usage) = @{$subcmd{$cmd}}; chdir $store; $fn->(); exit 0; # utils sub usage { my $prog = basename $0; say STDERR "Usage: $prog $cmd $usage"; exit 1; } sub name2file { my $f = shift; $f .= ".gpg" unless $f =~ m,\.gpg$,; return $f; } # tr -cd -- $chars < /dev/random | dd bs=$len count=1 status=none sub gen { my ($chars, $length) = @_; my $pass = ""; open(my $fh, '<:raw', '/dev/random') or die "can't open /dev/random: $!"; my $l = $length; while ($l gt 0) { read($fh, my $t, $length * 4) or die "failed to read /dev/random: $!"; $t =~ s/[^$chars]//g; $l -= length($t); $pass .= $t; } close($fh); return substr($pass, 0, $length); } sub readpass { # todo some stty black magic to avoid echo print shift if -t; my $pass = <>; die "failed to read stdin: $!" unless defined($pass); chomp $pass; return $pass; } sub mkdirs { my $dir = shift; my $parent = dirname $dir; mkdirs($parent) unless -d $parent || $parent eq '/'; mkdir $dir or die "mkdir $dir: $!" unless -d $dir; } sub writepass { my ($file, $pass) = @_; mkdirs(dirname $file); my @args = ($gpg, @gpg_flags, '-e', '-r', recipient(), '-o', $file); open my $fh, '|-', @args; say $fh "$pass"; close($fh); } sub recipient { open my $fh, '<', "$store/.gpg-id" or die "can't open recipient file"; my $r = <$fh>; chomp $r; close($fh); return $r; } sub passfind { my $pattern = shift; my @entries; find({ wanted => sub { if (m,/.git$, || m,/.got$,) { $File::Find::prune = 1; return; } return if defined($pattern) && ! m/$pattern/; return unless -f && m,.gpg$,; s,^$store/*,,; s,.gpg$,,; push @entries, $_; }, no_chdir => 1, follow_fast => 1, }, ($store)); return sort(@entries); } sub got { # discard stdout open my $fh, '-|', ($got, @_); close($fh); return !$?; } sub got_add { return got 'add', '-I', shift; } sub got_rm { got 'remove', '-f', shift or exit(1); } sub got_ci { my $pid = fork; die "failed to fork: $!" unless defined $pid; if ($pid ne 0) { wait; die "failed to commit changes" if $?; return; } open (STDOUT, ">&", \*STDERR) or die "can't redirect stdout to stderr"; exec ($got, 'commit', '-m', shift) or die "failed to exec $got: $!"; } # cmds sub cmd_cat { GetOptions('h|?' => \&usage) or usage; usage unless @ARGV; while (@ARGV) { my $file = name2file(shift @ARGV); system ($gpg, @gpg_flags, '-d', $file); die "failed to exec $gpg: $!" if $? == -1; } } sub cmd_find { GetOptions('h|?' => \&usage) or usage; usage if @ARGV gt 1; map { say $_ } passfind(shift @ARGV); } sub cmd_gen { my $chars = $default_chars; my $length = $default_length; my $nop; my $q; GetOptions( 'c=s' => sub { $chars = $_[1] }, 'h|?' => \&usage, 'l=i' => sub { $length = $_[1] }, 'n' => \$nop, 'q' => \$q, ) or usage; usage if @ARGV ne 1; my $name = shift @ARGV; my $file = name2file $name; my $renamed = -f $file; my $pass = gen($chars, $length); unless ($nop) { writepass($file, $pass); got_add $file; got_ci($renamed ? "update $name" : "+$name"); } say $pass unless $q; } sub cmd_got { exec $got, @ARGV; } # TODO: handle moving directories? sub cmd_mv { GetOptions('h|?' => \&usage) or usage; usage if @ARGV ne 2; my $a = shift @ARGV; my $b = shift @ARGV; my $pa = name2file $a; my $pb = name2file $b; die "source password doesn't exist" unless -f $pa; die "target password exists" if -f $pb; rename $pa, $pb or die "can't rename $a to $b: $!"; got_rm $pa; got_add $pb or die "can't add $pb\n"; got_ci "mv $a $b"; } sub cmd_rm { GetOptions('h|?' => \&usage) or usage; usage if @ARGV ne 1; my $name = shift @ARGV; my $file = name2file $name; got_rm $file; got_ci "-$name"; } sub cmd_tee { my $q; GetOptions( 'h|?' => \&usage, 'q' => \$q, ) or usage; usage if @ARGV ne 1; my $name = shift @ARGV; my $file = name2file $name; my $pass = readpass "Enter the password: "; writepass($file, $pass); got_add $file; got_ci (-f $file ? "update $name" : "+$name"); say $pass unless $q; } sub cmd_tog { exec $tog, @ARGV; } __END__ =head1 NAME B - manage passwords =head1 SYNOPSIS B I [-h] [arg ...] Valid subcommands are: cat, find, gen, got, mv, rm, tee, tog. If no I is given, B is assumed. =head1 DESCRIPTION B is a simple password manager. It manages passwords stored in a directory tree rooted at I<~/.password-store> (or I<$PLASS_STORE>), where every password is a single file encrypted with gpg2(1). Passwords entries can be referenced using the path relative to the store directory. The extension ".gpg" is optional. The whole store is supposed to be managed by the got(1) version control system. The commands for B are as follows: =over =item B I Decrypt and print the passwords of the given I. =item B [I] Print one per line all the entries of the store, optionally filtered by the given I. =item B [B<-nq>] [B<-c> I] [B<-l> I] I Generate and persist a password for the given I in the store. B<-c> can be used to control the characters allowed in the password (by default I i.e. all the printable ASCII character) and B<-l> the length (32 by default.) Unless B<-q> is provided, plass prints the generated password. If the B<-n> option is given, plass won't persist the password. =item B I Execute got(1) in the password store directory with the given I. =item B I I Rename a password entry, doesn't work with directories. I must exist and I mustn't. =item B I Remove the password I from the store. =item B [B<-q>] I Prompt for a password, persist it in the store under the given I name and then print it again to standard output. =item B I Execute tog(1) in the password store directory with the given I. =back =head1 CREATING A PASSWORD STORE A password store is just a normal got(1) repository with a worktree checked out in I<~/.password-store> (or I<$PLASS_STORE>). The only restriction is that a file called I<.gpg-id> must exist in the root of the work tree for most B commands to work. For example, a got repository and password store can be created as follows: $ mkdir .password-store $ cd .password-store $ echo foo@example.com > .gpg-id $ cd ~/git $ got init pass.git $ got import -r pass.git -m 'initial import' ~/.password-store $ cd ~/.password-store $ got checkout -E ~/git/pass.git . See got(1) for more information. Otherwise, if a repository already exists, a password-store can be checked out more simply as: $ got checkout ~/git/pass.git ~/.password-store To migrate from pass(1), just delete I<~/.password-store> and checkout it again using got(1). =head1 ENVIRONMENT =over =item PLASS_CHARS Default range of characters to use to generate passwords. =item PLASS_GOT Path to the got(1) executable. =item PLASS_GPG Path to the gpg2(1) executable. =item PLASS_LENGTH Default length for the passwords generated. =item PLASS_STORE Path to the password-store directory tree. I<~/.password-store> by default. =item PLASS_TOG Path to the tog(1) executable. =back =head1 FILES =over =item I<~/.password-store> Password store used by default. =item I<~/.password-store/.gpg-id> File containing the gpg recipient used to encrypt the passwords. =back =head1 ACKNOWLEDGEMENTS B was heavily influenced by pass(1) in the design, but it's a complete different implementation with different tools involved. =head1 AUTHORS The B utility was written by Omar Polo >. =head1 CAVEATS B B output format isn't designed to handle files with newlines in them. Use find(1) B<-print0> or similar if it's a concern. There isn't a B sub-command, the store initialisation must be performed manually. =cut