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); }