16#include <QCryptographicHash>
19#include <QMessageAuthenticationCode>
29constexpr char kSteamAlphabet[] =
"23456789BCDFGHJKMNPQRTVWXY";
31constexpr quint64 kSteamAlphabetSize =
sizeof(kSteamAlphabet) - 1;
33constexpr int kMinHmacSize = 20;
41 const QString upper = name.toUpper();
42 if (upper == QStringLiteral(
"SHA512") ||
43 upper == QStringLiteral(
"HMAC-SHA-512")) {
46 if (upper == QStringLiteral(
"SHA256") ||
47 upper == QStringLiteral(
"HMAC-SHA-256")) {
61 return QStringLiteral(
"SHA512");
63 return QStringLiteral(
"SHA256");
67 return QStringLiteral(
"SHA1");
75auto cryptoHash(
Totp::Algorithm algorithm) -> QCryptographicHash::Algorithm {
78 return QCryptographicHash::Sha512;
80 return QCryptographicHash::Sha256;
84 return QCryptographicHash::Sha1;
88auto encodeLabel(
const QString &value) -> QString {
89 return QString::fromLatin1(QUrl::toPercentEncoding(value,
":@"));
98auto parsePositiveUInt(
const QString &value, uint fallback) -> uint {
100 const uint parsed = value.toUInt(&ok);
101 return (ok && parsed > 0) ? parsed : fallback;
106auto Totp::parse(
const QString &config) -> std::optional<Settings> {
113 const QString trimmed = config.trimmed();
114 if (trimmed.isEmpty()) {
121 const QUrl url(trimmed);
122 if (url.isValid() && url.scheme().compare(QStringLiteral(
"otpauth"),
123 Qt::CaseInsensitive) == 0) {
126 if (url.host().compare(QStringLiteral(
"totp"), Qt::CaseInsensitive) != 0) {
130 const QUrlQuery query(url);
132 query.queryItemValue(QStringLiteral(
"secret"), QUrl::FullyDecoded);
134 query.queryItemValue(QStringLiteral(
"issuer"), QUrl::FullyDecoded);
136 settings.
label = url.path();
137 if (settings.
label.startsWith(QLatin1Char(
'/'))) {
138 settings.
label.remove(0, 1);
144 settings.
digits = parsePositiveUInt(
146 settings.
step = parsePositiveUInt(
147 query.queryItemValue(QStringLiteral(
"period")),
DEFAULT_STEP);
148 if (query.hasQueryItem(QStringLiteral(
"algorithm"))) {
150 algorithmByName(query.queryItemValue(QStringLiteral(
"algorithm")));
152 if (query.queryItemValue(QStringLiteral(
"encoder"))
153 .compare(QStringLiteral(
"steam"), Qt::CaseInsensitive) == 0) {
158 static const QStringList known = {
159 QStringLiteral(
"secret"), QStringLiteral(
"issuer"),
160 QStringLiteral(
"digits"), QStringLiteral(
"period"),
161 QStringLiteral(
"algorithm"), QStringLiteral(
"encoder")};
164 const QList<QPair<QString, QString>> items =
165 query.queryItems(QUrl::FullyDecoded);
166 for (
const QPair<QString, QString> &item : items) {
167 if (!known.contains(item.first, Qt::CaseInsensitive)) {
171 }
else if (trimmed.startsWith(QStringLiteral(
"otpauth:"),
172 Qt::CaseInsensitive)) {
185 if (settings.
key.isEmpty()) {
188 settings.
base32Key = QString::fromLatin1(sanitized);
202 return parse(config).has_value();
206 return unixTime / settings.step;
211 return settings.step -
static_cast<uint
>(unixTime % settings.step);
215 const quint64 bigEndianCounter = qToBigEndian(
counter(settings, unixTime));
216 const QByteArray message(
reinterpret_cast<const char *
>(&bigEndianCounter),
217 sizeof(bigEndianCounter));
219 const QByteArray hmac = QMessageAuthenticationCode::hash(
220 message, settings.key, cryptoHash(settings.algorithm));
221 if (hmac.size() < kMinHmacSize) {
229 static_cast<quint8
>(hmac.at(hmac.size() - 1)) & 0x0F;
230 const quint32 binary =
231 (
static_cast<quint32
>(
static_cast<quint8
>(hmac.at(offset)) & 0x7F)
233 (
static_cast<quint32
>(
static_cast<quint8
>(hmac.at(offset + 1))) << 16) |
234 (
static_cast<quint32
>(
static_cast<quint8
>(hmac.at(offset + 2))) << 8) |
235 static_cast<quint32
>(
static_cast<quint8
>(hmac.at(offset + 3)));
239 quint64 value = binary;
243 code.append(QLatin1Char(kSteamAlphabet[value % kSteamAlphabetSize]));
244 value /= kSteamAlphabetSize;
252 for (uint i = 0; i < settings.digits; ++i) {
255 return QString::number(binary % modulus)
256 .rightJustified(
static_cast<int>(settings.digits), QLatin1Char(
'0'));
264 const QString label =
265 settings.label.isEmpty() ? QStringLiteral(
"QtPass") : settings.label;
269 const QString secret =
271 QString uri = QStringLiteral(
"otpauth://totp/%1?secret=%2")
272 .arg(encodeLabel(label), secret);
273 if (!settings.issuer.isEmpty()) {
274 uri += QStringLiteral(
"&issuer=%1").arg(encodeLabel(settings.issuer));
278 QStringLiteral(
"&algorithm=%1").arg(algorithmName(settings.algorithm));
280 uri += QStringLiteral(
"&digits=%1&period=%2")
281 .arg(settings.digits)
284 uri += QStringLiteral(
"&encoder=steam");
288 for (
const QPair<QString, QString> &item : settings.extraParams) {
289 uri += QStringLiteral(
"&%1=%2").arg(
290 QString::fromLatin1(QUrl::toPercentEncoding(item.first)),
291 QString::fromLatin1(QUrl::toPercentEncoding(item.second)));
297 const QString &issuer) -> QString {
298 const std::optional<Settings> settings =
parse(config);
299 if (!settings.has_value()) {
303 if (canonical.
label.isEmpty()) {
304 canonical.
label = label;
306 if (canonical.
issuer.isEmpty()) {
307 canonical.
issuer = issuer;
311 const QString uri =
toUri(canonical);
319 return static_cast<quint64
>(QDateTime::currentSecsSinceEpoch());
Strict RFC 4648 base32 codec plus the input sanitizer that makes human-typed TOTP secrets decodable.
static auto sanitizeInput(const QByteArray &encodedData) -> QByteArray
Make human-entered base32 text decodable.
static auto decode(const QByteArray &encodedData) -> QByteArray
Decode strict RFC 4648 base32 data.
static auto removePadding(const QByteArray &encodedData) -> QByteArray
Strip trailing '=' padding.
Algorithm
HMAC hash function, selected by the algorithm URI parameter.
@ Sha1
HMAC-SHA-1, the RFC 6238 default.
static constexpr uint MAX_STEP
static constexpr uint MIN_DIGITS
Lower clamp for digits.
static constexpr uint DEFAULT_STEP
RFC 6238 default period.
static auto counter(const Settings &settings, quint64 unixTime) -> quint64
Time-step counter for a point in time.
static auto generateNow(const Settings &settings) -> QString
Generate the code for the current wall-clock time.
static constexpr uint DEFAULT_DIGITS
RFC 6238 default code length.
static constexpr qsizetype MAX_CONFIG_LENGTH
static constexpr uint STEAM_DIGITS
Steam codes are always 5 chars.
static auto currentUnixTime() -> quint64
Current wall-clock time.
static auto toUri(const Settings &settings) -> QString
Serialise settings as a canonical otpauth URI.
static auto normalize(const QString &config, const QString &label={}, const QString &issuer={}) -> QString
Normalise a user-entered value to a canonical otpauth URI.
static auto generate(const Settings &settings, quint64 unixTime) -> QString
Generate the code for a given point in time.
@ Steam
Steam Guard's five-character alphabet.
static auto parse(const QString &config) -> std::optional< Settings >
Parse an OTP configuration string.
static constexpr uint MAX_DIGITS
Upper clamp for digits.
static auto isValid(const QString &config) -> bool
Convenience validity test, for UI feedback.
static auto secondsRemaining(const Settings &settings, quint64 unixTime) -> uint
Seconds until the code for unixTime expires.
Validated TOTP parameters. Only Totp::parse() produces one.
QString label
otpauth label; may be empty.
uint digits
Code length, MIN_DIGITS..MAX_DIGITS.
QByteArray key
Decoded shared secret; never empty.
QString base32Key
Sanitized base32 secret, for re-serialisation.
Algorithm algorithm
HMAC hash function.
uint step
Period in seconds, 1..MAX_STEP.
QList< QPair< QString, QString > > extraParams
Encoder encoder
Digit encoding.
RFC 6238 TOTP: otpauth URI parsing, code generation and canonical re-serialisation.