QtPass Developer documentation
QtPass test coverage
Current view: top level - src/totp.cpp (source / functions) Coverage Total Hit
Test: QtPass Lines: 96.2 % 133 128
Test Date: 2026-11-14 13:19:18 Functions: 100.0 % 13 13

            Line data    Source code
       1              : // SPDX-FileCopyrightText: 2025 KeePassXC Team <team@keepassxc.org>
       2              : // SPDX-FileCopyrightText: 2026 Anne Jan Brouwer
       3              : // SPDX-License-Identifier: GPL-3.0-or-later
       4              : 
       5              : /**
       6              :  * @file
       7              :  * @brief Implements Totp: otpauth URI parsing with its length cap and HOTP
       8              :  * refusal, RFC 4226 dynamic truncation, Steam encoding and URI round-tripping.
       9              :  *
      10              :  * @see totp.h
      11              :  */
      12              : 
      13              : #include "totp.h"
      14              : #include "base32.h"
      15              : 
      16              : #include <QCryptographicHash>
      17              : #include <QDateTime>
      18              : #include <QLatin1Char>
      19              : #include <QMessageAuthenticationCode>
      20              : #include <QStringList>
      21              : #include <QUrl>
      22              : #include <QUrlQuery>
      23              : #include <QtEndian>
      24              : #include <QtGlobal>
      25              : 
      26              : namespace {
      27              : 
      28              : /// Steam Guard's alphabet: no A, E, I, L, O, S, U, Z, 0 or 1.
      29              : constexpr char kSteamAlphabet[] = "23456789BCDFGHJKMNPQRTVWXY";
      30              : /// Number of symbols in kSteamAlphabet, excluding the terminating NUL.
      31              : constexpr quint64 kSteamAlphabetSize = sizeof(kSteamAlphabet) - 1;
      32              : /// Shortest HMAC output the dynamic truncation below can read from (SHA-1).
      33              : constexpr int kMinHmacSize = 20;
      34              : 
      35              : /**
      36              :  * @brief Map an `algorithm` URI value onto a hash function.
      37              :  * @param name Value of the `algorithm` parameter.
      38              :  * @return The matching algorithm, defaulting to SHA-1.
      39              :  */
      40           32 : auto algorithmByName(const QString &name) -> Totp::Algorithm {
      41              :   const QString upper = name.toUpper();
      42           64 :   if (upper == QStringLiteral("SHA512") ||
      43           32 :       upper == QStringLiteral("HMAC-SHA-512")) {
      44              :     return Totp::Algorithm::Sha512;
      45              :   }
      46           38 :   if (upper == QStringLiteral("SHA256") ||
      47           19 :       upper == QStringLiteral("HMAC-SHA-256")) {
      48            8 :     return Totp::Algorithm::Sha256;
      49              :   }
      50              :   return Totp::Algorithm::Sha1;
      51              : }
      52              : 
      53              : /**
      54              :  * @brief Canonical name for an algorithm, as written into an otpauth URI.
      55              :  * @param algorithm Hash function.
      56              :  * @return "SHA1", "SHA256" or "SHA512".
      57              :  */
      58            3 : auto algorithmName(Totp::Algorithm algorithm) -> QString {
      59            3 :   switch (algorithm) {
      60            3 :   case Totp::Algorithm::Sha512:
      61            3 :     return QStringLiteral("SHA512");
      62            0 :   case Totp::Algorithm::Sha256:
      63            0 :     return QStringLiteral("SHA256");
      64              :   case Totp::Algorithm::Sha1:
      65              :     break;
      66              :   }
      67            0 :   return QStringLiteral("SHA1");
      68              : }
      69              : 
      70              : /**
      71              :  * @brief Map an algorithm onto the QtCore hash enumerator.
      72              :  * @param algorithm Hash function.
      73              :  * @return The QCryptographicHash equivalent.
      74              :  */
      75              : auto cryptoHash(Totp::Algorithm algorithm) -> QCryptographicHash::Algorithm {
      76           47 :   switch (algorithm) {
      77              :   case Totp::Algorithm::Sha512:
      78              :     return QCryptographicHash::Sha512;
      79            6 :   case Totp::Algorithm::Sha256:
      80            6 :     return QCryptographicHash::Sha256;
      81              :   case Totp::Algorithm::Sha1:
      82              :     break;
      83              :   }
      84           35 :   return QCryptographicHash::Sha1;
      85              : }
      86              : 
      87              : /// Percent-encode a URI label or issuer, keeping ':' and '@' readable.
      88           32 : auto encodeLabel(const QString &value) -> QString {
      89           64 :   return QString::fromLatin1(QUrl::toPercentEncoding(value, ":@"));
      90              : }
      91              : 
      92              : /**
      93              :  * @brief Read a positive integer query parameter.
      94              :  * @param value Raw parameter text.
      95              :  * @param fallback Returned when @p value is absent, non-numeric or zero.
      96              :  * @return The parsed value, or @p fallback.
      97              :  */
      98          246 : auto parsePositiveUInt(const QString &value, uint fallback) -> uint {
      99          246 :   bool ok = false;
     100              :   const uint parsed = value.toUInt(&ok);
     101          246 :   return (ok && parsed > 0) ? parsed : fallback;
     102              : }
     103              : 
     104              : } // namespace
     105              : 
     106          262 : auto Totp::parse(const QString &config) -> std::optional<Settings> {
     107              :   // Checked before anything else sees it (trimmed() copies it, QUrl and
     108              :   // QUrlQuery would make a crafted URI of tens of MiB tens of millions of
     109              :   // query items on the GUI thread); padding counts too.
     110          262 :   if (config.size() > MAX_CONFIG_LENGTH) {
     111            4 :     return std::nullopt;
     112              :   }
     113              :   const QString trimmed = config.trimmed();
     114          258 :   if (trimmed.isEmpty()) {
     115            7 :     return std::nullopt;
     116              :   }
     117              : 
     118          251 :   Settings settings;
     119          251 :   QString rawSecret;
     120              : 
     121          251 :   const QUrl url(trimmed);
     122          743 :   if (url.isValid() && url.scheme().compare(QStringLiteral("otpauth"),
     123              :                                             Qt::CaseInsensitive) == 0) {
     124              :     // Counter-based HOTP cannot be generated from a clock; refuse rather than
     125              :     // return a wrong code that looks right.
     126          306 :     if (url.host().compare(QStringLiteral("totp"), Qt::CaseInsensitive) != 0) {
     127           30 :       return std::nullopt;
     128              :     }
     129              : 
     130          123 :     const QUrlQuery query(url);
     131              :     rawSecret =
     132          246 :         query.queryItemValue(QStringLiteral("secret"), QUrl::FullyDecoded);
     133              :     settings.issuer =
     134          246 :         query.queryItemValue(QStringLiteral("issuer"), QUrl::FullyDecoded);
     135              :     // The label is everything after the host, e.g. "/Example:alice".
     136          123 :     settings.label = url.path();
     137          123 :     if (settings.label.startsWith(QLatin1Char('/'))) {
     138          122 :       settings.label.remove(0, 1);
     139              :     }
     140              :     // toUInt() returns 0 with no error signal for anything non-numeric, and the
     141              :     // clamp below would turn that into digits=1 / step=1 — a one-character code
     142              :     // rotating every second, which normalize() would then persist. Keep the RFC
     143              :     // default unless the value really is a usable number.
     144          123 :     settings.digits = parsePositiveUInt(
     145          246 :         query.queryItemValue(QStringLiteral("digits")), DEFAULT_DIGITS);
     146          123 :     settings.step = parsePositiveUInt(
     147          246 :         query.queryItemValue(QStringLiteral("period")), DEFAULT_STEP);
     148          246 :     if (query.hasQueryItem(QStringLiteral("algorithm"))) {
     149           32 :       settings.algorithm =
     150           64 :           algorithmByName(query.queryItemValue(QStringLiteral("algorithm")));
     151              :     }
     152          246 :     if (query.queryItemValue(QStringLiteral("encoder"))
     153          246 :             .compare(QStringLiteral("steam"), Qt::CaseInsensitive) == 0) {
     154            8 :       settings.encoder = Encoder::Steam;
     155              :     }
     156              :     // Anything we do not model is kept verbatim so toUri() can put it back;
     157              :     // dropping it silently mutated URIs carrying e.g. `image=`.
     158              :     static const QStringList known = {
     159            4 :         QStringLiteral("secret"),    QStringLiteral("issuer"),
     160            4 :         QStringLiteral("digits"),    QStringLiteral("period"),
     161          151 :         QStringLiteral("algorithm"), QStringLiteral("encoder")};
     162              :     // FullyDecoded, because toUri() percent-encodes these again: the default
     163              :     // PrettyDecoded leaves escapes in place and the round trip double-encodes.
     164              :     const QList<QPair<QString, QString>> items =
     165          123 :         query.queryItems(QUrl::FullyDecoded);
     166          449 :     for (const QPair<QString, QString> &item : items) {
     167          326 :       if (!known.contains(item.first, Qt::CaseInsensitive)) {
     168              :         settings.extraParams.append(item);
     169              :       }
     170              :     }
     171          319 :   } else if (trimmed.startsWith(QStringLiteral("otpauth:"),
     172              :                                 Qt::CaseInsensitive)) {
     173              :     // Claims to be an otpauth URI but QUrl could not parse it. Falling through
     174              :     // to the bare-secret branch would hand the whole string to
     175              :     // Base32::sanitizeInput(), which strips the punctuation and leaves
     176              :     // decodable base32 — producing a confidently wrong code. Refuse instead.
     177            5 :     return std::nullopt;
     178              :   } else {
     179              :     // Not a URI: treat the whole string as a bare base32 secret.
     180           93 :     rawSecret = trimmed;
     181              :   }
     182              : 
     183          216 :   const QByteArray sanitized = Base32::sanitizeInput(rawSecret.toLatin1());
     184          432 :   settings.key = Base32::decode(sanitized);
     185          216 :   if (settings.key.isEmpty()) {
     186           51 :     return std::nullopt;
     187              :   }
     188            0 :   settings.base32Key = QString::fromLatin1(sanitized);
     189              : 
     190          165 :   if (settings.encoder == Encoder::Steam) {
     191            8 :     settings.digits = STEAM_DIGITS;
     192              :   }
     193              :   // Clamp on every path: an out-of-range `digits` would otherwise overflow the
     194              :   // modulus, and a zero `step` would divide by zero.
     195          165 :   settings.digits = qBound(MIN_DIGITS, settings.digits, MAX_DIGITS);
     196          165 :   settings.step = qBound(1U, settings.step, MAX_STEP);
     197              : 
     198              :   return settings;
     199          502 : }
     200              : 
     201          137 : auto Totp::isValid(const QString &config) -> bool {
     202          137 :   return parse(config).has_value();
     203              : }
     204              : 
     205           71 : auto Totp::counter(const Settings &settings, quint64 unixTime) -> quint64 {
     206           71 :   return unixTime / settings.step;
     207              : }
     208              : 
     209           24 : auto Totp::secondsRemaining(const Settings &settings, quint64 unixTime)
     210              :     -> uint {
     211           24 :   return settings.step - static_cast<uint>(unixTime % settings.step);
     212              : }
     213              : 
     214           47 : auto Totp::generate(const Settings &settings, quint64 unixTime) -> QString {
     215           47 :   const quint64 bigEndianCounter = qToBigEndian(counter(settings, unixTime));
     216              :   const QByteArray message(reinterpret_cast<const char *>(&bigEndianCounter),
     217           47 :                            sizeof(bigEndianCounter));
     218              : 
     219              :   const QByteArray hmac = QMessageAuthenticationCode::hash(
     220           88 :       message, settings.key, cryptoHash(settings.algorithm));
     221           47 :   if (hmac.size() < kMinHmacSize) {
     222            0 :     return {};
     223              :   }
     224              : 
     225              :   // RFC 4226 dynamic truncation. QByteArray elements are signed char, so every
     226              :   // byte is widened through quint8: without that, any byte >= 0x80
     227              :   // sign-extends and corrupts the assembled 31-bit value.
     228              :   const int offset =
     229           47 :       static_cast<quint8>(hmac.at(hmac.size() - 1)) & 0x0F; // NOLINT
     230              :   const quint32 binary =
     231           47 :       (static_cast<quint32>(static_cast<quint8>(hmac.at(offset)) & 0x7F)
     232           47 :        << 24) |
     233           47 :       (static_cast<quint32>(static_cast<quint8>(hmac.at(offset + 1))) << 16) |
     234           47 :       (static_cast<quint32>(static_cast<quint8>(hmac.at(offset + 2))) << 8) |
     235           47 :       static_cast<quint32>(static_cast<quint8>(hmac.at(offset + 3)));
     236              : 
     237           47 :   if (settings.encoder == Encoder::Steam) {
     238              :     // Steam emits the least significant symbol first.
     239            3 :     quint64 value = binary;
     240            3 :     QString code;
     241            3 :     code.reserve(static_cast<int>(STEAM_DIGITS));
     242           18 :     for (uint i = 0; i < STEAM_DIGITS; ++i) {
     243           15 :       code.append(QLatin1Char(kSteamAlphabet[value % kSteamAlphabetSize]));
     244           15 :       value /= kSteamAlphabetSize;
     245              :     }
     246              :     return code;
     247              :   }
     248              : 
     249              :   // Integer exponentiation into a quint64: pow() into a quint32 (as KeePassXC
     250              :   // did) overflows at ten digits.
     251              :   quint64 modulus = 1;
     252          348 :   for (uint i = 0; i < settings.digits; ++i) {
     253          304 :     modulus *= 10;
     254              :   }
     255           88 :   return QString::number(binary % modulus)
     256           44 :       .rightJustified(static_cast<int>(settings.digits), QLatin1Char('0'));
     257              : }
     258              : 
     259            2 : auto Totp::generateNow(const Settings &settings) -> QString {
     260            2 :   return generate(settings, currentUnixTime());
     261              : }
     262              : 
     263           27 : auto Totp::toUri(const Settings &settings) -> QString {
     264              :   const QString label =
     265           27 :       settings.label.isEmpty() ? QStringLiteral("QtPass") : settings.label;
     266              :   // The Key-Uri-Format spec says the secret's base32 padding is omitted, and
     267              :   // third-party importers reject or truncate `secret=...======`.
     268              :   // sanitizeInput() re-adds padding when reading, so this round-trips.
     269              :   const QString secret =
     270           54 :       QString::fromLatin1(Base32::removePadding(settings.base32Key.toLatin1()));
     271           27 :   QString uri = QStringLiteral("otpauth://totp/%1?secret=%2")
     272           54 :                     .arg(encodeLabel(label), secret);
     273           27 :   if (!settings.issuer.isEmpty()) {
     274           10 :     uri += QStringLiteral("&issuer=%1").arg(encodeLabel(settings.issuer));
     275              :   }
     276           27 :   if (settings.algorithm != Algorithm::Sha1) {
     277              :     uri +=
     278            6 :         QStringLiteral("&algorithm=%1").arg(algorithmName(settings.algorithm));
     279              :   }
     280           27 :   uri += QStringLiteral("&digits=%1&period=%2")
     281           27 :              .arg(settings.digits)
     282           27 :              .arg(settings.step);
     283           27 :   if (settings.encoder == Encoder::Steam) {
     284            2 :     uri += QStringLiteral("&encoder=steam");
     285              :   }
     286              :   // Re-emit anything the parser did not model, so canonicalising an existing
     287              :   // URI does not quietly discard parameters other authenticators honour.
     288           29 :   for (const QPair<QString, QString> &item : settings.extraParams) {
     289            4 :     uri += QStringLiteral("&%1=%2").arg(
     290            4 :         QString::fromLatin1(QUrl::toPercentEncoding(item.first)),
     291            4 :         QString::fromLatin1(QUrl::toPercentEncoding(item.second)));
     292              :   }
     293           27 :   return uri;
     294              : }
     295              : 
     296           27 : auto Totp::normalize(const QString &config, const QString &label,
     297              :                      const QString &issuer) -> QString {
     298           27 :   const std::optional<Settings> settings = parse(config);
     299           27 :   if (!settings.has_value()) {
     300            5 :     return {};
     301              :   }
     302           22 :   Settings canonical = *settings;
     303           22 :   if (canonical.label.isEmpty()) {
     304            8 :     canonical.label = label;
     305              :   }
     306           22 :   if (canonical.issuer.isEmpty()) {
     307           20 :     canonical.issuer = issuer;
     308              :   }
     309              :   // Percent-encoding and the added fields can take a value near the length
     310              :   // cap past it; a URI parse() refuses would store an OTP that makes no code.
     311           22 :   const QString uri = toUri(canonical);
     312           22 :   if (uri.size() > MAX_CONFIG_LENGTH) {
     313            1 :     return {};
     314              :   }
     315              :   return uri;
     316           22 : }
     317              : 
     318           17 : auto Totp::currentUnixTime() -> quint64 {
     319           17 :   return static_cast<quint64>(QDateTime::currentSecsSinceEpoch());
     320              : }
        

Generated by: LCOV version 2.0-1