// SPDX-License-Identifier: AGPL-3.0-only // SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® module Crafter.Review:Totp_impl; import :Totp; import :Json; import :Text; import :Crypto; import :Platform; import std; namespace Crafter::Review::Totp { namespace { constexpr std::string_view Usage = R"(A current TOTP code (RFC 6238, SHA-1, 30 s, 6 digits) for a login fixture - what the functional lens types into the one-time-code field. crafter-review totp the fixture's `totpSecret` (base32), as the consumer's login seeder writes it crafter-review totp --secret Prints the code and the seconds left in its window; the lens regenerates when the window rolled over.)"; // Floor division and modulo of floats, for a positive step: fmod, then // the quotient of what is left, floored. double FloorDiv(double x, double y) { const double mod = std::fmod(x, y); double div = (x - mod) / y; if (mod != 0.0 && ((y < 0) != (mod < 0))) div -= 1.0; if (div == 0.0) return std::copysign(0.0, x / y); double floored = std::floor(div); if (div - floored > 0.5) floored += 1.0; return floored; } double Mod(double x, double y) { double mod = std::fmod(x, y); if (mod != 0.0 && ((y < 0) != (mod < 0))) mod += y; return mod; } } Code At(std::string_view secret, double now, std::int32_t step, std::int32_t digits) { // secret.strip().replace(' ', '').upper().rstrip('='), then padded. const std::string s = Dyn::RStrip(Dyn::Upper(Dyn::Replace(Dyn::Strip(secret), " ", "")), "="); const std::size_t length = Dyn::Length(s); const std::string padded = s + std::string((8 - length % 8) % 8, '='); if (!std::ranges::all_of(padded, [](char c) { return static_cast(c) < 0x80; })) { throw std::invalid_argument("string argument should contain only ASCII characters"); } const std::string key = Base32Decode(padded); const auto counter = static_cast(FloorDiv(now, step)); std::string message(8, '\0'); for (std::int32_t i = 0; i < 8; ++i) message[static_cast(i)] = static_cast((counter >> (56 - 8 * i)) & 0xFF); const std::string h = HmacSha1(key, message); const std::size_t o = static_cast(h.back()) & 0x0F; std::uint32_t value = 0; for (std::size_t i = 0; i < 4; ++i) value = (value << 8) | static_cast(h[o + i]); std::uint64_t modulus = 1; for (std::int32_t i = 0; i < digits; ++i) modulus *= 10; std::string code = std::to_string((value & 0x7FFFFFFF) % modulus); if (code.size() < static_cast(digits)) code.insert(0, static_cast(digits) - code.size(), '0'); return {code, step - static_cast(Mod(now, step))}; } std::int32_t Main(std::span args) { std::string secret; try { if (args.size() == 2 && args[0] == "--secret") { secret = std::string(args[1]); } else if (args.size() == 1) { const Json fixture = Dyn::LoadJsonFile(std::string(args[0])); const Json* found = nullptr; for (const std::string_view key : {"totpSecret", "totp_secret", "secret"}) { const Json& value = Dyn::Get(fixture, key); if (Dyn::Truthy(value)) { found = &value; break; } } if (!found) { std::println(std::cerr, "{}: no totpSecret in the fixture", args[0]); return 1; } if (!found->is_string()) throw Dyn::Error("AttributeError", std::format("'{}' object has no attribute 'strip'", Dyn::TypeName(*found))); secret = found->get(); } else { std::println(std::cerr, "{}", Usage); return 1; } const Code code = At(secret, Platform::Now()); std::println("{} (valid {}s)", code.code, code.left); return 0; } catch (const std::exception& error) { std::println(std::cerr, "totp: {}", error.what()); return 1; } } }