97 lines
4.4 KiB
C++
97 lines
4.4 KiB
C++
|
|
// 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;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|