Line data Source code
1 : // SPDX-FileCopyrightText: 2026 Anne Jan Brouwer
2 : // SPDX-License-Identifier: GPL-3.0-or-later
3 : /**
4 : * @file
5 : * @brief Implements the colon-listing parser: record classification, one
6 : * handler per record type, and primary-fingerprint lookup.
7 : */
8 : #include "gpgkeystate.h"
9 : #include "gpgkeystate_p.h"
10 :
11 : #include "util.h"
12 :
13 : #include <QDateTime>
14 : #include <QRegularExpression>
15 :
16 : /** @brief Fewest fields a record must have to be read at all. */
17 : constexpr int GPG_MIN_FIELDS = 10;
18 : /** @brief Field index of the validity letter (`u`, `f`, `r`, ...). */
19 : constexpr int GPG_FIELD_VALIDITY = 1;
20 : /** @brief Field index of the key ID. */
21 : constexpr int GPG_FIELD_KEY_ID = 4;
22 : /** @brief Field index of the creation time, in seconds since the epoch. */
23 : constexpr int GPG_FIELD_CREATED = 5;
24 : /** @brief Field index of the expiry time; empty for a key without one. */
25 : constexpr int GPG_FIELD_EXPIRY = 6;
26 : /** @brief Field index of the user ID. */
27 : constexpr int GPG_FIELD_USERID = 9;
28 : /**
29 : * @brief Field 9 of an `fpr` record: the fingerprint, in the slot the user
30 : * ID occupies in a `uid` record.
31 : */
32 : constexpr int GPG_FIELD_FINGERPRINT = 9;
33 :
34 624 : auto classifyRecord(const QString &record_type) -> GpgRecordType {
35 624 : if (record_type == "pub") {
36 : return GpgRecordType::Pub;
37 : }
38 452 : if (record_type == "sec") {
39 : return GpgRecordType::Sec;
40 : }
41 427 : if (record_type == "uid") {
42 : return GpgRecordType::Uid;
43 : }
44 240 : if (record_type == "fpr") {
45 : return GpgRecordType::Fpr;
46 : }
47 41 : if (record_type == "sub") {
48 : return GpgRecordType::Sub;
49 : }
50 20 : if (record_type == "ssb") {
51 : return GpgRecordType::Ssb;
52 : }
53 13 : if (record_type == "grp") {
54 7 : return GpgRecordType::Grp;
55 : }
56 : return GpgRecordType::Unknown;
57 : }
58 :
59 165 : void handlePubSecRecord(const QStringList &props, bool secret,
60 : UserInfo ¤t_user) {
61 165 : if (props.size() < GPG_MIN_FIELDS) {
62 2 : return;
63 : }
64 163 : current_user.key_id = props[GPG_FIELD_KEY_ID];
65 163 : current_user.name = props[GPG_FIELD_USERID];
66 163 : if (!props[GPG_FIELD_VALIDITY].isEmpty()) {
67 162 : current_user.validity = props[GPG_FIELD_VALIDITY][0].toLatin1();
68 : }
69 :
70 : // An empty field (GnuPG's "no expiry") leaves the QDateTime null, so
71 : // callers distinguish "unset" from "set" with isValid() alone.
72 163 : bool okCreated = false;
73 : const qint64 createdSecs = props[GPG_FIELD_CREATED].toLongLong(&okCreated);
74 163 : if (okCreated) {
75 162 : current_user.created = QDateTime::fromSecsSinceEpoch(createdSecs);
76 : }
77 :
78 163 : bool okExpiry = false;
79 : const qint64 expirySecs = props[GPG_FIELD_EXPIRY].toLongLong(&okExpiry);
80 163 : if (okExpiry) {
81 9 : current_user.expiry = QDateTime::fromSecsSinceEpoch(expirySecs);
82 : }
83 :
84 163 : current_user.have_secret = secret;
85 : }
86 :
87 : /**
88 : * @brief Handle a uid record in GPG colon output
89 : * @param props The colon-split fields of the record
90 : * @param current_user UserInfo to populate with user name
91 : */
92 159 : void handleUidRecord(const QStringList &props, UserInfo ¤t_user) {
93 159 : current_user.name = props[GPG_FIELD_USERID];
94 159 : }
95 :
96 160 : void handleFprRecord(const QStringList &props, UserInfo ¤t_user) {
97 160 : if (props.size() < GPG_MIN_FIELDS) {
98 : return;
99 : }
100 : // The key ID from the pub/sec record is the fingerprint's tail; take the
101 : // whole fingerprint, but only something shaped like one (40 hex for v4,
102 : // 64 for v5/v6): it becomes a recipient and a signing identity later.
103 : static const QRegularExpression shape(
104 166 : QStringLiteral("^(?:[0-9A-Fa-f]{40}|[0-9A-Fa-f]{64})$"));
105 : const QString &fingerprint = props[GPG_FIELD_FINGERPRINT];
106 326 : if (!current_user.key_id.isEmpty() && shape.match(fingerprint).hasMatch() &&
107 149 : fingerprint.endsWith(current_user.key_id, Qt::CaseInsensitive)) {
108 142 : current_user.key_id = fingerprint;
109 : }
110 : }
111 :
112 159 : auto parseGpgColonOutput(const QString &output, bool secret)
113 : -> QList<UserInfo> {
114 159 : QList<UserInfo> users;
115 :
116 : const QStringList lines =
117 159 : output.split(Util::newLinesRegex(), Qt::SkipEmptyParts);
118 :
119 159 : UserInfo current_user;
120 :
121 656 : for (const QString &key : lines) {
122 497 : QStringList props = key.split(':');
123 497 : if (props.size() < GPG_MIN_FIELDS) {
124 : continue;
125 : }
126 :
127 : const QString &record_type = props[0];
128 492 : const GpgRecordType type = classifyRecord(record_type);
129 :
130 492 : switch (type) {
131 : case GpgRecordType::Pub:
132 : case GpgRecordType::Sec:
133 162 : if (!current_user.key_id.isEmpty()) {
134 : users.append(current_user);
135 : }
136 162 : current_user = UserInfo();
137 162 : handlePubSecRecord(props, secret && (type == GpgRecordType::Sec),
138 : current_user);
139 : break;
140 : case GpgRecordType::Uid:
141 160 : if (current_user.name.isEmpty()) {
142 159 : handleUidRecord(props, current_user);
143 : }
144 : break;
145 153 : case GpgRecordType::Fpr:
146 153 : handleFprRecord(props, current_user);
147 : break;
148 : default:
149 : break;
150 : }
151 : }
152 :
153 159 : if (!current_user.key_id.isEmpty()) {
154 : users.append(current_user);
155 : }
156 :
157 159 : return users;
158 159 : }
159 :
160 21 : auto primaryFingerprintOf(const QString &output, const QString &key)
161 : -> QString {
162 21 : QString wanted = key.trimmed().toUpper();
163 21 : if (wanted.startsWith(QLatin1String("0X"))) {
164 1 : wanted.remove(0, 2);
165 : }
166 21 : if (wanted.isEmpty()) {
167 1 : return {};
168 : }
169 20 : const bool isId = wanted.size() == 8 || wanted.size() == 16;
170 20 : QStringList holders;
171 20 : QString primary;
172 : bool expectPrimary = false;
173 20 : bool holds = false;
174 : // A pub or sub record carries the key's own ID: the fingerprint's tail
175 : // for v4 keys, its head for v5/v6. A short ID is the long one's tail.
176 41 : const auto namesKeyId = [&](const QStringList &props) {
177 71 : return isId && props[GPG_FIELD_KEY_ID].toUpper().endsWith(wanted);
178 20 : };
179 51 : const auto endBlock = [&] {
180 51 : if (holds && !primary.isEmpty() && !holders.contains(primary)) {
181 17 : holders << primary;
182 : }
183 51 : primary.clear();
184 51 : holds = false;
185 71 : };
186 : const QStringList lines =
187 20 : output.split(Util::newLinesRegex(), Qt::SkipEmptyParts);
188 142 : for (const QString &line : lines) {
189 122 : const QStringList props = line.split(QLatin1Char(':'));
190 122 : if (props.size() < GPG_MIN_FIELDS) {
191 : continue;
192 : }
193 113 : switch (classifyRecord(props[0])) {
194 31 : case GpgRecordType::Pub:
195 31 : endBlock();
196 : expectPrimary = true;
197 31 : holds = holds || namesKeyId(props);
198 31 : break;
199 13 : case GpgRecordType::Sub:
200 : expectPrimary = false;
201 13 : holds = holds || namesKeyId(props);
202 13 : break;
203 44 : case GpgRecordType::Fpr:
204 44 : if (expectPrimary) {
205 31 : primary = props[GPG_FIELD_FINGERPRINT].toUpper();
206 : expectPrimary = false;
207 : }
208 73 : if (!isId && props[GPG_FIELD_FINGERPRINT].toUpper() == wanted) {
209 10 : holds = true;
210 : }
211 : break;
212 : default:
213 : break;
214 : }
215 : }
216 20 : endBlock();
217 20 : return holders.size() == 1 ? holders.first() : QString();
218 : }
|