diff --git a/Makefile b/Makefile index 754262f..e529afa 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -VERSION= 0.4 +VERSION= 1.0 DISTNAME= plass-${VERSION} PROGS= plass pwg totp MANS= plass.1 pwg.1 totp.1 diff --git a/README.md b/README.md index 1ddec38..e176179 100644 --- a/README.md +++ b/README.md @@ -19,16 +19,9 @@ To build and install it, execute $ make $ doas make install -On linux, `libbsd-overlay` must be used: - - $ make CFLAGS="$(pkg-config --cflags libbsd-overlay)" \ - LDFLAGS="$(pkg-config --libs libbsd-overlay libcrypto)" - $ sudo make install - For casual use, an `install-local` target that only copies the programs in ~/bin is provided. - At the moment plass is completely compatible with pass, but in the future the encryption tool may be switched to something different to gpg. diff --git a/plass b/plass index 11a98ed..3e96945 100755 --- a/plass +++ b/plass @@ -20,9 +20,13 @@ use v5.32; use open ":std", ":encoding(UTF-8)"; +use Encode::Locale; +use Encode qw(decode); + use Getopt::Long qw(:config bundling require_order); use File::Basename; use File::Find; +use File::Path qw(make_path); use File::Temp qw(tempfile); my $store = $ENV{'PLASS_STORE'} // $ENV{'HOME'}.'/.password-store'; @@ -72,14 +76,6 @@ sub name2file { return $f; } -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 edit { my ($editor, $fh, $tempfile, $epath) = @_; @@ -98,12 +94,12 @@ sub edit { my $newtime = (stat($tempfile))[9]; if ($oldtime == $newtime) { say STDERR "no changes made."; - return + return; } - open(STDOUT, '>', $epath) or die "can't redirect stdout: $!"; - system ($gpg, @gpg_flags, '-e', '-r', recipient(), '-o', '-', - $tempfile); + system ($gpg, @gpg_flags, '-e', '-r', recipient(), '-o', $epath, + '--batch', '--yes', $tempfile); + die "gpg failed" if $? != 0; } @@ -120,46 +116,48 @@ sub passfind { my $pattern = shift; my @entries; + $pattern = decode(locale => $pattern) if defined $pattern; + find({ wanted => sub { + my $raw = $_; + $_ = decode(locale => $_); if (m,/.git$, || m,/.got$,) { $File::Find::prune = 1; return; } - return unless -f && m,.gpg$,; + return unless -f $raw && m,.gpg$,; s,^$store/*,,; s,.gpg$,,; - return if defined($pattern) && ! m/$pattern/i; + return if defined($pattern) && ! m/$pattern/ix; push @entries, $_; }, no_chdir => 1, follow_fast => 1, }, ($store)); - return sort(@entries); + my @sorted_entries = sort(@entries); + return @sorted_entries; } sub got { - # discard stdout - open my $fh, '-|', ('got', @_); - close($fh); - return !$?; -} - -sub got_add { - my $file = shift; - - open (my $null, '>', '/dev/null') or die "can't open /dev/null: $!"; - open (my $stderr, ">&", STDERR) or die "can't save stderr: $!"; - open (STDERR, ">&", $null) or die "can't redirect stderr: $!"; + my $pid = fork; + die "failed to fork: $!" unless defined $pid; - got 'info', $file; - my $found = !$?; + if ($pid != 0) { + wait; + return !$?; + } - open (STDERR, ">&", $stderr) or die "can't restore stderr: $!"; + open (STDOUT, '>', '/dev/null') + or die "can't redirect to /dev/null"; + exec ('got', @_); +} - return $found ? 1 : (got 'add', '-I', $file); +sub got_add { + got 'add', shift + or exit(1); } sub got_rm { @@ -219,7 +217,7 @@ sub cmd_find { GetOptions('h|?' => \&usage) or usage; usage if @ARGV > 1; - map { say $_ } passfind(shift @ARGV); + say $_ foreach passfind(shift @ARGV); } # TODO: handle moving directories? @@ -236,7 +234,7 @@ sub cmd_mv { die "source password doesn't exist" unless -f $pa; die "target password exists" if -f $pb; - mkdirs(dirname $pb); + make_path(dirname $pb); rename $pa, $pb or die "can't rename $a to $b: $!"; got_rm $pa; @@ -269,7 +267,7 @@ sub cmd_tee { my $file = name2file $name; my $msg = -f $file ? "update $name" : "+$name"; - mkdirs(dirname $file); + make_path(dirname $file); my @args = ($gpg, @gpg_flags, '-e', '-r', recipient(), '--batch', '--yes', '-o', $file); diff --git a/plass.1 b/plass.1 index fb699d5..cf456cd 100644 --- a/plass.1 +++ b/plass.1 @@ -11,7 +11,7 @@ .\" 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. -.Dd January 23, 2023 +.Dd August 30, 2023 .Dt PLASS 1 .Os .Sh NAME @@ -52,7 +52,7 @@ Interactively modify the content of the given with an editor. .It Cm find Op Ar pattern Print the entries of the store one per line, optionally filtered by -the case-insensitive +the case-insensitive extended regular expression .Ar pattern . .It Cm mv Ar from Ar to Rename a password entry, doesn't work with directories. @@ -91,8 +91,7 @@ The editor spawned by .Cm edit . If not set, the .Xr ed 1 -text editor will be used in order to given it the attention -it deserves. +text editor will be used to give it the attention it deserves. .El .Sh FILES .Bl -tag -width Ds diff --git a/pwg b/pwg index 2980606..96d0618 100755 --- a/pwg +++ b/pwg @@ -1,6 +1,7 @@ -#!/bin/sh +#!/usr/bin/env perl # -# Copyright (c) 2022 Omar Polo +# Copyright (c) 2022, 2023 Omar Polo +# Copyright (c) 2023 Alexander Arkhipov # # Permission to use, copy, modify, and distribute this software for any # purpose with or without fee is hereby granted, provided that the above @@ -14,35 +15,100 @@ # ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF # OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. -me=$(basename "$0") +use strict; +use warnings; +use v5.32; -usage() { - echo "usage: $me [-an] [-w wordlist] [len]" >&2 - exit 1 +use open ":std", ":encoding(UTF-8)"; + +use Getopt::Long qw(:config bundling require_order); +use File::Basename; + +my $urandom; # opened later + +my $chars = "\x20-\x7E"; +my $wordlist; +my $length = 32; + +my $me = basename $0; +sub usage { + say STDERR "usage: $me [-an] [-w wordlist] [len]"; + exit(1); +} + +# not really arc4random but closer... +sub arc4random { + my $r = read($urandom, my $buf, 4) + or die "$me: failed to read /dev/urandom: $!\n"; + die "$me: short read\n" if $r != 4; + return unpack('L', $buf); +} + +# Calculate a uniformly distributed random number less than $upper_bound +# avoiding "modulo bias". +# +# Uniformity is achieved by generating new random numbers until the one +# returned is outside the range [0, 2**32 % $upper_bound). This +# guarantees the selected random number will be inside +# [2**32 % $upper_bound, 2**32) which maps back to [0, $upper_bound) +# after reduction modulo $upper_bound. +sub randline { + my $upper_bound = shift; + + return 0 if $upper_bound < 2; + + my $min = 2**32 % $upper_bound; + + # This could theoretically loop forever but each retry has + # p > 0.5 (worst case, usually far better) of selecting a + # number inside the range we need, so it should rarely need + # to re-roll. + my $r; + while (1) { + $r = arc4random; + last if $r >= $min; + } + return $r % $upper_bound; +} + +GetOptions( + "a" => sub { $chars = "0-9a-zA-Z" }, + "n" => sub { $chars = "0-9" }, + "w=s" => \$wordlist, + ) or usage; + +$length = 6 if defined $wordlist; +$length = shift if @ARGV; +die "$me: invalid length: $length\n" unless $length =~ /^\d+$/; + +open($urandom, "<:raw", "/dev/urandom") + or die "$me: can't open /dev/urandom: $!\n"; + +if (not defined $wordlist) { + my $pass = ""; + my $l = $length; + while ($l >= 0) { + read($urandom, my $t, 128) + or die "$me: failed to read /dev/urandom: $!\n"; + $t =~ s/[^$chars]//g; + $l -= length($t); + $pass .= $t; + } + say substr($pass, 0, $length); + exit 0; } -wordlist= -chars="[:print:]" -len=32 - -while getopts anw: ch; do - case $ch in - a) chars="[:alnum:]" ;; - n) chars="[:digit:]" ;; - w) wordlist="$OPTARG"; len=6 ;; - ?) usage ;; - esac -done -shift $(($OPTIND - 1)) - -[ $# -gt 1 ] && usage -[ $# -eq 1 ] && len="$1" - -if [ -n "$wordlist" ]; then - passphrase=$(sort -R "$wordlist" | head -n "$len") - [ -n "$passphrase" ] && echo $passphrase -else - export LC_ALL=C - tr -cd "$chars" /dev/null && \ - echo -fi +open(my $fh, "<", $wordlist) or die "$me: can't open $wordlist: $!\n"; + +my @lines = (0); +push @lines, tell $fh while <$fh>; + +while ($length--) { + seek $fh, $lines[randline scalar(@lines)], 0 + or die "$me: seek: $!\n"; + my $line = <$fh>; + chomp($line); + print $line; + print " " if $length; +} +say ""; diff --git a/pwg.1 b/pwg.1 index 529c53f..5db5d77 100644 --- a/pwg.1 +++ b/pwg.1 @@ -1,4 +1,4 @@ -.\" Copyright (c) 2021, 2022 Omar Polo +.\" Copyright (c) 2021, 2022, 2023 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 @@ -11,7 +11,7 @@ .\" 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. -.Dd January 21, 2023 +.Dd November 23, 2023 .Dt PWG 1 .Os .Sh NAME @@ -24,25 +24,22 @@ .Op Ar length .Sh DESCRIPTION .Nm -is a password and passphrase generator. -It generates a random string of characters or a diceware-style pass -phrase. +generates a random string of characters or a diceware-style passphrase +using a word list, a file with one word per line. The random properties are the ones provided by the operating system' .Pa /dev/urandom -and -.Xr sort 1 -.Fl R . +device. .Pp The options are as follows: .Bl -tag -width Ds .It Fl a -use only alphanumeric characters. +Use only alphanumeric characters. .It Fl n -use only numbers. +Use only numbers. .It Fl w Ar wordlist -generate a passphrase using words from the +Generate a passphrase from a the given .Ar wordlist -file. +instead of a random string of characters. .El .Pp If no diff --git a/totp.1 b/totp.1 index e24409d..2fe7731 100644 --- a/totp.1 +++ b/totp.1 @@ -1,4 +1,4 @@ -.\" Copyright (c) 2022 Omar Polo +.\" Copyright (c) 2022, 2023 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 @@ -11,7 +11,7 @@ .\" 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. -.Dd January 23, 2023 +.Dd August 30, 2023 .Dt TOTP 1 .Os .Sh NAME @@ -30,11 +30,12 @@ The secret is usually provided by the authenticator .Pq for e.g.\& a website and is either a base32-encoded string or a .Sq otpauth:// -URL. +URI. Blanks in the secret string are ignored, but only one line is read. .Pp .Nm -uses a period of 30 seconds and generates six digits long codes. +uses a period of 30 seconds, HMAC-SHA1 and generates six digits long +codes, unless the URI specifies otherwise. .Sh EXIT STATUS .Ex -std .Sh EXAMPLES @@ -57,6 +58,10 @@ follows the algorithm outlined in RFC 6238 .Dq TOTP: Time-Based One-Time Password Algorithm and uses the base32 encoding as defined in RFC 3548 .Dq The Base16, Base32, and Base64 Data Encodings . +.Sq otpauth:// +URIs are parsed as per the +.Dq Key URI Format +proposed by Google Authenticator. .Sh AUTHORS .An -nosplit The diff --git a/totp.c b/totp.c index b201ae6..071aa14 100644 --- a/totp.c +++ b/totp.c @@ -1,5 +1,6 @@ /* * Copyright (c) 2022, 2023 Omar Polo + * Copyright (c) 2014 Reyk Floeter * * Permission to use, copy, modify, and distribute this software for any * purpose with or without fee is hereby granted, provided that the above @@ -17,6 +18,7 @@ #include #include #include +#include #include #include #include @@ -34,7 +36,7 @@ # define __dead __attribute__((noreturn)) #endif -#if defined(__FreeBSD__) +#if defined(__FreeBSD__) || defined(__NetBSD__) # include #elif defined(__APPLE__) # include @@ -46,15 +48,29 @@ #endif static __dead void -usage(void) +usage(const char *argv0) { - fprintf(stderr, "usage: %s\n", getprogname()); + const char *me; + + if ((me = strrchr(argv0, '/')) != NULL) + me++; + else + me = argv0; + + fprintf(stderr, "usage: %s = 'a' && c <= 'z') + c = toupper((unsigned char)c); + if (c >= 'A' && c <= 'Z') return (c - 'A'); if (c >= '2' && c <= '7') @@ -95,20 +111,94 @@ b32decode(const char *s, char *q, size_t qlen) return (q - t); } +/* adapted from httpd(8) */ +static char * +url_decode(char *url, char *dst) +{ + char *p, *q; + char hex[3]; + unsigned long x; + + hex[2] = '\0'; + p = url; + q = dst; + + while (*p != '\0') { + if (*p != '%') { + *q++ = *p++; + continue; + } + + if (!isxdigit((unsigned char)p[1]) || + !isxdigit((unsigned char)p[2])) + return (NULL); + + hex[0] = p[1]; + hex[1] = p[2]; + + /* + * We don't have to validate "hex" because it is + * guaranteed to include two hex chars followed by nul. + */ + x = strtoul(hex, NULL, 16); + *q++ = (char)x; + p += 3; + } + *q = '\0'; + return (url); +} + static int -uri2secret(char *s) +uri2secret(char *s, int *digits, const EVP_MD **alg, int *period) { - char *q, *t; + char *q, *t, *f, *ep, *secret = NULL; + long l; if ((q = strchr(s, '?')) == NULL) return (-1); - if ((t = strstr(q, "?secret=")) == NULL && - (t = strstr(q, "&secret=")) == NULL) + + t = q + 1; + while ((f = strsep(&t, "&")) != NULL) { + if (!strncmp(f, "secret=", 7)) + secret = f + 7; + else if (!strncmp(f, "digits=", 7)) { + f += 7; + if (!strcmp(f, "6")) + *digits = 6; + else if (!strcmp(f, "7")) + *digits = 7; + else if (!strcmp(f, "8")) + *digits = 8; + else + warnx("invalid number of digits; using 6"); + } else if (!strncmp(f, "algorithm=", 10)) { + f += 10; + if (!strcmp(f, "SHA1")) + *alg = EVP_sha1(); + else if (!strcmp(f, "SHA256")) + *alg = EVP_sha256(); + else if (!strcmp(f, "SHA512")) + *alg = EVP_sha512(); + else + warnx("unknown algorithm; using SHA1"); + } else if (!strncmp(f, "period=", 7)) { + f += 7; + errno = 0; + l = strtol(f, &ep, 10); + if (f[0] == '\0' || *ep != '\0') + err(1, "period is not a number: %s", f); + if (errno == ERANGE && (l == LONG_MAX || l == LONG_MIN)) + err(1, "period is way out of range: %s", f); + if (l < 1 || l > 120) + err(1, "period is out of range: %s", f); + *period = l; + } + } + + if (secret == NULL) return (-1); - t += 8; - while (*t != '\0' && *t != '&' && *t != '#') - *s++ = *t++; - *s = '\0'; + if (url_decode(secret, s) == NULL) + errx(1, "failed to percent-decode the secret"); return (0); } @@ -117,30 +207,37 @@ main(int argc, char **argv) { char buf[1024]; size_t buflen; + const EVP_MD *alg; unsigned char md[EVP_MAX_MD_SIZE]; unsigned int mdlen; + const char *argv0; char *s, *q, *line = NULL; size_t linesize = 0; ssize_t linelen; uint64_t ct; uint32_t hash; uint8_t off; - int ch; + int ch, digits = 6, period = 30; if (pledge("stdio", NULL) == -1) err(1, "pledge"); + if ((argv0 = argv[0]) == NULL) + argv0 = "totp"; + while ((ch = getopt(argc, argv, "")) != -1) { switch (ch) { default: - usage(); + usage(argv0); } } argc -= optind; argv += optind; if (argc != 0) - usage(); + usage(argv0); + + alg = EVP_sha1(); linelen = getline(&line, &linesize, stdin); if (linelen == -1) { @@ -159,22 +256,33 @@ main(int argc, char **argv) if (linelen < 1) errx(1, "no secret provided"); - if (!strncmp(line, "otpauth://", 10) && uri2secret(line) == -1) + if (!strncmp(line, "otpauth://", 10) && + uri2secret(line, &digits, &alg, &period) == -1) errx(1, "failed to decode otpauth URI"); if ((buflen = b32decode(line, buf, sizeof(buf))) == 0) err(1, "can't base32 decode the secret"); - ct = htobe64(time(NULL) / 30); + ct = htobe64(time(NULL) / period); - HMAC(EVP_sha1(), buf, buflen, (unsigned char *)&ct, sizeof(ct), - md, &mdlen); + HMAC(alg, buf, buflen, (unsigned char *)&ct, sizeof(ct), md, &mdlen); off = md[mdlen - 1] & 0x0F; memcpy(&hash, md + off, sizeof(hash)); hash = be32toh(hash); - printf("%06d\n", (hash & 0x7FFFFFFF) % 1000000); + + switch (digits) { + case 6: + printf("%06d\n", (hash & 0x7FFFFFFF) % 1000000); + break; + case 7: + printf("%07d\n", (hash & 0x7FFFFFFF) % 10000000); + break; + case 8: + printf("%08d\n", (hash & 0x7FFFFFFF) % 100000000); + break; + } free(line); return (0);