Skip to content

Instantly share code, notes, and snippets.

@tron1point0
Created February 6, 2018 11:53
Show Gist options
  • Star 1 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save tron1point0/f989a6fa2baf19313bf367affa7f05ab to your computer and use it in GitHub Desktop.
Save tron1point0/f989a6fa2baf19313bf367affa7f05ab to your computer and use it in GitHub Desktop.
#!/usr/bin/env perl
use v5.16;
use warnings;
use Digest::HMAC_SHA1 qw(hmac_sha1);
use Convert::Base32 qw(decode_base32);
use Getopt::Long;
use Pod::Usage;
my %GLOBAL = (
time => time,
verbose => 0,
help => 0,
man => 0,
config => [],
query => '',
names => 1,
add => '',
);
sub LoadFile {
my ($file) = (@_,$_);
return unless -r $file;
my @tdefs = ();
my $def = {};
open my $fh, '<', $file;
while (<$fh>) {
chomp;
s/#.*$//; # Strip comments
s/^\s*//; # trim leading
s/\s*$//; # trim trailing
if (m/^---/ && $. > 1) {
push @tdefs, $def;
$def = {};
next;
}
if (m/^(.*?)\s*:\s*(.*)$/) {
$def->{$1} = $2;
next;
}
}
push @tdefs, $def;
@tdefs;
}
sub debug {
chomp $_[-1];
warn @_, "\n";
}
sub verbose(&;@) {
my ($fn, $level, @ret) = @_;
$level //= 1;
@ret = ($_) unless @ret;
{ local ($_) = @ret;
local ($a, $b) = @ret;
debug $fn->(@ret) if $GLOBAL{verbose} >= $level;
}
return @ret if wantarray;
$ret[0];
}
sub totp {
my %opt = verbose {
"Generating token for:\n", pp({@_})
} 2, @_;
my $tc = verbose {
"Using [$_] as counter."
} 2, int(($opt{time} - $opt{epoch}) / $opt{timestep});
my $hmac = hmac_sha1
scalar(reverse pack 'Q', $tc),
decode_base32($opt{secret});
my @bytes = unpack 'C*', $hmac;
my $offset = $bytes[-1] & 0xF;
my $hash = (unpack 'N', pack 'C4',
verbose {
"Using [@{[join ',', @_]}] for the 4 bytes."
} 3, @bytes[$offset .. $offset + 3])
& 0x7FFF_FFFF;
$opt{prefix} . verbose { "Generated [$_] for [$opt{name}]." } 2,
scalar reverse substr
scalar(reverse $hash),
0,
$opt{digits};
}
sub check_initial_setup {
pod2usage
-exitval => 1,
-sections => 'INITIAL SETUP',
-verbose => 99 unless @_;
@_;
}
sub uniq_by(&;@) {
my ($fn, @list) = @_;
my %h = ();
grep { not $h{$fn->()}++ } @list;
}
GetOptions(\%GLOBAL, qw(
help|h+
man|m+
verbose|v+
config|c=s@
time|t=i
query|q=s
names|no-names|n:0
add|a=s
)) or pod2usage 2;
# YAML::XS is so slow to load - 40ms minimum.
if ($ENV{YAML_XS}) {
require YAML::XS;
YAML::XS->import(qw(LoadFile));
}
pod2usage 1 if $GLOBAL{help};
pod2usage
-exitval => 0,
-verbose => 2 if $GLOBAL{man};
if ($GLOBAL{verbose} > 0) {
require Data::Dump;
Data::Dump->import(qw(pp));
}
my %DEFAULT = (
epoch => 0,
digits => 6,
prefix => '',
timestep => 30,
time => $GLOBAL{time},
);
my @DEFAULT_CONFIGS = (
"$ENV{HOME}/.totp.yml",
"$ENV{XDG_CONFIG_HOME}/totp/totp.yml",
'./.totp.yml',
'./totp.yml',
);
sub config_files {
grep -r, map {
verbose { "Checking $_..." }
} @{$GLOBAL{config}} ? @{$GLOBAL{config}} : @DEFAULT_CONFIGS;
}
if ($GLOBAL{add} =~ m/^(?:([^:]+):)?(.+)$/) {
my $file = (config_files)[0] || $GLOBAL{config}[0] || $DEFAULT_CONFIGS[0];
my $name = verbose {
"Adding token named [$_] with secret [$2] to [$file]."
} 1, $1 || 'DEFAULT';
open my $fh, '>>', $file;
print $fh <<"END";
--- # Added by $0 @{[join " ", @ARGV]}
name: $name
secret: $2
END
exit 0
}
my @generated = map {
$_->{token} = totp %DEFAULT, %$_;
$_;
} map {
verbose { "Loaded token:\n", pp($_) } 2
} uniq_by {
$_->{secret}
} grep {
$_->{name} =~ $GLOBAL{query}
} map {
LoadFile $_
} check_initial_setup map {
verbose { "Found file $_" }
} config_files;
# Print just the token if there's only
# one and STDOUT is not a TTY. (For copy/paste buffers.)
print $generated[0]{token} and exit 0
if @generated == 1 and not -t *STDOUT;
say join "\n", map {
($GLOBAL{names} ? "$_->{name}: " : ()) . $_->{token};
} @generated;
__END__
=head1 NAME
I<totp> - Compute TOTP tokens
=head1 SYNOPSIS
totp [-hmnv] [-c FILES...] [-q REGEX] [-t TIME]
totp -a [NAME:]SECRET [-c FILE]
=head1 OPTIONS
-c FILES Look for token definitions in FILES
-q REGEX Only show tokens whose names match REGEX
-n Hide token names from output
-t TIME Use TIME as current time
-a SECRET Add the secret to the first config file found
-v Verbose output (to STDERR)
-h This text
-m Full manual page
=head1 DESCRIPTION
Implements the L<Time-based One-time Password
Algorithm|https://en.wikipedia.org/wiki/Time-based_One-time_Password_Algorithm>
Defaults for B<epoch>, B<time>, B<timestep>, and B<digits> are set
to the same values that L<Google
Authenticator|https://play.google.com/store/apps/details?id=com.google.android.apps.authenticator2>
uses.
=head1 INITIAL SETUP
You need to create at least one token definition file in
order for I<totp> to be useful.
Get your I<base32> encoded secret, SECRET, and then run:
$ totp -a SECRET
=head1 OPTION DETAILS
=over
=item B<--config|-c> FILES...
Read token definitions from I<FILES...> instead of the default
locations.
See L<LOCATIONS> for the default locations.
See L<FORMAT> for the format of the definition files.
=item B<--query|-q> REGEX
Only shows tokens whose B<name> property matches I<REGEX>.
Works well with B<--no-name> when you just need to echo a specific
token to C<STDOUT>.
=item B<--no-name|-n>
Show only the generated tokens. Do not show the B<name>s of the
tokens.
Works well with B<--query> when you just need to echo a specific
token to C<STDOUT>.
If only one token is defined and C<STDOUT> is not a TTY, B<--no-names>
is assumed. This is so you can do:
totp -q NAME | pbcopy
And have only the token itself in the paste buffer.
=item B<--time|-t> SECONDS
The Unix time to generate the token for in seconds since the Unix
epoch.
=item B<--add|-a> [NAME:]SECRET
Adds a basic token definition named I<NAME> with secret I<SECRET>
to either the first default configuration file (see L<LOCATIONS>)
or to the first file specified by a B<--config> argument.
=item B<--verbose|-v>
Increase output verbosity. Can be repeated for more verbosity.
=item B<--help|-h>
Show the L<SYNOPSIS> and L<OPTIONS> sections of this man page.
=item B<--man|-m>
Show this man page.
=back
=head1 CONFIGURATION FILES
I<totp> uses C<YAML> files to store token definitions. Their format
is defined in L<FORMAT> and the default locations that I<totp> looks
for configuration files in are specified in L<LOCATIONS>.
=head2 FORMAT
Each definition file may contain any number of C<YAML> documents.
Each document is assumed to define exactly one token. Each token
I<must> have the following properties defined:
=over
=item B<name>
The human-readable name of this token. These do not I<need> to be
unique, but you're just going to end up confusing yourself.
=item B<secret>
The I<base32>-encoded secret given to you by the token issuer.
=back
Each token I<may> also define the following properties:
=over
=item B<prefix>
String that will be prepended to the token after it is generated
but before it is echoed to the terminal.
Default: C<''>
=item B<timestep>
The amount of time in seconds between each new token generation.
Default: C<30>
=item B<digits>
The number of digits of the generated token to return.
Default: C<6>
=item B<epoch>
The starting unix time for generating the counter offset.
Default: C<0>
=back
=head2 LOCATIONS
I<totp> will look in these locations for token definition files,
in order:
$HOME/.totp.yml
$XDG_CONFIG_HOME/totp/totp.yml
./.totp.yml
./totp.yml
If you specify B<--config> arguments, they will replace the above
list in the order they are specified in the argument list.
=head1 ENVIRONMENT
If the I<YAML_XS> environment variable is set to any truthy value,
I<totp> will use L<YAML::XS> module to load token definitions. This
is slow - L<YAML::XS> takes ~40ms just to load. If this environment
variable is not set to a truthy value, I<totp> will instead use a
much faster line-based parser to read the definitions.
You should only need to set I<YAML_XS> if your definition file
contains something other than C<key: value> lines, trailing line
comments, and the C<---> document splitter.
=head1 EXAMPLE
If the contents of C<$HOME/.totp.yml> are:
---
name: DEFAULT
secret: hq5sotirt722l3dmdyqvyhfxstmzoowb
Then:
$ totp -t 0
DEFAULT: 657844
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment