aboutsummaryrefslogtreecommitdiff
path: root/src/keyring.cpp
blob: 324aec2484de3b326874e2d403f6e8a5563afdc9 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
// Copyright 2026 Osmium Sorcerer
// SPDX-License-Identifier: MIT

#include <QByteArray>
#include <QCborMap>
#include <QCborStreamReader>
#include <QCborValue>
#include <QDataStream>
#include <QDir>
#include <QIODevice>
#include <QLockFile>
#include <QSaveFile>

#include <sodium.h>

#include "file_functions.h"
#include "keyring.h"

extern "C" {
#include "tpm/tpm.h"
}

// Encrypted secret key serialized format version.
//
// Version 1: Secret key encrypted with ChaCha20-Poly1305 using password-based
// Argon2-derived wrapping key.
//
// Serialization: Argon2 parameters for key derivation, encrypted key, Poly1305
// MAC over the encrypted payload with the whole header as additional
// authenticated data.
//
// Fields: version (u8), salt[16], opslimit (u32be), memlimit (u32be),
// alg (u8), ciphertext[32], mac[16].
static constexpr quint8 key_format_version = 1;

// Magic that is prepended to a byte sequence to identify it as a 32-byte
// X25519 public key. Don't expect new tags to ever be introduced unless you
// want certified post-quantum-secure lawyer simulator authentication with
// ML-KEM. Simplicity over flexibility.
static constexpr quint8 cert_x25519_tag = 0x26;

// All-zero nonce for use in key wrapping. Nonces are provided to ensure
// uniqueness of multiple keystreams under the same key. In our case, we only
// ever encrypt one message with one derived key, the uniqueness of which is
// provided by the salt that is randomly generated for each new key wrapping.
static constexpr quint8 all_zero_nonce[crypto_aead_chacha20poly1305_NPUBBYTES] = {};

// Domain separator string to isolate generic BLAKE2b specifically to this
// authentication mechanism.
static const auto domain_sep = QByteArrayLiteral("Einsof-Auth-DHCR");

// Internal state, set by tpm2_init if TPM check and self-test succeed, and we
// can use hardware keys.
static int tpm_available = 0;

static QString get_keyring_path()
{
  return QDir(get_app_path()).filePath("keyring.cbor");
}

int keyring_initialize()
{
  if (sodium_init() < 0)
  {
    return 1;
  }

  QFile index(get_keyring_path());
  if (!index.exists())
  {
    if (!index.open(QIODevice::NewOnly, QFileDevice::ReadOwner | QFileDevice::WriteOwner))
    {
      return 3;
    }
    index.close();
  }

  if (tpm2_init() == STKB_SUCCESS)
  {
    tpm_available = 1;
  }

  return 0;
}

// Expose a way to query status without making the internal state variable
// global.
// Yes, this is stupid.
int keyring_hardware_available()
{
    return tpm_available;
}

// Prepare the key CBOR entry and atomically insert it into the keyring.
// To ensure durability, file locking and atomic overwrite are used as
// provided by Qt. Frankly, I don't see the keyring being prone to corruption
// as there's no concurrent access, save for a contrived edge case of a user
// generating/deleting keys simultaneously from two client instances. It's
// done regardless for a good measure.
static int insert_into_keyring(quint8 tag, QStringView name, const QByteArray &pub, const QByteArray &sec)
{
  QCborMap entry;
  entry.insert(0, QCborValue(tag));
  entry.insert(1, QCborValue(name));
  entry.insert(2, QCborValue(pub));
  entry.insert(3, QCborValue(sec));
  QFile index_file(get_keyring_path());
  QLockFile index_lock(QDir(get_app_path()).filePath("keyring.lock"));
  if (!index_lock.lock())
  {
    return 1;
  }
  if (!index_file.open(QIODevice::ReadOnly))
  {
    return 1;
  }
  QCborStreamReader idx_in(&index_file);
  QCborValue index = QCborValue::fromCbor(idx_in);
  index_file.close();
  if (!index.isMap())
  {
    index = QCborMap();
  }
  QCborMap index_map = index.toMap();
  // Key fingerprint is used as its unique identifier.
  QByteArray fingerprint;
  fingerprint.append(tag);
  fingerprint.append(pub);
  uchar fpr_hash[crypto_generichash_BYTES];
  crypto_generichash(fpr_hash, sizeof(fpr_hash), (const uchar *)fingerprint.constData(), fingerprint.size(), nullptr, 0);
  index_map.insert(QCborValue(QByteArray((const char *)fpr_hash, (qsizetype)sizeof(fpr_hash))), QCborValue(entry));
  QSaveFile index_file_w(get_keyring_path());
  if (!index_file_w.open(QIODevice::WriteOnly))
  {
    return 1;
  }
  index_file_w.write(QCborValue(index_map).toCbor());
  index_file_w.commit();
  index_lock.unlock();
  return 0;
}

// Software X25519 keys protected by a passphrase.
int generate_software_key(QStringView name, const QByteArray &password)
{
  // If we let users specify arbitrary parameters, additional constraints must
  // be enforced: min/max opslimit, type of memlimit (uint32_t vs size_t).
  // This flexibility is counterproductive. Instead, a simple high/moderate
  // toggle should be used if Argon2 parameter tuning is required.
  if (password.size() < crypto_pwhash_PASSWD_MIN || password.size() > crypto_pwhash_PASSWD_MAX)
  {
    return 1;
  }

  quint8 public_key[crypto_kx_PUBLICKEYBYTES];
  quint8 secret_key[crypto_kx_SECRETKEYBYTES];
  sodium_mlock(secret_key, sizeof(secret_key));
  if (crypto_kx_keypair(public_key, secret_key))
  {
    sodium_munlock(secret_key, sizeof(secret_key));
    return 2;
  }

  // Key wrapping. The crypto_pwhash derives the key from password with the
  // specified parameters (2 iterations over 2 gigabytes of memory). This
  // operation is slow and should ideally be handled asynchronously without
  // blocking the GUI thread.
  quint8 wrap_key[crypto_aead_chacha20poly1305_KEYBYTES];
  sodium_mlock(wrap_key, sizeof(wrap_key));
  quint8 salt[crypto_pwhash_SALTBYTES];
  randombytes_buf(salt, sizeof(salt));
  quint32 pwhash_opslimit = 2u;
  quint32 pwhash_memlimit = 2 * 1024 * 1024 * 1024u;
  quint8 pwhash_alg = crypto_pwhash_ALG_DEFAULT;
  if (crypto_pwhash(wrap_key, sizeof(wrap_key), password.constData(), password.size(), salt, pwhash_opslimit, pwhash_memlimit, pwhash_alg))
  {
    sodium_munlock(wrap_key, sizeof(wrap_key));
    sodium_munlock(secret_key, sizeof(secret_key));
    return 3;
  }

  // Payload: the wrapped secret key. Authenticated together with its header
  // (version tag and Argon2 parameters).
  quint8 payload[sizeof(secret_key) + crypto_aead_chacha20poly1305_ABYTES];
  QByteArray packed_header;
  packed_header.reserve(sizeof(quint8) + sizeof(salt) + sizeof(quint32) + sizeof(quint32) + sizeof(quint8));
  QDataStream hdr_out(&packed_header, QIODevice::WriteOnly);
  hdr_out << key_format_version;
  hdr_out.writeRawData((const char *)salt, sizeof(salt));
  hdr_out << pwhash_opslimit;
  hdr_out << pwhash_memlimit;
  hdr_out << pwhash_alg;
  if (crypto_aead_chacha20poly1305_encrypt(payload, nullptr, secret_key, sizeof(secret_key), (const quint8 *)packed_header.constData(), packed_header.size(), nullptr, all_zero_nonce, wrap_key))
  {
    sodium_munlock(wrap_key, sizeof(wrap_key));
    sodium_munlock(secret_key, sizeof(secret_key));
    return 4;
  }
  sodium_munlock(wrap_key, sizeof(wrap_key));
  sodium_munlock(secret_key, sizeof(secret_key));

  QByteArray public_key_array((const char *)public_key, (qsizetype)sizeof(public_key));
  QByteArray encrypted_secret(packed_header);
  encrypted_secret.append((const char *)payload, sizeof(payload));

  if (insert_into_keyring(cert_x25519_tag, name, public_key_array, encrypted_secret))
  {
    return 5;
  }

  return 0;
}

static constexpr quint8 cert_hwkey_p256_tag = 0x33;

static std::wstring optional_hardware_key_name(QStringView name)
{
#ifdef SOF_AO_HARDWARE_KEY_REQUIRES_PIN_UI
  (void)name;
  return std::wstring();
#else
  return name.toString().toStdWString();
#endif
}

// Hardware P-256 keys (because most TPMs can only do P-256, unfortunately);
// the TPM verifies the PIN.
int generate_hardware_key(QStringView name, const QByteArray &pin)
{
  // Compressed P-256 public key.
  uint8_t pub[33];
  // Protected secret blobs differ by size depending on the platform (and in
  // case of TSS2 implementation, size is unspecified). Prepare a sufficient
  // buffer to write to, the actual output size will be in `sec_written`.
  uint8_t sec[512];
  size_t sec_written = 0;
  std::wstring optional_name_wstr = optional_hardware_key_name(name);
  stkb_user_input input = {
    .pin = (const uint8_t *)pin.constData(),
    .pin_len = (size_t)pin.size(),
    .key_name = optional_name_wstr.c_str(),
  };
  stkb_rc rc = tpm2_keygen(pub, sec, sizeof(sec), &sec_written, input);
  switch (rc) {
  case STKB_SUCCESS:
    break;
  default:
    return rc;
  }
  QByteArray public_array((const char *)pub, (qsizetype)sizeof(pub));
  QByteArray protected_array((const char *)sec, (qsizetype)sec_written);
  if (insert_into_keyring(cert_hwkey_p256_tag, name, public_array, protected_array))
    return 1;
  return 0;
}

static ResponseResult compute_with_software_key(quint8 *shared_secret, const QByteArray &encrypted_secret, QByteArrayView password, QByteArrayView ephemeral_key)
{
  if (password.size() < crypto_pwhash_PASSWD_MIN || password.size() > crypto_pwhash_PASSWD_MAX)
  {
    return ResponseResult::invalid_password;
  }

  if (ephemeral_key.size() != crypto_kx_PUBLICKEYBYTES)
  {
    return ResponseResult::incompatible_arguments;
  }

  // After retrieving the key from the keyring, the process is the same as
  // in key generation, but in the opposite direction.
  quint8 secret_key[crypto_kx_SECRETKEYBYTES];
  sodium_mlock(secret_key, sizeof(secret_key));

  quint8 sk_version_tag;
  quint8 salt[crypto_pwhash_SALTBYTES];
  quint32 pwhash_opslimit;
  quint32 pwhash_memlimit;
  quint8 pwhash_alg;
  quint8 payload[sizeof(secret_key) + crypto_aead_chacha20poly1305_ABYTES];

  QDataStream key_in(encrypted_secret);
  key_in >> sk_version_tag;
  quint8 supported_version = key_format_version;
  if (sk_version_tag != supported_version)
  {
    sodium_munlock(secret_key, sizeof(secret_key));
    return ResponseResult::unsupported_version;
  }
  key_in.readRawData((char *)salt, sizeof(salt));
  key_in >> pwhash_opslimit;
  key_in >> pwhash_memlimit;
  key_in >> pwhash_alg;
  key_in.readRawData((char *)payload, sizeof(payload));

  quint8 wrap_key[crypto_aead_chacha20poly1305_KEYBYTES];
  sodium_mlock(wrap_key, sizeof(wrap_key));
  if (crypto_pwhash(wrap_key, sizeof(wrap_key), password.constData(), password.size(), salt, pwhash_opslimit, pwhash_memlimit, pwhash_alg))
  {
    sodium_munlock(wrap_key, sizeof(wrap_key));
    sodium_munlock(secret_key, sizeof(secret_key));
    return ResponseResult::derivation_failed;
  }

  const quint8 *packed_header = (quint8 *)encrypted_secret.constData();
  const size_t packed_header_len = sizeof(quint8) + sizeof(salt) + sizeof(quint32) + sizeof(quint32) + sizeof(quint8);

  if (crypto_aead_chacha20poly1305_decrypt(secret_key, nullptr, nullptr, payload, sizeof(payload), packed_header, packed_header_len, all_zero_nonce, wrap_key))
  {
    sodium_munlock(wrap_key, sizeof(wrap_key));
    sodium_munlock(secret_key, sizeof(secret_key));
    return ResponseResult::decryption_failed;
  }
  sodium_munlock(wrap_key, sizeof(wrap_key));

  // Now we've unlocked the key, and ready to perform DH with the server's
  // ephemeral key to prove ourselves.
  if (crypto_scalarmult(shared_secret, secret_key, (const uchar *)ephemeral_key.constData()))
  {
    // We ended up with the point at infinity, something stupid must've
    // happened. Reject.
    sodium_munlock(secret_key, sizeof(secret_key));
    return ResponseResult::bad_curve_point;
  }
  sodium_munlock(secret_key, sizeof(secret_key));

  return ResponseResult::success;
}

static ResponseResult compute_with_hardware_key(quint8 *shared_secret, const QByteArray &blob, QByteArrayView pin, QByteArrayView ephemeral_key)
{
  if (ephemeral_key.size() != 64)
  {
    return ResponseResult::incompatible_arguments;
  }
  stkb_user_input input = {
    .pin = (const uint8_t *)pin.constData(),
    .pin_len = (size_t)pin.size(),
    .key_name = nullptr,
  };
  stkb_rc rc = tpm2_compute_ss(shared_secret, (const uint8_t *)blob.constData(), (size_t)blob.size(), (const uint8_t *)ephemeral_key.constData(), input);
  switch (rc) {
    case STKB_SUCCESS:
      return ResponseResult::success;
    case STKB_ERROR:
      return ResponseResult::hardware_fault;
    case STKB_BAD_PIN:
    case STKB_AUTH_FAILURE:
      return ResponseResult::invalid_pin;
    case STKB_LOCKOUT:
      return ResponseResult::hardware_lockout;
    case STKB_TPM_UNAVAILABLE:
    default:
      return ResponseResult::inaccessible_keyring;
  }
}

KeyringKey acquire_keyring_key(QByteArrayView key_id)
{
  struct KeyringKey key;
  QFile index_file(get_keyring_path());
  if (!index_file.open(QIODevice::ReadOnly))
  {
    return key;
  }
  QCborStreamReader idx_in(&index_file);
  QCborValue index = QCborValue::fromCbor(idx_in);
  index_file.close();
  QCborMap tmp_map = index.toMap().value(QCborValue(QByteArray(key_id))).toMap();

  QCborValue val = tmp_map.value(2);
  if (!val.isByteArray())
  {
    return key;
  }
  key.public_key = val.toByteArray();

  val = tmp_map.value(3);
  if (!val.isByteArray())
  {
    return key;
  }
  key.protected_secret = val.toByteArray();

  val = tmp_map.value(0);
  if (!val.isInteger())
  {
    return key;
  }
  qint64 raw_tag = val.toInteger();
  if (raw_tag < 0 || raw_tag > 255)
  {
    return key;
  }
  quint8 tag = (quint8)raw_tag;
  switch (tag) {
  case cert_x25519_tag:
    key.type = KeyringStorageType::software;
    break;
  case cert_hwkey_p256_tag:
    key.type = KeyringStorageType::hardware;
    break;
  default:
    break;
  }

  val = tmp_map.value(1);
  if (!val.isString())
  {
    return key;
  }
  key.name = val.toString();


  key.id = QByteArray(key_id);

  return key;
}

// Look up the key_id in the keyring, unlock it with supplied password using
// parameters from its header, perform a Diffie-Hellman key exchange with the
// server's ephemeral key to derive a shared secret, and hash it along with
// additional data to prove your authenticity to the server.
ResponseResult unlock_and_auth(const KeyringKey &key, QByteArrayView input, QByteArrayView ephemeral_key, QByteArrayView username, AuthResponse &out)
{
  uint8_t shared_secret[32];
  sodium_mlock(shared_secret, sizeof(shared_secret));

  ResponseResult result;
  switch (key.type) {
  case KeyringStorageType::software:
    result = compute_with_software_key(shared_secret, key.protected_secret, input, ephemeral_key);
    break;
  case KeyringStorageType::hardware:
    result = compute_with_hardware_key(shared_secret, key.protected_secret, input, ephemeral_key);
    break;
  default:
    result = ResponseResult::corrupted_entry;
    break;
  }
  if (result != ResponseResult::success)
  {
    sodium_munlock(shared_secret, sizeof(shared_secret));
    return result;
  }

  // The proof is a BLAKE2b-256 hash over the following:
  //   1. Domain-separating constant.
  //   2. Shared DH secret, serving as a proof of posession of the secret key
  //      that the server can verify using our public key.
  //   3. Server's random ephemeral key used specifically in this attempt.
  //   4. Our public key, binding our identity.
  //   5. Our username, binding the transcript to the secret and the rest of the
  //      session context.
  //
  // Since the server possesses our certificate (public key) and its private
  // ephemeral key, it'll end up getting the same DH secret as we did. Assuming
  // everything else matches up in the transcript, it'll independently
  // calculate the same hash and successfully authenticate us.
  //
  // Note that BLAKE2 is resistant to length-extension attacks, and all fields
  // but last are fixed-length, eliminating encoding ambiguity and
  // canonicalization issues.
  quint8 proof[crypto_generichash_BYTES];
  crypto_generichash_state state;
  crypto_generichash_init(&state, nullptr, 0, sizeof(proof));
  crypto_generichash_update(&state, (const uchar *)domain_sep.constData(), domain_sep.size());
  crypto_generichash_update(&state, shared_secret, sizeof(shared_secret));
  crypto_generichash_update(&state, (const uchar *)ephemeral_key.constData(), ephemeral_key.size());
  crypto_generichash_update(&state, (const uchar *)key.public_key.constData(), key.public_key.size());
  crypto_generichash_update(&state, (const uchar *)username.constData(), username.size());
  crypto_generichash_final(&state, proof, sizeof(proof));

  out.response = QByteArray((const char *)proof, (qsizetype)sizeof(proof));

  sodium_munlock(shared_secret, sizeof(shared_secret));

  return ResponseResult::success;
}

// The errors aren't handled carefully, there's a hazard of stale locks being
// created. Fortunately, Qt seems to automatically detect these.
void delete_key(const QByteArray &key_id)
{
  QFile index_file(get_keyring_path());
  QLockFile index_lock(QDir(get_app_path()).filePath("keyring.lock"));
  if (!index_lock.lock())
  {
    return;
  }
  if (!index_file.open(QIODevice::ReadOnly))
  {
    return;
  }
  QCborStreamReader idx_in(&index_file);
  QCborValue index = QCborValue::fromCbor(idx_in);
  index_file.close();
  if (!index.isMap())
  {
    return;
  }
  QCborMap index_map = index.toMap();
  index_map.remove(QCborValue(key_id));
  QSaveFile index_file_w(get_keyring_path());
  if (!index_file_w.open(QIODevice::WriteOnly))
  {
    return;
  }
  index_file_w.write(QCborValue(index_map).toCbor());
  index_file_w.commit();
  index_lock.unlock();
}

// Keyring table model methods (for UI).

KeyringModel::KeyringModel(QObject *parent) : QAbstractTableModel(parent)
{}

int KeyringModel::rowCount(const QModelIndex &) const
{
  return m_keys.size();
}

int KeyringModel::columnCount(const QModelIndex &) const
{
  return 2;
}

QVariant KeyringModel::data(const QModelIndex &index, int role) const
{
  if (role == Qt::DisplayRole)
  {
    const KeyInfo entry = m_keys.at(index.row());
    switch (index.column())
    {
    case 0:
      return entry.name;
    case 1:
      return entry.bytes;
    default:
      return {};
    }
  }
  else if (role == Qt::FontRole && index.column() == 1)
  {
    // There's QFontDatabase::systemFont(QFontDatabase::FixedFont)
    QFont font;
    font.setFamily("Monospace");
    return font;
  }
  // "Get key ID" user role. Magic. There ought to be a more ergonomic way to
  // do this.
  else if (role == KeyIDRole)
  {
    return m_keys.at(index.row()).id;
  }
  return {};
}

// I have removed key ID from the table view, I believe it's better to have
// users create unique names, and if not, they still have certificates to
// differentiate. Key ID is purely internal. Make sure it can still be
// accessed by the key unlock dialog.
QVariant KeyringModel::headerData(int section, Qt::Orientation orientation, int role) const
{
  if (role != Qt::DisplayRole || orientation != Qt::Horizontal)
  {
    return {};
  }
  switch (section)
  {
  case 0:
    return "Note";
  case 1:
    return "Certificate";
  default:
    return {};
  }
}

// Load the keyring from the file into the table to be displayed in the Keyring
// tab. Called whenever the keyring is modified.
void KeyringModel::load_keys()
{
  QFile index_file(get_keyring_path());
  if (!index_file.open(QIODevice::ReadOnly))
  {
    return;
  }
  QCborStreamReader idx_in(&index_file);
  const QCborValue index = QCborValue::fromCbor(idx_in);
  index_file.close();
  if (!index.isMap())
  {
    return;
  }
  const QCborMap index_map = index.toMap();
  QVector<KeyInfo> infos;
  infos.reserve(index_map.size());
  for (QCborMap::const_iterator i = index_map.cbegin(); i != index_map.cend(); ++i)
  {
    KeyInfo entry;

    const QCborValue key = i.key();
    if (key.isByteArray())
    {
      entry.id = key.toByteArray();
    }
    else
    {
      entry.id.clear();
    }

    const QCborValue val = i.value();
    if (val.isMap())
    {
      const QCborMap map = val.toMap();
      entry.name = map.value(1).toString();
      quint8 key_tag = (quint8)map.value(0).toInteger();
      if (map.value(2).isByteArray() && key_tag != 0)
      {
        key_tag = key_tag == 0x1 ? 0x56 : key_tag; // TODO: legacy
        const QByteArray pk = map.value(2).toByteArray();
        QByteArray tagged;
        tagged.reserve(sizeof(key_tag) + pk.size());
        tagged.append(key_tag);
        tagged.append(pk);
        entry.bytes = QString::fromLatin1(tagged.toBase64(QByteArray::Base64UrlEncoding | QByteArray::OmitTrailingEquals));
      }
      else
      {
        entry.bytes.clear();
      }
    }
    infos.append(entry);
  }
  beginResetModel();
  m_keys = infos;
  endResetModel();
}