commit - /dev/null
commit + 5cdf5adc619235c2890e51c8c92e24bcd130aa97
blob - /dev/null
blob + a140df2df2b4dc2c44cf73d27b69d5d14b25e64f (mode 644)
--- /dev/null
+++ Makefile
+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
+# 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
+#!/usr/bin/env perl
+#
+# Copyright (c) 2022 Omar Polo <op@omarpolo.com>
+#
+# 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<plass> - manage passwords
+
+=head1 SYNOPSIS
+
+B<plass> I<command> [-h] [arg ...]
+
+Valid subcommands are: cat, find, gen, got, mv, oneshot, regen, rm,
+tog, write.
+
+=head1 DESCRIPTION
+
+B<plass> 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<plass> are as follows:
+
+=over
+
+=item B<cat> I<entries ...>
+
+Decrypt and print the passwords of the given I<entries>.
+
+=item B<find> [I<pattern>]
+
+Print one per line all the entries of the store, optionally filtered
+by the given I<pattern>.
+
+=item B<gen> [B<-c> I<chars>] [B<-l> I<length>] I<entry>
+
+Generate and persist a password for the given I<entry> 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<got> I<arguments ...>
+
+Execute got(1) in the password store directory with the given
+I<arguments>.
+
+=item B<mv> I<from> I<to>
+
+Rename a password entry, doesn't work with directories. I<from> must
+exist and I<to> mustn't.
+
+=item B<oneshot> [B<-c> I<chars>] [B<-l> I<length>]
+
+Like B<gen> but prints the the generated password and does not persist
+it.
+
+=item B<regen> [B<-c> I<chars>] [B<-l> I<length>] I<entry>
+
+Like B<gen> but re-generates a password in-place.
+
+=item B<rm> I<entry>
+
+Remove the password I<entry> from the store.
+
+=item B<tog> I<arguments ...>
+
+Execute tog(1) in the password store directory with the given
+I<arguments>.
+
+=item B<write> I<entry>
+
+Prompt for a password and persist it in the store under the given
+I<entry> 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<plass> 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<plass> was heavily influenced by pass(1) in the design, but it's a
+complete different implementation with different tools involved.
+
+=head1 AUTHORS
+
+The B<plass> utility was written by Omar Polo <I<op@omarpolo.com>>.
+
+=head1 CAVEATS
+
+B<plass> B<find> 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<init> sub-command, the store initialisation must be
+performed manually.
+
+=cut