You can not generate a code for a specific time. Currently only UTC is supported.
639 lines
29 KiB
Zig
639 lines
29 KiB
Zig
const std = @import("std");
|
|
const info = std.log.info;
|
|
const debug = std.log.debug;
|
|
//const stderr = std.io.getStdErr().writer();
|
|
const print = std.debug.print;
|
|
const eql = std.mem.eql;
|
|
const Allocator = std.mem.Allocator;
|
|
const testing = std.testing;
|
|
const expect = testing.expect;
|
|
const ArrayList = std.ArrayList;
|
|
const Base32 = @import("base32.zig").Base32;
|
|
const Base32Error = @import("base32.zig").Base32Error;
|
|
const options = @import("config");
|
|
const Time = @import("time.zig").Time;
|
|
const TimeError = @import("time.zig").TimeError;
|
|
|
|
// otpauth://totp/AWS+Dev?secret=47STA47VFCMMLLWOLHWO3KY7MYNC36MLCDTHOLIYKJCTTSSAMKVM7YA3VWT2AJEP&digits=6&icon=Amazon
|
|
// otpauth://totp/Gitea%20%28git.lucares.de%29:andi?algorithm=SHA1&digits=6&issuer=Gitea&period=30&secret=MSP53Q672UJMSCLQVRCJKMMZKK7MWSMYFL77OQ24JBM65RZWY7F2Y45FMTNLYM36
|
|
|
|
pub fn main() !void {
|
|
mainInternal() catch |err| {
|
|
switch (err) {
|
|
Base32Error.InvalidCharacter, Base32Error.InvalidPadding => {
|
|
try std.io.getStdErr().writer().print("The secret is invalid.\n", .{});
|
|
},
|
|
ArgumentError.UnknownParameter, //
|
|
ArgumentError.MissingAuthenticatorParam, //
|
|
ArgumentError.MissingConfigLocation, //
|
|
ArgumentError.FailedToOpenConfigFile, //
|
|
ArgumentError.TooManyParsinErrors, //
|
|
=> {
|
|
// do nothing, error message is already written (because the message contains the name of the unknown parameter)
|
|
},
|
|
ArgumentError.InvalidOtpAuthUrl => {},
|
|
ArgumentError.AuthenticatorNotFound => {
|
|
try std.io.getStdErr().writer().print("authenticator not found\n", .{});
|
|
},
|
|
TimeError.ParsingError => {
|
|
try std.io.getStdErr().writer().print("Invalid time. The argument for the --time parameter must be a valid ISO 8601 date with second precision for timezone UTC. E.g. 2000-01-01T00:00:00\n", .{});
|
|
},
|
|
else => |leftover_err| {
|
|
try std.io.getStdErr().writer().print("{?}\n", .{leftover_err});
|
|
},
|
|
}
|
|
std.process.exit(1);
|
|
};
|
|
}
|
|
|
|
fn mainInternal() !void {
|
|
var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
|
|
defer arena.deinit();
|
|
const allocator = arena.allocator();
|
|
|
|
const args = try std.process.argsAlloc(allocator);
|
|
|
|
const arg: Args = try parseArgs(allocator, args);
|
|
//print("parsed Args: {?any}\n", arg);
|
|
if (arg.print_version) {
|
|
try std.io.getStdOut().writer().print("{}", .{options.version});
|
|
} else if (arg.list) {
|
|
const names = try executeGetList(allocator, arg.config_location);
|
|
|
|
for (0..names.items.len) |i| {
|
|
try std.io.getStdOut().writer().print("{s}\n", .{names.items[i]});
|
|
}
|
|
} else if (arg.show != null) {
|
|
const authenticator = try getAuthenticator(allocator, arg.show.?, arg.config_location);
|
|
const time = if (arg.time != null) try Time.parseIso8601(arg.time.?) else std.time.timestamp();
|
|
const code = try authenticator.code(allocator, time);
|
|
try std.io.getStdOut().writer().print("{s}\n", .{code});
|
|
} else if (arg.validateAuthenticator != null and arg.validateCode != null) {
|
|
const authenticator = try getAuthenticator(allocator, arg.validateAuthenticator.?, arg.config_location);
|
|
const time = if (arg.time != null) try Time.parseIso8601(arg.time.?) else std.time.timestamp();
|
|
const matches = try authenticator.validate(allocator, arg.validateCode.?, time);
|
|
if (!matches) {
|
|
std.process.exit(1);
|
|
}
|
|
std.process.exit(0);
|
|
} else {
|
|
printHelp();
|
|
}
|
|
}
|
|
|
|
fn printHelp() void {
|
|
const msg =
|
|
\\Usage: zig-totp [command] [options]
|
|
\\
|
|
\\Commands:
|
|
\\ list List the configured authentiators
|
|
\\ show NAME Show the code for the authenticator with name NAME
|
|
\\ validate NAME CODE Validate the code CODE for the authenticator with name NAME
|
|
\\
|
|
\\Options:
|
|
\\ --bash Generate shell integration for bash. Usage: add
|
|
\\ eval "$(zig-totp --bash)"
|
|
\\ to your ~/.bashrc
|
|
\\ --config PATH The path to a config file (default is $XDG_CONFIG_HOME/zig-totp or $HOME/.zig-totp).
|
|
\\ --fish Generate shell integration for fish. Usage:
|
|
\\ zig-totp --fish > $__fish_config_dir/completions/zig-totp.fish
|
|
\\ --help Show this help
|
|
\\ --time Set the time at which a code is computed. Time format is ISO 8601 with second precision for timezone UTC, e.g., 2000-01-01T00:00:00
|
|
\\ --version Print the version number
|
|
\\
|
|
;
|
|
std.debug.print("{s}", .{msg});
|
|
}
|
|
|
|
const Args = struct { list: bool, show: ?[]const u8, config_location: []const u8, validateAuthenticator: ?[]const u8, validateCode: ?[]const u8, print_version: bool, show_help: bool, time: ?[]const u8 };
|
|
|
|
const OtpAuthUrl = struct { name: []const u8, secretEncoded: []const u8, url: []const u8, period: u32, digits: u4, algorithm: []const u8 };
|
|
|
|
const Authenticator = struct {
|
|
url: OtpAuthUrl,
|
|
|
|
/// algorithm based on https://www.rfc-editor.org/rfc/rfc6238
|
|
pub fn code(self: Authenticator, allocator: Allocator, time: i64) ![]const u8 {
|
|
if (self.url.name.len > 0) {}
|
|
|
|
const secret = try Base32.decodeU8(allocator, self.url.secretEncoded);
|
|
defer allocator.free(secret);
|
|
if (false) {
|
|
debug("secret: {s}\n", .{secret});
|
|
}
|
|
|
|
const intervalNumber = @divTrunc(time, self.url.period);
|
|
|
|
var intervalAsU8Array: [8]u8 = undefined;
|
|
std.mem.writePackedInt(i64, intervalAsU8Array[0..], 0, intervalNumber, .big);
|
|
//debug("interval packed: {X}\n", .{intervalAsU8Array});
|
|
|
|
//debug("intervalNumber: {d}\n", .{intervalNumber});
|
|
|
|
if (std.mem.eql(u8, self.url.algorithm, "SHA1")) {
|
|
var hmac: [std.crypto.auth.hmac.HmacSha1.mac_length]u8 = undefined;
|
|
std.crypto.auth.hmac.HmacSha1.create(hmac[0..], &intervalAsU8Array, secret);
|
|
return try self.generateTotp(allocator, &hmac);
|
|
}
|
|
|
|
if (std.mem.eql(u8, self.url.algorithm, "SHA256")) {
|
|
var hmac: [std.crypto.auth.hmac.sha2.HmacSha256.mac_length]u8 = undefined;
|
|
std.crypto.auth.hmac.sha2.HmacSha256.create(hmac[0..], &intervalAsU8Array, secret);
|
|
return try self.generateTotp(allocator, &hmac);
|
|
}
|
|
|
|
if (std.mem.eql(u8, self.url.algorithm, "SHA512")) {
|
|
var hmac: [std.crypto.auth.hmac.sha2.HmacSha512.mac_length]u8 = undefined;
|
|
std.crypto.auth.hmac.sha2.HmacSha512.create(hmac[0..], &intervalAsU8Array, secret);
|
|
return try self.generateTotp(allocator, &hmac);
|
|
}
|
|
|
|
unreachable;
|
|
}
|
|
|
|
fn generateTotp(self: Authenticator, allocator: Allocator, hmac: []const u8) ![]const u8 {
|
|
// take the 4 least significant bits of the hash and use them as byte offset
|
|
const leastSignificantByte = hmac[hmac.len - 1];
|
|
const byteIndex = leastSignificantByte & 0b1111;
|
|
//debug("index: {d}\n", .{byteIndex});
|
|
|
|
const x: [4]u8 = [4]u8{ hmac[byteIndex], hmac[byteIndex + 1], hmac[byteIndex + 2], hmac[byteIndex + 3] };
|
|
|
|
const tokenBase = std.mem.readInt(i32, &x, .big) & 0x7fffffff;
|
|
//debug("tokenBase: {d}\n", .{tokenBase});
|
|
const token = @mod(tokenBase, (std.math.pow(i64, 10, self.url.digits)));
|
|
|
|
const result = try zeroPad(allocator, self.url.digits, token);
|
|
//debug("code as 0-padded string: {s} ({d})\n", .{ result, token });
|
|
return result;
|
|
}
|
|
|
|
fn validate(self: Authenticator, allocator: Allocator, expectedCode: []const u8, time: i64) !bool {
|
|
const actualCode = try self.code(allocator, time);
|
|
defer allocator.free(actualCode);
|
|
return eql(u8, actualCode, expectedCode);
|
|
}
|
|
};
|
|
|
|
fn zeroPad(allocator: Allocator, digits: u4, x: anytype) ![]u8 {
|
|
const result = try allocator.alloc(u8, digits);
|
|
|
|
const s = try std.fmt.allocPrint(allocator, "{d}", .{x});
|
|
defer allocator.free(s);
|
|
|
|
var i: usize = 0;
|
|
var j: usize = 0;
|
|
|
|
while (i < digits) {
|
|
if (j < s.len) {
|
|
result[@as(usize, digits) - i - 1] = s[s.len - j - 1];
|
|
} else {
|
|
result[@as(usize, digits) - i - 1] = '0';
|
|
}
|
|
|
|
j += 1;
|
|
i += 1;
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
const ArgumentError = error{ InvalidOtpAuthUrl, UnknownParameter, MissingConfigLocation, AuthenticatorNotFound, MissingAuthenticatorParam, FailedToOpenConfigFile, TooManyParsinErrors };
|
|
|
|
fn parseArgs(allocator: Allocator, args: []const []const u8) !Args {
|
|
var result = Args{ .list = false, .show = null, .config_location = try configLocation(allocator), .validateAuthenticator = null, .validateCode = null, .print_version = false, .show_help = false, .time = null };
|
|
var i: u17 = 1;
|
|
while (i < args.len) : (i += 1) {
|
|
const arg = args[i];
|
|
|
|
if (eql(u8, "--bash", arg)) {
|
|
const msg =
|
|
\\_zig_totp_completions()
|
|
\\{
|
|
\\ if [ "${COMP_WORDS[$COMP_CWORD-1]}" = 'show' ] || [ "${COMP_WORDS[$COMP_CWORD-1]}" = 'validate' ]
|
|
\\ then
|
|
\\ COMPREPLY=($(compgen -W "$(zig-totp list 2> /dev/null )" -- "${COMP_WORDS[$COMP_CWORD]}" ))
|
|
\\ elif [ "${COMP_WORDS[$COMP_CWORD-2]}" = 'validate' ]
|
|
\\ then
|
|
\\ COMPREPLY=($(compgen -W "CODE" -- "${COMP_WORDS[$COMP_CWORD]}"))
|
|
\\ elif [ "${COMP_WORDS[$COMP_CWORD-1]}" = '--config' ]
|
|
\\ then
|
|
\\ COMPREPLY=($(compgen -A file -- "${COMP_WORDS[$COMP_CWORD]}" ))
|
|
\\ else
|
|
\\ COMPREPLY=($(compgen -W "list show validate --bash --fish --help --time --config" -- "${COMP_WORDS[$COMP_CWORD]}" ))
|
|
\\ fi
|
|
\\}
|
|
\\complete -F _zig_totp_completions zig-totp
|
|
\\
|
|
;
|
|
try std.io.getStdOut().writer().print("{s}\n", .{msg});
|
|
std.process.exit(0);
|
|
} else if (eql(u8, "--fish", arg)) {
|
|
const msg =
|
|
\\# all subcommands zig-totp knows, is useful later
|
|
\\set -l commands list show validate
|
|
\\
|
|
\\# disable file completion so that we have more control
|
|
\\complete -c zig-totp --no-files
|
|
\\
|
|
\\# parameters:
|
|
\\complete -c zig-totp -l bash -d "generate bash integration code"
|
|
\\complete -c zig-totp -l fish -d "generate fish integration code"
|
|
\\complete -c zig-totp -l config --force-files --require-parameter -d 'override config location'
|
|
\\complete -c zig-totp -l time --require-parameter -d 'set time'
|
|
\\complete -c zig-totp -l help -d "show help"
|
|
\\
|
|
\\# sub commands:
|
|
\\complete -c zig-totp -n "not __fish_seen_subcommand_from $commands" -a 'list' -d 'show available authenticators'
|
|
\\
|
|
\\complete -c zig-totp -n "not __fish_seen_subcommand_from $commands" -a 'show' -d 'show the code for an authenticator'
|
|
\\complete -c zig-totp -n "__fish_prev_arg_in show" -xa '(zig-totp list)'
|
|
\\
|
|
\\complete -c zig-totp -n "not __fish_seen_subcommand_from $commands" -a 'validate' -d 'validate code for an authenticator'
|
|
\\complete -c zig-totp -n "__fish_prev_arg_in validate" -xa '(zig-totp list)'
|
|
\\
|
|
;
|
|
|
|
try std.io.getStdOut().writer().print("{s}\n", .{msg});
|
|
std.process.exit(0);
|
|
} else if (eql(u8, "--config", arg)) {
|
|
i += 1;
|
|
if (i >= args.len) {
|
|
std.debug.print("expected a path after parameter '--config'\n see 'zig-totp --help' for help\n", .{});
|
|
return error.MissingConfigLocation;
|
|
}
|
|
result.config_location = args[i];
|
|
} else if (eql(u8, "--help", arg) or eql(u8, "help", arg)) {
|
|
result.show_help = true;
|
|
} else if (eql(u8, "--version", arg)) {
|
|
result.print_version = true;
|
|
} else if (eql(u8, "--time", arg)) {
|
|
i += 1;
|
|
if (i >= args.len) {
|
|
std.debug.print("expected a path after parameter '--time'\n see 'zig-totp --help' for help\n", .{});
|
|
return error.MissingConfigLocation;
|
|
}
|
|
result.time = args[i];
|
|
} else if (eql(u8, "list", arg)) {
|
|
result.list = true;
|
|
} else if (eql(u8, "show", arg)) {
|
|
i += 1;
|
|
if (i >= args.len) {
|
|
std.debug.print("expected authenticator name after command 'show'\n get a list of available authenticators with 'zig-totp list'\n", .{});
|
|
return error.MissingAuthenticatorParam;
|
|
}
|
|
result.show = args[i];
|
|
} else if (eql(u8, "validate", arg)) {
|
|
i += 2;
|
|
if (i >= args.len) {
|
|
std.debug.print("expected two parameters for 'validate'. The name for an authenticator and a code to validate.\n", .{});
|
|
return error.MissingValidateParam;
|
|
}
|
|
result.validateAuthenticator = args[i - 1];
|
|
result.validateCode = args[i];
|
|
} else {
|
|
std.debug.print("unknown argument: {s}\n", .{arg});
|
|
return ArgumentError.UnknownParameter;
|
|
}
|
|
}
|
|
return result;
|
|
}
|
|
|
|
// see https://www.ietf.org/archive/id/draft-linuxgemini-otpauth-uri-01.txt
|
|
// or https://github.com/google/google-authenticator/wiki/Key-Uri-Format
|
|
fn parseOtpAuthUrl(allocator: Allocator, url: []const u8) !OtpAuthUrl {
|
|
const uri = try std.Uri.parse(url);
|
|
|
|
if (!eql(u8, uri.scheme, "otpauth")) {
|
|
return error.InvalidOtpAuthUrl;
|
|
}
|
|
|
|
if (uri.query == null) {
|
|
return error.InvalidOtpAuthUrl;
|
|
}
|
|
|
|
var name = try uri.path.toRawMaybeAlloc(allocator);
|
|
name = std.mem.trimLeft(u8, name, "/");
|
|
|
|
const params = try parseQueryString(allocator, try uri.query.?.toRawMaybeAlloc(allocator));
|
|
|
|
return OtpAuthUrl{
|
|
.name = name,
|
|
.secretEncoded = params.get("secret").?,
|
|
.url = url,
|
|
.period = if (params.get("period") != null) try std.fmt.parseInt(u32, params.get("period").?, 10) else 30,
|
|
.digits = if (params.get("digits") != null) try std.fmt.parseInt(u4, params.get("digits").?, 10) else 6,
|
|
.algorithm = if (params.get("algorithm") != null) params.get("algorithm").? else "SHA1",
|
|
};
|
|
}
|
|
|
|
fn parseQueryString(allocator: Allocator, query: []const u8) !std.StringHashMap([]const u8) {
|
|
var map = std.StringHashMap([]const u8).init(allocator);
|
|
|
|
var it = std.mem.splitSequence(u8, query, "&");
|
|
|
|
while (it.peek() != null) {
|
|
const next = it.next().?;
|
|
|
|
var paramIt = std.mem.splitSequence(u8, next, "=");
|
|
const name = paramIt.next();
|
|
const value = paramIt.next();
|
|
|
|
if (name) |n| {
|
|
if (value) |v| {
|
|
try map.put(n, v);
|
|
} else {
|
|
try map.put(n, "");
|
|
}
|
|
}
|
|
}
|
|
|
|
return map;
|
|
}
|
|
|
|
fn configLocation(allocator: Allocator) ![]const u8 {
|
|
const xdg_config_home: ?[]const u8 = std.process.getEnvVarOwned(allocator, "XDG_CONFIG_HOME") catch null;
|
|
|
|
if (xdg_config_home) |base| {
|
|
const config_location = try std.mem.concat(allocator, u8, &[_][]const u8{ base, "/zig-totp" });
|
|
//debug("config_location: {s}", .{config_location});
|
|
return config_location;
|
|
}
|
|
|
|
const home = std.process.getEnvVarOwned(allocator, "HOME") catch std.process.getEnvVarOwned(allocator, "HOMEPATH") catch null;
|
|
const base = home orelse unreachable;
|
|
const config_location = try std.mem.concat(allocator, u8, &[_][]const u8{ base, "/.zig-totp" });
|
|
|
|
//debug("config_location: {s}", .{config_location});
|
|
return config_location;
|
|
}
|
|
|
|
/// Reads the config file into a list of OtpAuthUrl.
|
|
/// The caller should use an arena allocator and free the memory eventually.
|
|
fn read_config(allocator: Allocator, config_location: []const u8) !ArrayList(OtpAuthUrl) {
|
|
const file_result = std.fs.cwd().openFile(config_location, .{});
|
|
if (file_result) |file| {
|
|
defer file.close();
|
|
|
|
var buf_reader = std.io.bufferedReader(file.reader());
|
|
var in_stream = buf_reader.reader();
|
|
|
|
var authenticators = std.ArrayList(OtpAuthUrl).init(allocator);
|
|
|
|
var number_of_errors: u32 = 0;
|
|
var line_no: usize = 1;
|
|
while (try in_stream.readUntilDelimiterOrEofAlloc(allocator, '\n', 1024 * 1024)) |line| {
|
|
if (line.len > 0 and std.mem.trim(u8, line, " \r").len > 0) {
|
|
const authenticatorResult = parseOtpAuthUrl(allocator, line);
|
|
if (authenticatorResult) |authenticator| {
|
|
try authenticators.append(authenticator);
|
|
} else |err| {
|
|
number_of_errors += 1;
|
|
switch (err) {
|
|
error.UnexpectedCharacter, error.InvalidFormat => {
|
|
try std.io.getStdErr().writer().print("Unexpected character in line {d} in file {s}. Line will be ignored.\n", .{ line_no, config_location });
|
|
if (number_of_errors >= 10) {
|
|
try std.io.getStdErr().writer().print("too many parsing errors\n", .{});
|
|
return ArgumentError.TooManyParsinErrors;
|
|
}
|
|
},
|
|
error.InvalidOtpAuthUrl => {
|
|
try std.io.getStdErr().writer().print("invalid otpauth url in line {d} in {s}\n", .{ line_no, config_location });
|
|
},
|
|
else => {
|
|
return err;
|
|
},
|
|
}
|
|
}
|
|
}
|
|
line_no += 1;
|
|
}
|
|
return authenticators;
|
|
} else |err| {
|
|
//debug("file open error: {}\n", .{err});
|
|
switch (err) {
|
|
error.AccessDenied, //
|
|
error.BadPathName,
|
|
error.InvalidWtf8,
|
|
=> {
|
|
try std.io.getStdErr().writer().print("cannot open config file: {}\n", .{err});
|
|
},
|
|
error.FileNotFound => {
|
|
try std.io.getStdErr().writer().print("config file not found. Create a new config file in $HOME/.zig-totp or $XDG_CONFIG_HOME/zig-totp.\n", .{});
|
|
},
|
|
error.IsDir => {
|
|
try std.io.getStdErr().writer().print("the configuration location must be a file, but is a directory.\n", .{});
|
|
},
|
|
else => {
|
|
return err;
|
|
},
|
|
}
|
|
return ArgumentError.FailedToOpenConfigFile;
|
|
}
|
|
}
|
|
|
|
fn executeGetList(allocator: Allocator, config_location: []const u8) !std.ArrayList([]const u8) {
|
|
var names = std.ArrayList([]const u8).init(allocator);
|
|
|
|
const authenticators = try read_config(allocator, config_location);
|
|
defer authenticators.deinit();
|
|
|
|
for (0..authenticators.items.len) |i| {
|
|
try names.append(authenticators.items[i].name);
|
|
}
|
|
|
|
return names;
|
|
}
|
|
|
|
fn getAuthenticator(allocator: Allocator, name: []const u8, config_location: []const u8) !Authenticator {
|
|
const otpAuthUrls = try read_config(allocator, config_location);
|
|
|
|
for (0..otpAuthUrls.items.len) |i| {
|
|
const url: OtpAuthUrl = otpAuthUrls.items[i];
|
|
if (eql(u8, url.name, name)) {
|
|
return Authenticator{ .url = url };
|
|
}
|
|
}
|
|
return error.AuthenticatorNotFound;
|
|
}
|
|
|
|
test "parse command line parameter: 'list'" {
|
|
var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
|
|
defer arena.deinit();
|
|
const allocator = arena.allocator();
|
|
|
|
const arg = try parseArgs(allocator, &[_][]const u8{ "path/of/executable", "list" });
|
|
try expect(arg.list);
|
|
}
|
|
|
|
test "read list of entries" {
|
|
var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
|
|
defer arena.deinit();
|
|
const allocator = arena.allocator();
|
|
|
|
std.fs.cwd().deleteTree("test-tmp") catch unreachable;
|
|
try std.fs.cwd().makeDir("test-tmp");
|
|
const file = try std.fs.cwd().createFile("test-tmp/zig-totp", .{ .read = true });
|
|
defer {
|
|
file.close();
|
|
std.fs.cwd().deleteTree("test-tmp") catch unreachable;
|
|
}
|
|
|
|
_ = try file.write("otpauth://totp/token1?secret=c2VjcmV0Cg==\notpauth://totp/token2?secret=c2VjcmV0Cg==");
|
|
|
|
const list: std.ArrayList([]const u8) = try executeGetList(allocator, "test-tmp/zig-totp");
|
|
try std.testing.expectEqualStrings("token1", list.items[0]);
|
|
try std.testing.expectEqualStrings("token2", list.items[1]);
|
|
try std.testing.expectEqual(2, list.items.len);
|
|
}
|
|
|
|
test "read list of entries, ignoring invalid otpauth urls" {
|
|
var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
|
|
defer arena.deinit();
|
|
const allocator = arena.allocator();
|
|
|
|
std.fs.cwd().deleteTree("test-tmp") catch unreachable;
|
|
try std.fs.cwd().makeDir("test-tmp");
|
|
const file = try std.fs.cwd().createFile("test-tmp/zig-totp", .{ .read = true });
|
|
defer {
|
|
file.close();
|
|
std.fs.cwd().deleteTree("test-tmp") catch unreachable;
|
|
}
|
|
|
|
_ = try file.write("otpauth://totp/token1?secret=c2VjcmV0Cg==\notpauth://invalid\n");
|
|
|
|
const list: std.ArrayList([]const u8) = try executeGetList(allocator, "test-tmp/zig-totp");
|
|
|
|
try std.testing.expectEqualStrings("token1", list.items[0]);
|
|
try std.testing.expectEqual(1, list.items.len);
|
|
}
|
|
|
|
test "parse oth pauth url" {
|
|
var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
|
|
defer arena.deinit();
|
|
const allocator = arena.allocator();
|
|
|
|
const actual: OtpAuthUrl = try parseOtpAuthUrl(allocator, "otpauth://totp/foo?secret=MFQQ&period=31&digits=7");
|
|
|
|
try std.testing.expectEqualStrings("MFQQ", actual.secretEncoded);
|
|
try std.testing.expectEqualStrings("foo", actual.name);
|
|
try std.testing.expectEqual(31, actual.period);
|
|
try std.testing.expectEqual(7, actual.digits);
|
|
}
|
|
|
|
test "zero padding" {
|
|
const actual = try zeroPad(std.testing.allocator, 6, 123);
|
|
defer std.testing.allocator.free(actual);
|
|
|
|
try std.testing.expectEqualStrings("000123", actual);
|
|
}
|
|
|
|
test "authenticator generate code with 6 digits" {
|
|
const authenticator = Authenticator{ .url = OtpAuthUrl{ .name = "TestAuthenticator", .secretEncoded = "HJ5PX4IFQKD37HFNXLJYBAVD5G6NMMUOKUCDXJ4XTLUUBWSXRXEN5SR4MLAZA5M2", .url = "not needed", .period = 30, .digits = 6, .algorithm = "SHA1" } };
|
|
|
|
const code = try authenticator.code(std.testing.allocator, 1725695340);
|
|
defer std.testing.allocator.free(code);
|
|
try std.testing.expectEqualStrings("218139", code);
|
|
}
|
|
test "authenticator generate code with 10 digits and zero padding" {
|
|
const authenticator = Authenticator{ .url = OtpAuthUrl{ .name = "TestAuthenticator", .secretEncoded = "HJ5PX4IFQKD37HFNXLJYBAVD5G6NMMUOKUCDXJ4XTLUUBWSXRXEN5SR4MLAZA5M2", .url = "not needed", .period = 60, .digits = 10, .algorithm = "SHA1" } };
|
|
|
|
const code = try authenticator.code(std.testing.allocator, 1725695340);
|
|
defer std.testing.allocator.free(code);
|
|
try std.testing.expectEqualStrings("0844221464", code);
|
|
}
|
|
|
|
test "testcases from https://www.rfc-editor.org/rfc/rfc6238#appendix-A" {
|
|
const secretForSha1 = "GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ"; // plain: "12345678901234567890" hex: "3132333435363738393031323334353637383930"
|
|
|
|
const secretForSha256 = "GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQGEZA"; // hex: "3132333435363738393031323334353637383930313233343536373839303132"
|
|
|
|
const secretForSha512 = "GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQGEZDGNA"; // hex: "313233343536373839303132333435363738393031323334353637383930313233343536373839303132333435363738393031323334353637383930"
|
|
|
|
try testTOTP(OtpAuthUrl{ .name = "", .secretEncoded = secretForSha1, .url = "", .period = 30, .digits = 8, .algorithm = "SHA1" }, 59, "94287082");
|
|
|
|
try testTOTP(OtpAuthUrl{ .name = "", .secretEncoded = secretForSha1, .url = "", .period = 30, .digits = 8, .algorithm = "SHA1" }, 1111111109, "07081804");
|
|
|
|
try testTOTP(OtpAuthUrl{ .name = "", .secretEncoded = secretForSha1, .url = "", .period = 30, .digits = 8, .algorithm = "SHA1" }, 1111111111, "14050471");
|
|
|
|
try testTOTP(OtpAuthUrl{ .name = "", .secretEncoded = secretForSha1, .url = "", .period = 30, .digits = 8, .algorithm = "SHA1" }, 1234567890, "89005924");
|
|
|
|
try testTOTP(OtpAuthUrl{ .name = "", .secretEncoded = secretForSha1, .url = "", .period = 30, .digits = 8, .algorithm = "SHA1" }, 2000000000, "69279037");
|
|
|
|
try testTOTP(OtpAuthUrl{ .name = "", .secretEncoded = secretForSha1, .url = "", .period = 30, .digits = 8, .algorithm = "SHA1" }, 20000000000, "65353130");
|
|
|
|
try testTOTP(OtpAuthUrl{ .name = "", .secretEncoded = secretForSha256, .url = "", .period = 30, .digits = 8, .algorithm = "SHA256" }, 59, "46119246");
|
|
|
|
try testTOTP(OtpAuthUrl{ .name = "", .secretEncoded = secretForSha256, .url = "", .period = 30, .digits = 8, .algorithm = "SHA256" }, 1111111109, "68084774");
|
|
|
|
try testTOTP(OtpAuthUrl{ .name = "", .secretEncoded = secretForSha256, .url = "", .period = 30, .digits = 8, .algorithm = "SHA256" }, 1111111111, "67062674");
|
|
|
|
try testTOTP(OtpAuthUrl{ .name = "", .secretEncoded = secretForSha256, .url = "", .period = 30, .digits = 8, .algorithm = "SHA256" }, 1234567890, "91819424");
|
|
|
|
try testTOTP(OtpAuthUrl{ .name = "", .secretEncoded = secretForSha256, .url = "", .period = 30, .digits = 8, .algorithm = "SHA256" }, 2000000000, "90698825");
|
|
|
|
try testTOTP(OtpAuthUrl{ .name = "", .secretEncoded = secretForSha256, .url = "", .period = 30, .digits = 8, .algorithm = "SHA256" }, 20000000000, "77737706");
|
|
|
|
try testTOTP(OtpAuthUrl{ .name = "", .secretEncoded = secretForSha512, .url = "", .period = 30, .digits = 8, .algorithm = "SHA512" }, 59, "90693936");
|
|
|
|
try testTOTP(OtpAuthUrl{ .name = "", .secretEncoded = secretForSha512, .url = "", .period = 30, .digits = 8, .algorithm = "SHA512" }, 1111111109, "25091201");
|
|
|
|
try testTOTP(OtpAuthUrl{ .name = "", .secretEncoded = secretForSha512, .url = "", .period = 30, .digits = 8, .algorithm = "SHA512" }, 1111111111, "99943326");
|
|
|
|
try testTOTP(OtpAuthUrl{ .name = "", .secretEncoded = secretForSha512, .url = "", .period = 30, .digits = 8, .algorithm = "SHA512" }, 1234567890, "93441116");
|
|
|
|
try testTOTP(OtpAuthUrl{ .name = "", .secretEncoded = secretForSha512, .url = "", .period = 30, .digits = 8, .algorithm = "SHA512" }, 2000000000, "38618901");
|
|
|
|
try testTOTP(OtpAuthUrl{ .name = "", .secretEncoded = secretForSha512, .url = "", .period = 30, .digits = 8, .algorithm = "SHA512" }, 20000000000, "47863826");
|
|
}
|
|
|
|
fn testTOTP(otpAuthUrl: OtpAuthUrl, time: i64, expected: []const u8) !void {
|
|
const authenticator = Authenticator{ .url = otpAuthUrl };
|
|
|
|
const code = try authenticator.code(std.testing.allocator, time);
|
|
defer std.testing.allocator.free(code);
|
|
try std.testing.expectEqualStrings(expected, code);
|
|
}
|
|
|
|
test "secret is not Base32 - invalid padding" {
|
|
const authenticator = Authenticator{ .url = OtpAuthUrl{ .name = "", .secretEncoded = "H", .url = "", .period = 60, .digits = 6, .algorithm = "SHA1" } };
|
|
|
|
const actualError = authenticator.code(std.testing.allocator, 1725695340);
|
|
try std.testing.expectError(Base32Error.InvalidPadding, actualError);
|
|
}
|
|
test "secret is not Base32 - invalid padding, input has too many bytes" {
|
|
const authenticator = Authenticator{ .url = OtpAuthUrl{ .name = "", .secretEncoded = "HAX", .url = "", .period = 60, .digits = 6, .algorithm = "SHA1" } };
|
|
|
|
const actualError = authenticator.code(std.testing.allocator, 1725695340);
|
|
try std.testing.expectError(Base32Error.InvalidPadding, actualError);
|
|
}
|
|
test "secret is not Base32 - invalid padding, trailing bits are not 0" {
|
|
const authenticator = Authenticator{ .url = OtpAuthUrl{ .name = "", .secretEncoded = "HX", .url = "", .period = 60, .digits = 6, .algorithm = "SHA1" } };
|
|
|
|
const actualError = authenticator.code(std.testing.allocator, 1725695340);
|
|
try std.testing.expectError(Base32Error.InvalidPadding, actualError);
|
|
}
|
|
|
|
test "secret is not Base32 - invalid character" {
|
|
const authenticator = Authenticator{ .url = OtpAuthUrl{ .name = "", .secretEncoded = "0", .url = "", .period = 60, .digits = 6, .algorithm = "SHA1" } };
|
|
|
|
const actualError = authenticator.code(std.testing.allocator, 1725695340);
|
|
try std.testing.expectError(Base32Error.InvalidCharacter, actualError);
|
|
}
|
|
|
|
test "testcases for 'validate'" {
|
|
const secretForSha1 = "GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ"; // plain: "12345678901234567890" hex: "3132333435363738393031323334353637383930"
|
|
|
|
try testValidate(OtpAuthUrl{ .name = "", .secretEncoded = secretForSha1, .url = "", .period = 30, .digits = 8, .algorithm = "SHA1" }, 59, "94287082", true);
|
|
|
|
try testValidate(OtpAuthUrl{ .name = "", .secretEncoded = secretForSha1, .url = "", .period = 30, .digits = 8, .algorithm = "SHA1" }, 1111111109, "07081804", true);
|
|
|
|
try testValidate(OtpAuthUrl{ .name = "", .secretEncoded = secretForSha1, .url = "", .period = 30, .digits = 8, .algorithm = "SHA1" }, 1111111109, "12345678", false);
|
|
}
|
|
fn testValidate(otpAuthUrl: OtpAuthUrl, time: i64, expected: []const u8, expectedMatch: bool) !void {
|
|
const authenticator = Authenticator{ .url = otpAuthUrl };
|
|
|
|
const match = try authenticator.validate(std.testing.allocator, expected, time);
|
|
try std.testing.expectEqual(expectedMatch, match);
|
|
}
|