Compare commits

..

23 Commits

Author SHA1 Message Date
b329bc73e1 add parser for pre_release version
Some checks failed
Build / x86_64-linux-gnu (push) Failing after 18s
Build / x86_64-linux-musl (push) Failing after 18s
Build / x86_64-windows (push) Failing after 18s
2025-08-02 12:45:53 +02:00
cd8a9b0c76 try upload-artifact@v3
Some checks failed
Build / x86_64-linux-gnu (push) Failing after 18s
Build / x86_64-linux-musl (push) Failing after 18s
Build / x86_64-windows (push) Failing after 18s
2025-08-02 12:34:32 +02:00
4405bf4ab0 try caching
All checks were successful
Build / build (push) Successful in 22s
Build / x86_64-linux-gnu (push) Successful in 54s
Build / x86_64-linux-musl (push) Successful in 54s
Build / x86_64-windows (push) Successful in 53s
2025-08-02 12:28:08 +02:00
f5acf8af4e use target zig understands
All checks were successful
Build / x86_64-linux-gnu (push) Successful in 26s
Build / x86_64-linux-musl (push) Successful in 24s
Build / x86_64-windows (push) Successful in 23s
Build / build (push) Successful in 30s
2025-08-02 10:44:50 +02:00
6510314dea forgot the target
Some checks failed
Build / build (push) Successful in 21s
Build / x86_64-unknown-linux-musl (push) Failing after 15s
Build / x86_64-windows (push) Successful in 19s
2025-08-02 10:37:01 +02:00
ea5dc4d144 try matrix build
Some checks failed
Build / build (push) Successful in 21s
Build / x86_64-unknown-linux-musl (push) Failing after 15s
Build / x86_64-windows (push) Failing after 15s
2025-08-02 10:34:54 +02:00
4ff826d9f4 fetch complete repo
Some checks failed
Build / build (push) Failing after 1m10s
2025-07-28 19:03:08 +02:00
769573a23b try to upload artifacts
Some checks failed
Build / build (push) Failing after 32s
2025-07-28 18:55:50 +02:00
d5632dea6e fetch tags
All checks were successful
Build / build (push) Successful in 22s
2025-07-28 18:49:40 +02:00
83efe0547e cross compile for windows and linux
All checks were successful
Build / build (push) Successful in 23s
2025-07-28 18:46:41 +02:00
761d9086cd moving the zig binary doesn't work
Some checks failed
Build / build (push) Failing after 21s
2025-07-27 17:58:24 +02:00
2214ae92d1 I am so stupid
Some checks failed
Build / build (push) Failing after 8s
2025-07-27 17:56:39 +02:00
a0147b8216 host.docker.internal doesn't work, try magic ip instead
Some checks failed
Build / build (push) Failing after 8s
2025-07-27 17:55:40 +02:00
e1d195f236 brainfart
Some checks failed
Build / build (push) Failing after 5s
2025-07-27 17:54:53 +02:00
0fbea3d981 cannot use localhost in a container, obviously
Some checks failed
Build / build (push) Failing after 5s
2025-07-27 17:51:51 +02:00
25e2c10bb2 ubuntu slim does not have wget or curl
Some checks failed
Build / build (push) Failing after 5s
2025-07-27 17:50:33 +02:00
a296b509ae typo in name for checkout action
Some checks failed
Build / build (push) Failing after 13s
2025-07-27 17:46:56 +02:00
8b946e3423 1st try of a build workflow
Some checks failed
Build / build (push) Failing after 19s
2025-07-27 17:45:03 +02:00
f5f1e1593b add parameter --time
You can not generate a code for a specific time. Currently only UTC is
supported.
2025-03-09 20:19:45 +01:00
ff7d1ede59 update to zig 0.14.0
build.zig.zon now requires a fingerprint and there was some issue with
the name
2025-03-09 20:17:17 +01:00
3f3c3198f5 add completions for fish and update for bash 2025-03-09 10:57:52 +01:00
b4f82ebfda add option to show help 2025-03-08 19:31:05 +01:00
21a4c231a3 add command line parameter to print the version
The version is defined in build.zig. We use git describe to the
the build version. For release versions we enforce that the version
in build.zig and build.zig.zon must be equal to the output of
git describe.
2025-03-08 19:19:52 +01:00
5 changed files with 326 additions and 79 deletions

View File

@@ -0,0 +1,34 @@
name: Build
run-name: ${{ gitea.actor }} is building
on: [push]
jobs:
release:
name: ${{ matrix.target }}
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
include:
- target: x86_64-linux-musl
- target: x86_64-linux-gnu
- target: x86_64-windows
steps:
- run: printenv
- name: Check out repository
uses: actions/checkout@v4
with:
fetch-depth: 0
fetch-tags: ''
- run: wget http://172.17.0.1:8081/repository/ziglang.org/download/0.14.1/zig-x86_64-linux-0.14.1.tar.xz
- run: tar xfv zig-x86_64-linux-0.14.1.tar.xz
- run: ./zig-x86_64-linux-0.14.1/zig build -Doptimize=ReleaseSmall -Dtarget=${{ matrix.target }} -Dexe_name=zig-totp-$GITHUB_REF_NAME-${{ matrix.target }}
- name: upload artifacts
uses: actions/upload-artifact@v3
with:
name: zig-totp-${{matrix.target }}
path: zig-out

View File

@@ -1,11 +1,16 @@
const std = @import("std");
// TODO TODO
// Must match the version in build.zig.zon
const totp_version: std.SemanticVersion = .{ .major = 0, .minor = 1, .patch = 0, .pre = "dev" };
// Although this function looks imperative, note that its job is to
// declaratively construct a build graph that will be executed by an external
// runner.
pub fn build(b: *std.Build) void {
const version = getVersion(b);
const options = b.addOptions();
options.addOption(std.SemanticVersion, "version", version);
// Standard target options allows the person running `zig build` to choose
// what target to build for. Here we do not override the defaults, which
// means any target is allowed, and the default is native. Other options
@@ -17,8 +22,10 @@ pub fn build(b: *std.Build) void {
// set a preferred release mode, allowing the user to decide how to optimize.
const optimize = b.standardOptimizeOption(.{});
const exe_name = b.option([]const u8, "exe_name", "Name of the executable") orelse "zig-totp";
const lib = b.addStaticLibrary(.{
.name = "zig-totp",
.name = exe_name,
// In this case the main source file is merely a path, however, in more
// complicated build scripts, this could be a generated file.
.root_source_file = b.path("src/root.zig"),
@@ -32,11 +39,12 @@ pub fn build(b: *std.Build) void {
b.installArtifact(lib);
const exe = b.addExecutable(.{
.name = "zig-totp",
.name = exe_name,
.root_source_file = b.path("src/main.zig"),
.target = target,
.optimize = optimize,
});
exe.root_module.addOptions("config", options);
// This declares intent for the executable to be installed into the
// standard location when the user invokes the "install" step (the default
@@ -91,3 +99,66 @@ pub fn build(b: *std.Build) void {
test_step.dependOn(&run_lib_unit_tests.step);
test_step.dependOn(&run_exe_unit_tests.step);
}
fn getVersion(b: *std.Build) std.SemanticVersion {
// if this is a release version , aka this is not a pre-release or build version,
// then use the specified version
if (totp_version.pre == null and totp_version.build == null) return totp_version;
// for pre-release version we use the git version
const args: []const []const u8 = &.{ "git", "-C", b.pathFromRoot("."), "describe", "--match", "*.*.*", "--tags" };
var out_code: u8 = undefined;
const output_untrimmed = b.runAllowFail(args, &out_code, .Ignore) catch |err| {
std.log.warn(
\\ failed to run git describe: {}
, .{err});
return totp_version;
};
const output_trimmed = std.mem.trim(u8, output_untrimmed, " \r\n");
switch (std.mem.count(u8, output_trimmed, "-")) {
0 => {
// release version, e.g. 1.0.0
if (!std.mem.eql(u8, output_trimmed, b.fmt("{}", .{totp_version}))) {
std.debug.panic("the version in build.zig and build.zig.zon must match the tag in git", .{});
}
return totp_version;
},
1 => {
// prerelease version: 1.0.0-dev
var iter = std.mem.splitScalar(u8, output_trimmed, '-');
const tag = iter.first();
const pre_release = iter.next().?;
const v: std.SemanticVersion = std.SemanticVersion.parse(tag) catch unreachable;
return .{
.major = v.major,
.minor = v.minor,
.patch = v.patch,
.pre = b.fmt("{s}", .{pre_release}),
};
},
2 => {
// development version, e.g. 1.0.0-7-64es356
var iter = std.mem.splitScalar(u8, output_trimmed, '-');
const tag = iter.first();
const commits_since_tag = iter.next().?;
const commit_hash = iter.next().?;
const v: std.SemanticVersion = std.SemanticVersion.parse(tag) catch unreachable;
return .{
.major = v.major,
.minor = v.minor,
.patch = v.patch,
.pre = b.fmt("dev.{s}", .{commits_since_tag}),
.build = commit_hash[1..],
};
},
else => {
std.debug.print("unexpected output of git describe: '{s}'\n", .{output_untrimmed});
std.process.exit(1);
},
}
}

View File

@@ -1,13 +1,27 @@
.{
.name = "zig-totp",
.name = .f,
// This is a [Semantic Version](https://semver.org/).
// In a future version of Zig it will be used for package deduplication.
.version = "0.0.0",
.version = "0.1.0-dev",
// Together with name, this represents a globally unique package
// identifier. This field is generated by the Zig toolchain when the
// package is first created, and then *never changes*. This allows
// unambiguous detection of one package being an updated version of
// another.
//
// When forking a Zig project, this id should be regenerated (delete the
// field and run `zig build`) if the upstream project is still maintained.
// Otherwise, the fork is *hostile*, attempting to take control over the
// original project's identity. Thus it is recommended to leave the comment
// on the following line intact, so that it shows up in code reviews that
// modify the field.
.fingerprint = 0x76d32be0e41821b4, // Changing this has security and trust implications.
// This field is optional.
// This is currently advisory only; Zig does not yet do anything
// with this value.
//.minimum_zig_version = "0.11.0",
//.minimum_zig_version = "0.14.0",
// This field is optional.
// Each dependency must either provide a `url` and `hash`, or a `path`.

View File

@@ -10,6 +10,9 @@ 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
@@ -32,6 +35,9 @@ pub fn main() !void {
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});
},
@@ -49,7 +55,9 @@ fn mainInternal() !void {
const arg: Args = try parseArgs(allocator, args);
//print("parsed Args: {?any}\n", arg);
if (arg.list) {
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| {
@@ -57,11 +65,13 @@ fn mainInternal() !void {
}
} else if (arg.show != null) {
const authenticator = try getAuthenticator(allocator, arg.show.?, arg.config_location);
const code = try authenticator.code(allocator, std.time.timestamp);
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 matches = try authenticator.validate(allocator, arg.validateCode.?, std.time.timestamp);
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);
}
@@ -81,16 +91,21 @@ fn printHelp() void {
\\ validate NAME CODE Validate the code CODE for the authenticator with name NAME
\\
\\Options:
\\ --bash Print script to set up Bash shell integration. Usage: add
\\ --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).
\\
\\ --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 };
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 };
@@ -98,7 +113,7 @@ const Authenticator = struct {
url: OtpAuthUrl,
/// algorithm based on https://www.rfc-editor.org/rfc/rfc6238
pub fn code(self: Authenticator, allocator: Allocator, timeFunc: *const fn () i64) ![]const u8 {
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);
@@ -107,7 +122,7 @@ const Authenticator = struct {
debug("secret: {s}\n", .{secret});
}
const intervalNumber = @divTrunc(timeFunc(), self.url.period);
const intervalNumber = @divTrunc(time, self.url.period);
var intervalAsU8Array: [8]u8 = undefined;
std.mem.writePackedInt(i64, intervalAsU8Array[0..], 0, intervalNumber, .big);
@@ -153,8 +168,8 @@ const Authenticator = struct {
return result;
}
fn validate(self: Authenticator, allocator: Allocator, expectedCode: []const u8, timeFunc: *const fn () i64) !bool {
const actualCode = try self.code(allocator, timeFunc);
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);
}
@@ -186,7 +201,7 @@ fn zeroPad(allocator: Allocator, digits: u4, x: anytype) ![]u8 {
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 };
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];
@@ -205,21 +220,60 @@ fn parseArgs(allocator: Allocator, args: []const []const u8) !Args {
\\ then
\\ COMPREPLY=($(compgen -A file -- "${COMP_WORDS[$COMP_CWORD]}" ))
\\ else
\\ COMPREPLY=($(compgen -W "list show validate --bash --config" -- "${COMP_WORDS[$COMP_CWORD]}" ))
\\ 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' for help\n", .{});
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)) {
@@ -477,14 +531,14 @@ test "zero padding" {
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, _closure_return_1725695340);
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, _closure_return_1725695340);
const code = try authenticator.code(std.testing.allocator, 1725695340);
defer std.testing.allocator.free(code);
try std.testing.expectEqualStrings("0844221464", code);
}
@@ -496,117 +550,89 @@ test "testcases from https://www.rfc-editor.org/rfc/rfc6238#appendix-A" {
const secretForSha512 = "GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQGEZDGNA"; // hex: "313233343536373839303132333435363738393031323334353637383930313233343536373839303132333435363738393031323334353637383930"
try testTOTP(OtpAuthUrl{ .name = "", .secretEncoded = secretForSha1, .url = "", .period = 30, .digits = 8, .algorithm = "SHA1" }, _closure_return_59, "94287082");
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" }, _closure_return_1111111109, "07081804");
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" }, _closure_return_1111111111, "14050471");
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" }, _closure_return_1234567890, "89005924");
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" }, _closure_return_2000000000, "69279037");
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" }, _closure_return_20000000000, "65353130");
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" }, _closure_return_59, "46119246");
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" }, _closure_return_1111111109, "68084774");
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" }, _closure_return_1111111111, "67062674");
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" }, _closure_return_1234567890, "91819424");
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" }, _closure_return_2000000000, "90698825");
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" }, _closure_return_20000000000, "77737706");
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" }, _closure_return_59, "90693936");
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" }, _closure_return_1111111109, "25091201");
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" }, _closure_return_1111111111, "99943326");
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" }, _closure_return_1234567890, "93441116");
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" }, _closure_return_2000000000, "38618901");
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" }, _closure_return_20000000000, "47863826");
try testTOTP(OtpAuthUrl{ .name = "", .secretEncoded = secretForSha512, .url = "", .period = 30, .digits = 8, .algorithm = "SHA512" }, 20000000000, "47863826");
}
fn testTOTP(otpAuthUrl: OtpAuthUrl, timeFunc: *const fn () i64, expected: []const u8) !void {
fn testTOTP(otpAuthUrl: OtpAuthUrl, time: i64, expected: []const u8) !void {
const authenticator = Authenticator{ .url = otpAuthUrl };
const code = try authenticator.code(std.testing.allocator, timeFunc);
const code = try authenticator.code(std.testing.allocator, time);
defer std.testing.allocator.free(code);
try std.testing.expectEqualStrings(expected, code);
}
fn _closure_return_1725695340() i64 {
return 1725695340;
}
fn _closure_return_59() i64 {
return 59;
}
fn _closure_return_1111111109() i64 {
return 1111111109;
}
fn _closure_return_1111111111() i64 {
return 1111111111;
}
fn _closure_return_1234567890() i64 {
return 1234567890;
}
fn _closure_return_2000000000() i64 {
return 2000000000;
}
fn _closure_return_20000000000() i64 {
return 20000000000;
}
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, _closure_return_1725695340);
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, _closure_return_1725695340);
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, _closure_return_1725695340);
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, _closure_return_1725695340);
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" }, _closure_return_59, "94287082", true);
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" }, _closure_return_1111111109, "07081804", 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" }, _closure_return_1111111109, "12345678", false);
try testValidate(OtpAuthUrl{ .name = "", .secretEncoded = secretForSha1, .url = "", .period = 30, .digits = 8, .algorithm = "SHA1" }, 1111111109, "12345678", false);
}
fn testValidate(otpAuthUrl: OtpAuthUrl, timeFunc: *const fn () i64, expected: []const u8, expectedMatch: bool) !void {
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, timeFunc);
const match = try authenticator.validate(std.testing.allocator, expected, time);
try std.testing.expectEqual(expectedMatch, match);
}

102
src/time.zig Normal file
View File

@@ -0,0 +1,102 @@
const std = @import("std");
const expect = std.testing.expect;
const expectEqualStrings = std.testing.expectEqualStrings;
const expectEqual = std.testing.expectEqual;
const Allocator = std.mem.Allocator;
pub const TimeError = error{ParsingError};
pub const Time = struct {
/// expecting a date in the format yyyy-MM-dd'T'HH:mm:ss
/// returning a unix timestamp
/// does not handle leap seconds
/// only handles UTC
pub fn parseIso8601(data: []const u8) !i64 {
const format = "nnnn-nn-nnTnn:nn:nn";
// 0123456789012345678
// validate the format
if (data.len != 19) {
return TimeError.ParsingError;
}
for (format, 0..) |formatChar, index| {
if (index >= data.len) {
return TimeError.ParsingError;
}
const c = data[index];
switch (formatChar) {
'n' => {
if (!isDigit(c)) return TimeError.ParsingError;
},
else => {
if (formatChar != c) return TimeError.ParsingError;
},
}
}
const year = try std.fmt.parseInt(i64, data[0..4], 10);
const month = try std.fmt.parseInt(i64, data[5..7], 10);
const day = try std.fmt.parseInt(i64, data[8..10], 10);
const hour = try std.fmt.parseInt(i64, data[11..13], 10);
const minute = try std.fmt.parseInt(i64, data[14..16], 10);
const second = try std.fmt.parseInt(i64, data[17..19], 10);
const unixTime: i64 = (year - 1970) * 365 * 24 * 3600 //
+ leapDays(year, month) * 24 * 3600 //
+ dayInYear(month, day) * 24 * 3600 //
+ hour * 3600 //
+ minute * 60 //
+ second;
return unixTime;
}
fn isDigit(byte: u8) bool {
return byte >= '0' and byte <= '9';
}
fn dayInYear(month: i64, day: i64) i64 {
const daysPerMonth = [_]u8{ 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31 };
var result: i64 = 0;
var i: u8 = 0;
while (i < (month - 1)) : (i += 1) {
result += daysPerMonth[i];
}
result += day - 1;
return result;
}
fn leapDays(year: i64, month: i64) i64 {
var i: u64 = 1972;
var result: i64 = 0;
while (i < year) : (i += 1) {
if (@mod(i, 4) == 0 and (i % 100 != 0 or @mod(i, 400) == 0)) {
result += 1;
}
}
if (month > 2 and @mod(year, 4) == 0 and (@mod(year, 100) != 0 or @mod(year, 400) == 0)) {
result += 1;
}
return result;
}
};
test "parsing error 'x025-01-01T00:00:00'" {
try std.testing.expectError(TimeError.ParsingError, Time.parseIso8601("x025-01-01T00:00:00"));
}
test "parsing error '2025-01-01'" {
try std.testing.expectError(TimeError.ParsingError, Time.parseIso8601("2025-01-01T"));
}
//test "parsing validation successfull" {
// const y = try Time.parseIso8601("2025-12-13T01:02:03");
// try expectEqual(12, y);
//}
test "parse 2025-02-18T18:37:45, expecting 1739903865" {
const unixTime = try Time.parseIso8601("2025-02-18T18:37:45");
try expectEqual(1739903865, unixTime);
}