Crafter.Review/implementations/Crafter.Review-Totp.cpp

97 lines
4.4 KiB
C++
Raw Normal View History

// 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 <fixture.json> the fixture's `totpSecret` (base32), as the consumer's login seeder writes it
crafter-review totp --secret <base32>
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<std::uint8_t>(c) < 0x80; })) {
throw std::invalid_argument("string argument should contain only ASCII characters");
}
const std::string key = Base32Decode(padded);
const auto counter = static_cast<std::uint64_t>(FloorDiv(now, step));
std::string message(8, '\0');
for (std::int32_t i = 0; i < 8; ++i) message[static_cast<std::size_t>(i)] = static_cast<char>((counter >> (56 - 8 * i)) & 0xFF);
const std::string h = HmacSha1(key, message);
const std::size_t o = static_cast<std::uint8_t>(h.back()) & 0x0F;
std::uint32_t value = 0;
for (std::size_t i = 0; i < 4; ++i) value = (value << 8) | static_cast<std::uint8_t>(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<std::size_t>(digits)) code.insert(0, static_cast<std::size_t>(digits) - code.size(), '0');
return {code, step - static_cast<std::int32_t>(Mod(now, step))};
}
std::int32_t Main(std::span<const std::string_view> 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<std::string>();
} 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;
}
}
}