• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

IJHack / QtPass / 40408942413

26 Oct 2026 09:59PM UTC coverage: 92.851% (-0.01%) from 92.861%
40408942413

push

github

web-flow
RnpCrypto: in-process OpenPGP encrypt and decrypt through RNP (spike) (#2048)

* RnpCrypto: in-process OpenPGP encrypt and decrypt through RNP (spike)

A first step towards a backend that needs no gpg, git or pass binary
(#2005), which would let QtPass run on Windows without Gpg4win and on
Android at all. RnpCrypto wraps RNP, the OpenPGP library Thunderbird
uses: import armored keys (gpg --export / --export-secret-keys) into its
own keyring, encrypt to several recipients as one binary message (as
pass writes .gpg files), and decrypt with a passphrase callback in
place of pinentry. Recipients are found the way .gpg-id names them: a
fingerprint, a long key ID with or without 0x, or part of a user ID
such as the e-mail address, as gpg -r accepts.

Built only with qmake CONFIG+=rnp (pkg-config librnp); nothing else in
QtPass uses it yet, and default builds are unchanged.

tst_rnpcrypto checks it against the real gpg on the system: RNP
decrypts what gpg encrypted, including the OCB packets GnuPG 2.4 and
later write by default for their own keys (the risk the research
flagged), gpg decrypts what RNP encrypted for every recipient, the
passphrase callback is asked and a wrong passphrase fails, and an
unknown recipient or a missing secret key is an error. The Ubuntu
Qt 6.11 CI job now builds with CONFIG+=rnp and librnp-dev, so the test
runs there against Ubuntu's RNP and GnuPG.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VJygpMUf461yZgfAqwMJ6d
Signed-off-by: Anne Jan Brouwer <brouwer@annejan.com>

* SECURITY.md: what QtPass holds without GnuPG; GnuPG stays the default

The in-process OpenPGP spike moves the secret key from gpg-agent (or a
smartcard) into QtPass: it is imported from a GnuPG export, kept under
its own passphrase, and while an entry is decrypted the unlocked key and
the passphrase typed into QtPass are in QtPass's memory. No smartcards
or YubiKeys in that mode.

SECURITY.md gains a sec... (continued)

9169 of 9875 relevant lines covered (92.85%)

193.95 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

86.42
/src/nativegrep.cpp
1
// SPDX-FileCopyrightText: 2026 Anne Jan Brouwer
2
// SPDX-License-Identifier: GPL-3.0-or-later
3
#include "nativegrep.h"
4
#include "executor.h"
5
#include "util.h"
6
#include <QDir>
7
#include <QElapsedTimer>
8
#include <QPointer>
9
#include <QProcess>
10
#include <QRegularExpression>
11
#include <QThread>
12
#include <utility>
13

14
NativeGrep::NativeGrep(QObject *parent) : QObject(parent) {}
189 ✔
15

16
NativeGrep::~NativeGrep() {
189 ✔
17
  static constexpr int kThreadTimeoutMs = 5000;
18
  cancel();
189 ✔
19
  QElapsedTimer elapsed;
189 ✔
20
  elapsed.start();
189 ✔
21
  for (const Worker &w : std::as_const(m_workers)) {
189 ✔
22
    if (w.thread && w.thread->isRunning()) {
×
23
      const int remaining =
24
          kThreadTimeoutMs - static_cast<int>(elapsed.elapsed());
×
25
      if (remaining > 0)
×
26
        w.thread->wait(remaining);
×
27
    }
28
  }
29
}
189 ✔
30

31
/**
32
 * @brief Decrypt one .gpg file and return lines matching rx.
33
 */
34
auto NativeGrep::matchFile(const QProcessEnvironment &env,
65 ✔
35
                           const QString &gpgExe, const QString &filePath,
36
                           const QRegularExpression &rx,
37
                           const std::atomic_bool *cancel) -> QStringList {
38
  const QString translatedPath =
39
      Executor::translatePathForWsl(filePath, gpgExe);
65 ✔
40
  QString plaintext;
65 ✔
41
  // The QProcess overload is the one that takes the cancel flag; it polls
42
  // the flag while waiting and terminates (then kills) gpg from this thread,
43
  // the one that owns the process.
44
  QProcess gpg;
65 ✔
45
  gpg.setProcessEnvironment(env);
65 ✔
46
  const int rc =
47
      Executor::executeBlocking(gpg, gpgExe,
585 ✔
48
                                {"-d", "--quiet", "--yes", "--no-encrypt-to",
49
                                 "--batch", "--use-agent", translatedPath},
50
                                QString(), &plaintext, nullptr, cancel);
65 ✔
51
  if (rc != 0 || plaintext.isEmpty())
65 ✔
52
    return {};
4 ✔
53
  QStringList matches;
61 ✔
54
  for (const QString &line : plaintext.split('\n')) {
196 ✔
55
    QString candidate = line;
56
    if (candidate.endsWith('\r'))
74 ✔
57
      candidate.chop(1);
×
58
    const QString t = candidate.trimmed();
59
    if (!t.isEmpty() && candidate.contains(rx))
74 ✔
60
      matches << t;
61
  }
62
  return matches;
63
}
130 ✔
64

65
/**
66
 * @brief Walk the store, decrypt every .gpg file, collect matches.
67
 */
68
auto NativeGrep::scanStore(const QProcessEnvironment &env,
10 ✔
69
                           const QString &gpgExe, const QString &storeDir,
70
                           const QRegularExpression &rx,
71
                           const std::atomic_bool *cancel)
72
    -> QList<QPair<QString, QStringList>> {
73
  QList<QPair<QString, QStringList>> results;
10 ✔
74
  // Regular files only: a link or junction is not an entry, and searching
75
  // through one would decrypt files outside the store.
76
  const QStringList files =
77
      Util::regularFilesUnder(storeDir, QStringList() << "*.gpg");
30 ✔
78
  for (const QString &filePath : files) {
74 ✔
79
    if (QThread::currentThread()->isInterruptionRequested() ||
64 ✔
80
        (cancel != nullptr && cancel->load()))
64 ✔
81
      return {};
×
82
    const QStringList matches = matchFile(env, gpgExe, filePath, rx, cancel);
64 ✔
83
    if (!matches.isEmpty()) {
64 ✔
84
      QString entry = QDir(storeDir).relativeFilePath(filePath);
61 ✔
85
      if (entry.endsWith(QLatin1String(".gpg")))
61 ✔
86
        entry.chop(4);
61 ✔
87
      results.append({entry, matches});
61 ✔
88
    }
89
  }
90
  return results;
91
}
92

93
/**
94
 * @brief Start a search on a worker thread.
95
 *
96
 * Results are emitted on the owner's thread via QMetaObject::invokeMethod. A
97
 * sequence counter discards results from superseded searches; the previous
98
 * search is asked to stop but not waited for, since blocking the UI thread
99
 * while gpg decrypts would freeze the interface.
100
 */
101
void NativeGrep::search(const QString &pattern, bool caseInsensitive,
10 ✔
102
                        const QString &gpgExe, const QString &storeDir,
103
                        const QProcessEnvironment &env) {
104
  cancel();
10 ✔
105

106
  // Advance the sequence before any early return so in-flight workers from the
107
  // previous query fail the seq check and cannot publish stale results.
108
  const int seq = ++m_seq;
10 ✔
109

110
  // Use trimmed() rather than isEmpty(): a whitespace-only string is a valid
111
  // regex that matches every non-empty line, which is almost never intentional
112
  // and would decrypt the entire store.
113
  //
114
  // Both early returns post finished() via Qt::QueuedConnection so that the
115
  // signal is always delivered asynchronously after search() returns, matching
116
  // the contract of the threaded path.
117
  if (pattern.trimmed().isEmpty()) {
10 ✔
118
    QMetaObject::invokeMethod(
×
119
        this,
120
        [this, seq]() {
×
121
          if (m_seq == seq)
×
122
            emit finished({});
×
123
        },
×
124
        Qt::QueuedConnection);
125
    return;
1 ✔
126
  }
127

128
  const QRegularExpression rx(
129
      pattern, caseInsensitive ? QRegularExpression::CaseInsensitiveOption
130
                               : QRegularExpression::PatternOptions{});
20 ✔
131
  if (!rx.isValid()) {
10 ✔
132
    QMetaObject::invokeMethod(
1 ✔
133
        this,
134
        [this, seq]() {
1 ✔
135
          if (m_seq == seq)
1 ✔
136
            emit finished({});
2 ✔
137
        },
1 ✔
138
        Qt::QueuedConnection);
139
    return;
140
  }
141
  QPointer<NativeGrep> self(this);
142

143
  auto emitResults = [self, seq](QList<QPair<QString, QStringList>> results) {
18 ✔
144
    if (!self)
9 ✔
145
      return;
146
    QMetaObject::invokeMethod(
9 ✔
147
        self,
148
        [self, seq, results = std::move(results)]() {
27 ✔
149
          if (self && self->m_seq == seq)
18 ✔
150
            emit self->finished(results);
9 ✔
151
        },
9 ✔
152
        Qt::QueuedConnection);
153
  };
9 ✔
154

155
  auto cancelFlag = std::make_shared<std::atomic_bool>(false);
9 ✔
156
  QThread *thread = QThread::create([gpgExe, storeDir, env, rx, cancelFlag,
18 ✔
157
                                     emitResults = std::move(emitResults)]() {
158
    std::move(emitResults)(
9 ✔
159
        scanStore(env, gpgExe, storeDir, rx, cancelFlag.get()));
9 ✔
160
  });
9 ✔
161

162
  m_workers.append({thread, cancelFlag});
18 ✔
163
  connect(thread, &QThread::finished, thread, &QObject::deleteLater);
9 ✔
164
  connect(thread, &QThread::finished, this, [this, thread]() {
9 ✔
165
    m_workers.removeIf(
9 ✔
166
        [thread](const Worker &w) { return w.thread == thread; });
9 ✔
167
  });
168
  thread->start();
9 ✔
169
}
19 ✔
170

171
void NativeGrep::cancel() {
388 ✔
172
  for (const Worker &w : std::as_const(m_workers)) {
389 ✔
173
    w.cancel->store(true);
174
    if (w.thread && w.thread->isRunning())
1 ✔
175
      w.thread->requestInterruption();
1 ✔
176
  }
177
}
388 ✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE TRIAL · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc