QtPass 2.0.0
Developer documentation
Loading...
Searching...
No Matches
totp.cpp
Go to the documentation of this file.
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
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
26namespace {
27
29constexpr char kSteamAlphabet[] = "23456789BCDFGHJKMNPQRTVWXY";
31constexpr quint64 kSteamAlphabetSize = sizeof(kSteamAlphabet) - 1;
33constexpr int kMinHmacSize = 20;
34
40auto algorithmByName(const QString &name) -> Totp::Algorithm {
41 const QString upper = name.toUpper();
42 if (upper == QStringLiteral("SHA512") ||
43 upper == QStringLiteral("HMAC-SHA-512")) {
45 }
46 if (upper == QStringLiteral("SHA256") ||
47 upper == QStringLiteral("HMAC-SHA-256")) {
49 }
51}
52
58auto algorithmName(Totp::Algorithm algorithm) -> QString {
59 switch (algorithm) {
61 return QStringLiteral("SHA512");
63 return QStringLiteral("SHA256");
65 break;
66 }
67 return QStringLiteral("SHA1");
68}
69
75auto cryptoHash(Totp::Algorithm algorithm) -> QCryptographicHash::Algorithm {
76 switch (algorithm) {
78 return QCryptographicHash::Sha512;
80 return QCryptographicHash::Sha256;
82 break;
83 }
84 return QCryptographicHash::Sha1;
85}
86
88auto encodeLabel(const QString &value) -> QString {
89 return QString::fromLatin1(QUrl::toPercentEncoding(value, ":@"));
90}
91
98auto parsePositiveUInt(const QString &value, uint fallback) -> uint {
99 bool ok = false;
100 const uint parsed = value.toUInt(&ok);
101 return (ok && parsed > 0) ? parsed : fallback;
102}
103
104} // namespace
105
106auto 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 if (config.size() > MAX_CONFIG_LENGTH) {
111 return std::nullopt;
112 }
113 const QString trimmed = config.trimmed();
114 if (trimmed.isEmpty()) {
115 return std::nullopt;
116 }
117
118 Settings settings;
119 QString rawSecret;
120
121 const QUrl url(trimmed);
122 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 if (url.host().compare(QStringLiteral("totp"), Qt::CaseInsensitive) != 0) {
127 return std::nullopt;
128 }
129
130 const QUrlQuery query(url);
131 rawSecret =
132 query.queryItemValue(QStringLiteral("secret"), QUrl::FullyDecoded);
133 settings.issuer =
134 query.queryItemValue(QStringLiteral("issuer"), QUrl::FullyDecoded);
135 // The label is everything after the host, e.g. "/Example:alice".
136 settings.label = url.path();
137 if (settings.label.startsWith(QLatin1Char('/'))) {
138 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 settings.digits = parsePositiveUInt(
145 query.queryItemValue(QStringLiteral("digits")), DEFAULT_DIGITS);
146 settings.step = parsePositiveUInt(
147 query.queryItemValue(QStringLiteral("period")), DEFAULT_STEP);
148 if (query.hasQueryItem(QStringLiteral("algorithm"))) {
149 settings.algorithm =
150 algorithmByName(query.queryItemValue(QStringLiteral("algorithm")));
151 }
152 if (query.queryItemValue(QStringLiteral("encoder"))
153 .compare(QStringLiteral("steam"), Qt::CaseInsensitive) == 0) {
154 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 QStringLiteral("secret"), QStringLiteral("issuer"),
160 QStringLiteral("digits"), QStringLiteral("period"),
161 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 query.queryItems(QUrl::FullyDecoded);
166 for (const QPair<QString, QString> &item : items) {
167 if (!known.contains(item.first, Qt::CaseInsensitive)) {
168 settings.extraParams.append(item);
169 }
170 }
171 } 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 return std::nullopt;
178 } else {
179 // Not a URI: treat the whole string as a bare base32 secret.
180 rawSecret = trimmed;
181 }
182
183 const QByteArray sanitized = Base32::sanitizeInput(rawSecret.toLatin1());
184 settings.key = Base32::decode(sanitized);
185 if (settings.key.isEmpty()) {
186 return std::nullopt;
187 }
188 settings.base32Key = QString::fromLatin1(sanitized);
189
190 if (settings.encoder == Encoder::Steam) {
191 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 settings.digits = qBound(MIN_DIGITS, settings.digits, MAX_DIGITS);
196 settings.step = qBound(1U, settings.step, MAX_STEP);
197
198 return settings;
199}
200
201auto Totp::isValid(const QString &config) -> bool {
202 return parse(config).has_value();
203}
204
205auto Totp::counter(const Settings &settings, quint64 unixTime) -> quint64 {
206 return unixTime / settings.step;
207}
208
209auto Totp::secondsRemaining(const Settings &settings, quint64 unixTime)
210 -> uint {
211 return settings.step - static_cast<uint>(unixTime % settings.step);
212}
213
214auto Totp::generate(const Settings &settings, quint64 unixTime) -> QString {
215 const quint64 bigEndianCounter = qToBigEndian(counter(settings, unixTime));
216 const QByteArray message(reinterpret_cast<const char *>(&bigEndianCounter),
217 sizeof(bigEndianCounter));
218
219 const QByteArray hmac = QMessageAuthenticationCode::hash(
220 message, settings.key, cryptoHash(settings.algorithm));
221 if (hmac.size() < kMinHmacSize) {
222 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 static_cast<quint8>(hmac.at(hmac.size() - 1)) & 0x0F; // NOLINT
230 const quint32 binary =
231 (static_cast<quint32>(static_cast<quint8>(hmac.at(offset)) & 0x7F)
232 << 24) |
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)));
236
237 if (settings.encoder == Encoder::Steam) {
238 // Steam emits the least significant symbol first.
239 quint64 value = binary;
240 QString code;
241 code.reserve(static_cast<int>(STEAM_DIGITS));
242 for (uint i = 0; i < STEAM_DIGITS; ++i) {
243 code.append(QLatin1Char(kSteamAlphabet[value % kSteamAlphabetSize]));
244 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 for (uint i = 0; i < settings.digits; ++i) {
253 modulus *= 10;
254 }
255 return QString::number(binary % modulus)
256 .rightJustified(static_cast<int>(settings.digits), QLatin1Char('0'));
257}
258
259auto Totp::generateNow(const Settings &settings) -> QString {
260 return generate(settings, currentUnixTime());
261}
262
263auto Totp::toUri(const Settings &settings) -> QString {
264 const QString label =
265 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 QString::fromLatin1(Base32::removePadding(settings.base32Key.toLatin1()));
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));
275 }
276 if (settings.algorithm != Algorithm::Sha1) {
277 uri +=
278 QStringLiteral("&algorithm=%1").arg(algorithmName(settings.algorithm));
279 }
280 uri += QStringLiteral("&digits=%1&period=%2")
281 .arg(settings.digits)
282 .arg(settings.step);
283 if (settings.encoder == Encoder::Steam) {
284 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 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)));
292 }
293 return uri;
294}
295
296auto Totp::normalize(const QString &config, const QString &label,
297 const QString &issuer) -> QString {
298 const std::optional<Settings> settings = parse(config);
299 if (!settings.has_value()) {
300 return {};
301 }
302 Settings canonical = *settings;
303 if (canonical.label.isEmpty()) {
304 canonical.label = label;
305 }
306 if (canonical.issuer.isEmpty()) {
307 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 const QString uri = toUri(canonical);
312 if (uri.size() > MAX_CONFIG_LENGTH) {
313 return {};
314 }
315 return uri;
316}
317
318auto Totp::currentUnixTime() -> quint64 {
319 return static_cast<quint64>(QDateTime::currentSecsSinceEpoch());
320}
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.
Definition base32.cpp:305
static auto decode(const QByteArray &encodedData) -> QByteArray
Decode strict RFC 4648 base32 data.
Definition base32.cpp:128
static auto removePadding(const QByteArray &encodedData) -> QByteArray
Strip trailing '=' padding.
Definition base32.cpp:294
Algorithm
HMAC hash function, selected by the algorithm URI parameter.
Definition totp.h:41
@ Sha256
HMAC-SHA-256.
Definition totp.h:43
@ Sha1
HMAC-SHA-1, the RFC 6238 default.
Definition totp.h:42
@ Sha512
HMAC-SHA-512.
Definition totp.h:44
static constexpr uint MAX_STEP
Definition totp.h:78
static constexpr uint MIN_DIGITS
Lower clamp for digits.
Definition totp.h:76
static constexpr uint DEFAULT_STEP
RFC 6238 default period.
Definition totp.h:74
static auto counter(const Settings &settings, quint64 unixTime) -> quint64
Time-step counter for a point in time.
Definition totp.cpp:205
static auto generateNow(const Settings &settings) -> QString
Generate the code for the current wall-clock time.
Definition totp.cpp:259
static constexpr uint DEFAULT_DIGITS
RFC 6238 default code length.
Definition totp.h:73
static constexpr qsizetype MAX_CONFIG_LENGTH
Definition totp.h:82
static constexpr uint STEAM_DIGITS
Steam codes are always 5 chars.
Definition totp.h:75
static auto currentUnixTime() -> quint64
Current wall-clock time.
Definition totp.cpp:318
static auto toUri(const Settings &settings) -> QString
Serialise settings as a canonical otpauth URI.
Definition totp.cpp:263
static auto normalize(const QString &config, const QString &label={}, const QString &issuer={}) -> QString
Normalise a user-entered value to a canonical otpauth URI.
Definition totp.cpp:296
static auto generate(const Settings &settings, quint64 unixTime) -> QString
Generate the code for a given point in time.
Definition totp.cpp:214
@ Steam
Steam Guard's five-character alphabet.
Definition totp.h:52
static auto parse(const QString &config) -> std::optional< Settings >
Parse an OTP configuration string.
Definition totp.cpp:106
static constexpr uint MAX_DIGITS
Upper clamp for digits.
Definition totp.h:77
static auto isValid(const QString &config) -> bool
Convenience validity test, for UI feedback.
Definition totp.cpp:201
static auto secondsRemaining(const Settings &settings, quint64 unixTime) -> uint
Seconds until the code for unixTime expires.
Definition totp.cpp:209
Validated TOTP parameters. Only Totp::parse() produces one.
Definition totp.h:59
QString label
otpauth label; may be empty.
Definition totp.h:66
QString issuer
Definition totp.h:67
uint digits
Code length, MIN_DIGITS..MAX_DIGITS.
Definition totp.h:64
QByteArray key
Decoded shared secret; never empty.
Definition totp.h:60
QString base32Key
Sanitized base32 secret, for re-serialisation.
Definition totp.h:61
Algorithm algorithm
HMAC hash function.
Definition totp.h:62
uint step
Period in seconds, 1..MAX_STEP.
Definition totp.h:65
QList< QPair< QString, QString > > extraParams
Definition totp.h:70
Encoder encoder
Digit encoding.
Definition totp.h:63
RFC 6238 TOTP: otpauth URI parsing, code generation and canonical re-serialisation.