commit 5cdf5adc619235c2890e51c8c92e24bcd130aa97 from: Omar Polo date: Sun May 08 15:01:40 2022 UTC initial import commit - /dev/null commit + 5cdf5adc619235c2890e51c8c92e24bcd130aa97 blob - /dev/null blob + a140df2df2b4dc2c44cf73d27b69d5d14b25e64f (mode 644) --- /dev/null +++ Makefile @@ -0,0 +1,8 @@ +INSTALL_PROGRAM = install -m 0555 + +.PHONY: all install + +all: + +install: + ${INSTALL_PROGRAM} plass ${HOME}/bin blob - /dev/null blob + 9000af423c15c803c0e782dd6385c7439a0788dc (mode 644) --- /dev/null +++ README.md @@ -0,0 +1,39 @@ +# plass -- manage passwords + +plass is a password manager inspired by password-store, but +reimplemented with a smaller and (IMHO) cleaner interface. It doesn't +have fancy trees in the output, not colors; the absence of these are +considered features. + +In addition, plass uses got(1) to manage the password store (but can +be easily patched to use git(1).) + +At the moment plass is completely compatible with pass, the same gpg2 +commands are used to decrypt and encrypt the passwords entries. In +the future, I'd like to switch the encryption tool to either signify +or age. + + +## Usage + +See `perldoc plass`. + + +## License + +plass is free software distributed under the ISC license + + 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. + blob - /dev/null blob + da823bc61fc1d7b114b65a731fca2619de789da6 (mode 755) --- /dev/null +++ plass @@ -0,0 +1,437 @@ +#!/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'} || '-_A-Za-z0-9'; +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 or pod2usage(1); + +my %subcmd = ( + cat => [\&cmd_cat, "entries..."], + find => [\&cmd_find, "[pattern]"], + gen => [\&cmd_gen, "[-c chars] [-l length] entry"], + got => [\&cmd_got, "arguments ..."], + mv => [\&cmd_mv, "from to"], + oneshot => [\&cmd_oneshot, "[-c chars] [-l length]"], + regen => [\&cmd_regen, "[-c chars] [-l length] entry"], + rm => [\&cmd_rm, "entry"], + tog => [\&cmd_tog, "arguments ..."], + write => [\&cmd_write, "entry"], + ); +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 writepass { + my ($file, $pass) = @_; + + 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 { + open my $fh, '-|', ($got, @_); + close($fh); + die "\"@_\" failed" if $? != 0; +} + +sub got_add { + got 'add', '-I', shift; +} + +sub got_rm { + got 'remove', '-f', shift; +} + +sub got_ci { + system ($got, 'commit', '-m', shift); + die "failed to commit changes: $!" if $? == -1; +} + + +# 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; + + GetOptions( + 'c=s' => sub { $chars = $_[1] }, + 'h|?' => \&usage, + 'l=i' => sub { $length = $_[1] }, + ) or usage; + usage if @ARGV ne 1; + + my $name = shift @ARGV; + my $file = name2file $name; + die "password already exists: $file\n" if -e $file; + + my $pass = gen($chars, $length); + writepass($file, $pass); + + got_add $file; + got_ci "+$name"; +} + +sub cmd_got { + chdir $store; + 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; + got_ci "mv $a $b"; +} + +sub cmd_oneshot { + my $chars = $default_chars; + my $length = $default_length; + + GetOptions( + 'c=s' => sub { $chars = $_[1] }, + 'h|?' => \&usage, + 'l=i' => sub { $length = $_[1] }, + ) or usage; + usage if @ARGV ne 0; + + say gen($chars, $length); +} + +sub cmd_regen { + my $chars = $default_chars; + my $length = $default_length; + + GetOptions( + 'c=s' => sub { $chars = $_[1] }, + 'h|?' => \&usage, + 'l=i' => sub { $length = $_[1] }, + ) or usage; + usage if @ARGV ne 1; + + my $name = shift @ARGV; + my $file = name2file $name; + die "password doesn't exist" unless -f $file; + + my $pass = gen($chars, $length); + writepass($file, $pass); + + got_add $file; + got_ci "regen $name"; +} + +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_tog { + chdir $store; + exec $tog, @ARGV; +} + +sub cmd_write { + GetOptions('h|?' => \&usage) or usage; + usage if @ARGV ne 1; + + my $name = shift @ARGV; + my $file = name2file $name; + + my $p1 = readpass "Enter the password: "; + my $p2 = readpass "Retype the password: "; + die "Passwords don't match\n" if $p1 ne $p2; + + writepass($file, $p1); + + got_add $file; + got_ci "+$name"; +} + +__END__ + +=head1 NAME + +B - manage passwords + +=head1 SYNOPSIS + +B I [-h] [arg ...] + +Valid subcommands are: cat, find, gen, got, mv, oneshot, regen, rm, +tog, write. + +=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<-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 +(I<-_A-Za-z0-9> by default) and B<-l> the length (32 by default.) + +=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 [B<-c> I] [B<-l> I] + +Like B but prints the the generated password and does not persist +it. + +=item B [B<-c> I] [B<-l> I] I + +Like B but re-generates a password in-place. + +=item B I + +Remove the password I from the store. + +=item B I + +Execute tog(1) in the password store directory with the given +I. + +=item B I + +Prompt for a password and persist it in the store under the given +I name. + +=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 + $ cd pass.git + $ got import -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 usign got(1). + +=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