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 : }
|