feat(sql): the database helper and the TOTP helper, as subcommands
`crafter-review sql` hands the SQL to the driver's broker named in REVIEW_GATES_DB; the broker runs it as one transaction, rolled back, and streams the lines back with the exit code last. The client speaks protocol 3.0 over Crafter.Network's TCP client: cleartext, MD5 and SCRAM-SHA-256, one Parse per statement, the transaction status as the backstop. The splitter, the refusals, the table layout and every message work on code points; connect and socket errors read "[Errno 111] Connection refused" or "timed out". `crafter-review totp` prints "123456 (valid 17s)". No sleep after the last connect attempt; TOTP reads the clock once; a broker token that is no string gets the NO DATABASE answer and the exit mark. The goldens add the splitter, the comment stripping, the fingerprint, the refusals, the first words, the headline, the cell, table, error and notice layouts, the run's refusals and limits and 40 TOTP codes (2774 checks now). tests/PgWire runs the helper over a Postgres stand-in and compares its output with the goldens, line for line, plus the broker's protocol. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
61acd6a72e
commit
e8a58f893f
10 changed files with 3666 additions and 3 deletions
97
implementations/Crafter.Review-Totp.cpp
Normal file
97
implementations/Crafter.Review-Totp.cpp
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
// 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue