diff --git a/lib/encryption/ssss.dart b/lib/encryption/ssss.dart index 44863eee1..a6bad7d11 100644 --- a/lib/encryption/ssss.dart +++ b/lib/encryption/ssss.dart @@ -211,13 +211,57 @@ class SSSS { .key; Future setDefaultKeyId(String keyId) async { - await client.setAccountData( - client.userID!, + await _setAccountDataAndWaitForSync( EventTypes.SecretStorageDefaultKey, SecretStorageDefaultKeyContent(key: keyId).toJson(), ); } + /// PUTs account data, then waits until [/sync] has applied the same payload so + /// [Client.accountData] and the DB stay aligned (both are updated in + /// [Client]'s sync handler). [MatrixApi.setAccountData] alone does not touch + /// local state; mirroring the PUT locally would race concurrent sync updates. + Future _setAccountDataAndWaitForSync( + String type, + Map content, + ) async { + final expected = content.copy(); + await client.setAccountData(client.userID!, type, content); + await _waitForAccountDataFromSync(type, expected); + } + + Future _waitForAccountDataFromSync( + String type, + Map expectedContent, + ) async { + bool matchesExpected() { + final ev = client.accountData[type]; + if (ev == null) return false; + return const DeepCollectionEquality().equals(ev.content, expectedContent); + } + + if (matchesExpected()) return; + + final completer = Completer(); + final subscription = client.onAccountData.stream.listen((event) { + if (event.type == type && matchesExpected()) { + if (!completer.isCompleted) completer.complete(); + } + }); + try { + if (matchesExpected()) return; + await completer.future.timeout( + const Duration(seconds: 60), + onTimeout: () => throw TimeoutException( + 'Timed out waiting for account data "$type" from sync after ' + 'setAccountData.', + ), + ); + } finally { + await subscription.cancel(); + } + } + SecretStorageKeyContent? getKey(String keyId) { return client.accountData[EventTypes.secretStorageKey(keyId)] ?.parsedSecretStorageKeyContent; @@ -271,16 +315,7 @@ class SSSS { final accountDataTypeKeyId = EventTypes.secretStorageKey(keyId); // noooow we set the account data - await client.setAccountData( - client.userID!, - accountDataTypeKeyId, - content.toJson(), - ); - - while (!client.accountData.containsKey(accountDataTypeKeyId)) { - Logs().v('Waiting accountData to have $accountDataTypeKeyId'); - await client.oneShotSync(); - } + await _setAccountDataAndWaitForSync(accountDataTypeKeyId, content.toJson()); final key = open(keyId); await key.setPrivateKey(privateKey); @@ -290,9 +325,13 @@ class SSSS { Future checkKey(Uint8List key, SecretStorageKeyContent info) async { if (info.algorithm == AlgorithmTypes.secretStorageV1AesHmcSha2) { if ((info.mac is String) && (info.iv is String)) { - final encrypted = await encryptAes(zeroStr, key, '', info.iv); - return info.mac!.replaceAll(RegExp(r'=+$'), '') == - encrypted.mac.replaceAll(RegExp(r'=+$'), ''); + return client.nativeImplementations.checkSecretStorageKey( + CheckSecretStorageKeyArgs( + key: key, + iv: info.iv!, + mac: info.mac!, + ), + ); } else { // no real information about the key, assume it is valid return true; @@ -397,7 +436,10 @@ class SSSS { 'mac': encrypted.mac, }; // store the thing in your account data - await client.setAccountData(client.userID!, type, content); + await _setAccountDataAndWaitForSync( + type, + Map.from(content), + ); final db = client.database; if (cacheTypes.contains(type)) { // cache the thing @@ -413,8 +455,9 @@ class SSSS { String type, String secret, String keyId, - Uint8List key, - ) async { + Uint8List key, { + bool isDefaultKey = true, + }) async { if (await getStored(type, keyId, key) != secret) { throw Exception('Secrets do not match up!'); } @@ -428,17 +471,22 @@ class SSSS { throw Exception('Wrong type for encrypted content!'); } - final otherKeys = - Set.from(encryptedContent.keys.where((k) => k != keyId)); + final defaultKeyId = this.defaultKeyId; + final otherKeys = Set.from( + encryptedContent.keys.where( + (k) => isDefaultKey || defaultKeyId == null + ? k != keyId + : k != keyId && k != defaultKeyId, + ), + ); encryptedContent.removeWhere((k, v) => otherKeys.contains(k)); - // yes, we are paranoid... + content['encrypted'] = encryptedContent; + // Yes, we are paranoid... if (await getStored(type, keyId, key) != secret) { throw Exception('Secrets do not match up!'); } - // store the thing in your account data - await client.setAccountData(client.userID!, type, content); + await _setAccountDataAndWaitForSync(type, content); if (cacheTypes.contains(type)) { - // cache the thing final ciphertext = encryptedContent .tryGetMap(keyId) ?.tryGet('ciphertext'); @@ -644,6 +692,212 @@ class SSSS { return null; } + /// Resolves the key id for a secret-storage key definition by its [name] + /// field on `m.secret_storage.key.` account data. + /// + /// If several keys share the same [name] (e.g. an orphaned definition left + /// on the server after rotation), returns the id that appears most often in + /// encrypted secret account data from [analyzeEncryptedSecrets]. Ties use + /// lexicographic key order. Returns null when no key with that [name] + /// exists. + String? keyIdForNamedSecretStorageKey(String name) { + if (name.isEmpty) return null; + const prefix = 'm.secret_storage.key.'; + final candidates = []; + for (final entry in client.accountData.entries) { + if (!entry.key.startsWith(prefix)) continue; + final keyName = entry.value.content['name']; + if (keyName == name) { + candidates.add(entry.key.substring(prefix.length)); + } + } + if (candidates.isEmpty) return null; + if (candidates.length == 1) return candidates.first; + + final usage = {for (final id in candidates) id: 0}; + for (final keyIds in analyzeEncryptedSecrets().values) { + for (final kid in keyIds) { + if (usage.containsKey(kid)) { + usage[kid] = usage[kid]! + 1; + } + } + } + final ranked = usage.entries.toList() + ..sort((a, b) { + final byCount = b.value.compareTo(a.value); + if (byCount != 0) return byCount; + return a.key.compareTo(b.key); + }); + if (ranked.first.value > 0) { + return ranked.first.key; + } + return candidates.first; + } + + /// Returns secret event types mapped to valid SSSS key ids. + /// + /// Only account data entries with a valid `encrypted` map shape are included. + Map> analyzeEncryptedSecrets() { + final secrets = >{}; + for (final entry in client.accountData.entries) { + final type = entry.key; + final event = entry.value; + final encryptedContent = event.content.tryGetMap( + 'encrypted', + ); + if (encryptedContent == null) continue; + + final validKeys = {}; + for (final keyEntry in encryptedContent.entries) { + final key = keyEntry.key; + final value = keyEntry.value; + if (!_isUsableEncryptedKeyEntry(key, value)) continue; + validKeys.add(key); + } + if (validKeys.isNotEmpty) { + secrets[type] = validKeys; + } + } + return secrets; + } + + /// Returns whether [type] has malformed encrypted entries, or entries + /// encrypted with invalid key ids. + bool hasInvalidEncryptedEntries(String type) { + final encryptedContent = client.accountData[type]?.content + .tryGetMap('encrypted'); + if (encryptedContent == null) return false; + + for (final keyEntry in encryptedContent.entries) { + final key = keyEntry.key; + final value = keyEntry.value; + if (value is! Map) continue; + if (!_isUsableEncryptedKeyEntry(key, value)) return true; + } + return false; + } + + bool _isUsableEncryptedKeyEntry(String key, Object? value) { + if (value is! Map) return false; + if (value['iv'] is! String || + value['ciphertext'] is! String || + value['mac'] is! String) { + return false; + } + return isKeyValid(key); + } + + /// Ordered key ids to try for migration: + /// preferred key first, then all other candidates once. + List orderedCandidateKeyIds( + Map> secretsByType, + String preferredKeyId, + ) { + final ordered = [preferredKeyId]; + for (final keyIds in secretsByType.values) { + for (final keyId in keyIds) { + if (keyId != preferredKeyId && !ordered.contains(keyId)) { + ordered.add(keyId); + } + } + } + return ordered; + } + + /// Migrates available secrets from old keys to [destinationKey]. + /// + /// Returns the set of secret types that were successfully migrated. + Future> migrateSecretsToKey({ + required OpenSSSS primaryUnlockedKey, + required OpenSSSS destinationKey, + String? unlockCredential, + Map? candidateOldKeys, + bool stripKeys = false, + bool stripAsDefaultKey = true, + }) async { + final remainingSecrets = analyzeEncryptedSecrets(); + final keyIds = + orderedCandidateKeyIds(remainingSecrets, primaryUnlockedKey.keyId); + if (keyIds.isEmpty) return {}; + + final migratedSecretTypes = {}; + Set claimSecretsForKey(String keyId) { + final claimed = remainingSecrets.entries + .where((entry) => entry.value.contains(keyId)) + .map((entry) => entry.key) + .toSet(); + remainingSecrets.removeWhere((_, keys) => keys.contains(keyId)); + return claimed; + } + + for (final keyId in keyIds) { + final key = keyId == primaryUnlockedKey.keyId + ? primaryUnlockedKey + : candidateOldKeys?[keyId] ?? + await _tryOpenAndUnlockKey( + keyId, + unlockCredential: unlockCredential, + ); + if (key == null || !key.isUnlocked) continue; + + for (final secretType in claimSecretsForKey(keyId)) { + final secret = await key.getStored(secretType); + await destinationKey.store(secretType, secret, add: true); + migratedSecretTypes.add(secretType); + } + if (remainingSecrets.isEmpty) break; + } + if (stripKeys) { + await _validateAndStripMigratedSecrets( + destinationKey: destinationKey, + migratedSecretTypes: migratedSecretTypes, + isDefaultKey: stripAsDefaultKey, + ); + } + return migratedSecretTypes; + } + + /// Validates migrated secrets for [destinationKey] and strips all other keys + /// from each migrated secret type. + Future _validateAndStripMigratedSecrets({ + required OpenSSSS destinationKey, + required Iterable migratedSecretTypes, + bool isDefaultKey = true, + }) async { + for (final type in migratedSecretTypes) { + final secret = await destinationKey.getStored(type); + await destinationKey.validateAndStripOtherKeys( + type, + secret, + isDefaultKey: isDefaultKey, + ); + } + await destinationKey.maybeCacheAll(); + } + + Future _tryOpenAndUnlockKey( + String keyId, { + String? unlockCredential, + }) async { + try { + final key = open(keyId); + if (unlockCredential == null || key.isUnlocked) return key; + try { + await key.unlock(keyOrPassphrase: unlockCredential); + } catch (e, s) { + Logs().v( + 'Could not unlock SSSS key $keyId with provided credential', + e, + s, + ); + } + return key; + } catch (e, s) { + Logs().v('Skipping unavailable SSSS key $keyId during migration', e, s); + return null; + } + } + String? keyIdFromType(String type) { final keys = keyIdsFromType(type); if (keys == null || keys.isEmpty) { @@ -799,12 +1053,22 @@ class OpenSSSS { } } - Future validateAndStripOtherKeys(String type, String secret) async { + Future validateAndStripOtherKeys( + String type, + String secret, { + bool isDefaultKey = true, + }) async { final privateKey = this.privateKey; if (privateKey == null) { throw Exception('SSSS not unlocked'); } - await ssss.validateAndStripOtherKeys(type, secret, keyId, privateKey); + await ssss.validateAndStripOtherKeys( + type, + secret, + keyId, + privateKey, + isDefaultKey: isDefaultKey, + ); } Future maybeCacheAll() async { diff --git a/lib/encryption/utils/bootstrap.dart b/lib/encryption/utils/bootstrap.dart index 2783fc89f..7da5de1c3 100644 --- a/lib/encryption/utils/bootstrap.dart +++ b/lib/encryption/utils/bootstrap.dart @@ -77,7 +77,6 @@ class Bootstrap { BootstrapState _state = BootstrapState.loading; Map? oldSsssKeys; OpenSSSS? newSsssKey; - Map? secretMap; Bootstrap({required this.encryption, this.onUpdate}) { if (analyzeSecrets().isNotEmpty) { @@ -101,42 +100,14 @@ class Bootstrap { } return newSecrets; } - final secrets = >{}; + final secrets = encryption.ssss.analyzeEncryptedSecrets(); for (final entry in client.accountData.entries) { final type = entry.key; - final event = entry.value; - final encryptedContent = - event.content.tryGetMap('encrypted'); - if (encryptedContent == null) { - continue; + if (secrets.containsKey(type)) continue; + if (encryption.ssss.hasInvalidEncryptedEntries(type)) { + // no valid keys for this type, but invalid encrypted entries exist + secrets[type] = {}; } - final validKeys = {}; - final invalidKeys = {}; - for (final keyEntry in encryptedContent.entries) { - final key = keyEntry.key; - final value = keyEntry.value; - if (value is! Map) { - // we don't add the key to invalidKeys as this was not a proper secret anyways! - continue; - } - if (value['iv'] is! String || - value['ciphertext'] is! String || - value['mac'] is! String) { - invalidKeys.add(key); - continue; - } - if (!encryption.ssss.isKeyValid(key)) { - invalidKeys.add(key); - continue; - } - validKeys.add(key); - } - if (validKeys.isEmpty && invalidKeys.isEmpty) { - continue; // this didn't contain any keys anyways! - } - // if there are no valid keys and only invalid keys then the validKeys set will be empty - // from that we know that there were errors with this secret and that we won't be able to migrate it - secrets[type] = validKeys; } _secretsCache = secrets; return analyzeSecrets(); @@ -267,32 +238,16 @@ class Bootstrap { Logs().v('Create key...'); newSsssKey = await encryption.ssss.createKey(passphrase, name); if (oldSsssKeys != null) { - // alright, we have to re-encrypt old secrets with the new key - final secrets = analyzeSecrets(); - Set removeKey(String key) { - final s = secrets.entries - .where((e) => e.value.contains(key)) - .map((e) => e.key) - .toSet(); - secrets.removeWhere((k, v) => v.contains(key)); - return s; - } - - secretMap = {}; - for (final entry in oldSsssKeys!.entries) { - final key = entry.value; - final keyId = entry.key; - if (!key.isUnlocked) { - continue; - } - for (final s in removeKey(keyId)) { - Logs().v('Get stored key of type $s...'); - secretMap![s] = await key.getStored(s); - Logs().v('Store new secret with this key...'); - await newSsssKey!.store(s, secretMap![s]!, add: true); - } - } - // alright, we re-encrypted all the secrets. We delete the dead weight only *after* we set our key to the default key + final existingOldKeys = oldSsssKeys!; + final primaryUnlockedKey = + existingOldKeys[encryption.ssss.defaultKeyId] ?? + existingOldKeys.values.first; + await encryption.ssss.migrateSecretsToKey( + primaryUnlockedKey: primaryUnlockedKey, + destinationKey: newSsssKey!, + candidateOldKeys: existingOldKeys, + stripKeys: true, + ); } await encryption.ssss.setDefaultKeyId(newSsssKey!.keyId); while (encryption.ssss.defaultKeyId != newSsssKey!.keyId) { @@ -301,14 +256,6 @@ class Bootstrap { ); await client.oneShotSync(); } - if (oldSsssKeys != null) { - for (final entry in secretMap!.entries) { - Logs().v('Validate and stripe other keys ${entry.key}...'); - await newSsssKey!.validateAndStripOtherKeys(entry.key, entry.value); - } - Logs().v('And make super sure we have everything cached...'); - await newSsssKey!.maybeCacheAll(); - } } catch (e, s) { Logs().e('[Bootstrapping] Error trying to migrate old secrets', e, s); state = BootstrapState.error; diff --git a/lib/src/utils/native_implementations.dart b/lib/src/utils/native_implementations.dart index ba0163e1b..8b095cc66 100644 --- a/lib/src/utils/native_implementations.dart +++ b/lib/src/utils/native_implementations.dart @@ -1,7 +1,11 @@ import 'dart:async'; +import 'dart:convert'; import 'dart:typed_data'; +import 'package:vodozemac/vodozemac.dart'; + import 'package:matrix/encryption.dart'; +import 'package:matrix/encryption/utils/base64_unpadded.dart'; import 'package:matrix/matrix.dart'; import 'package:matrix/src/utils/compute_callback.dart'; @@ -50,6 +54,8 @@ abstract class NativeImplementations { bool retryInDummy = false, }); + FutureOr checkSecretStorageKey(CheckSecretStorageKeyArgs args); + /// this implementation will catch any non-implemented method @override dynamic noSuchMethod(Invocation invocation) { @@ -76,12 +82,26 @@ abstract class NativeImplementations { return dummy.shrinkImage(argument); case 'calcImageMetadata': return dummy.calcImageMetadata(argument); + case 'checkSecretStorageKey': + return dummy.checkSecretStorageKey(argument); default: return super.noSuchMethod(invocation); } } } +class CheckSecretStorageKeyArgs { + final Uint8List key; + final String iv; + final String mac; + + const CheckSecretStorageKeyArgs({ + required this.key, + required this.iv, + required this.mac, + }); +} + class NativeImplementationsDummy extends NativeImplementations { const NativeImplementationsDummy(); @@ -124,6 +144,47 @@ class NativeImplementationsDummy extends NativeImplementations { }) { return MatrixImageFile.calcMetadataImplementation(bytes); } + + @override + FutureOr checkSecretStorageKey(CheckSecretStorageKeyArgs args) { + final iv = base64decodeUnpadded(args.iv); + iv[8] &= 0x7f; + + final zerosalt = Uint8List(8); + final prk = CryptoUtils.hmac(key: zerosalt, input: args.key); + final b = Uint8List(1); + + b[0] = 1; + final aesKey = CryptoUtils.hmac( + key: prk, + input: utf8.encode('') + b, + ); + + b[0] = 2; + final hmacKey = CryptoUtils.hmac( + key: prk, + input: aesKey + utf8.encode('') + b, + ); + + const zeroStr = + '\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00' + '\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00'; + + final plain = Uint8List.fromList(utf8.encode(zeroStr)); + final ciphertext = CryptoUtils.aesCtr( + input: plain, + key: Uint8List.fromList(aesKey), + iv: iv, + ); + final computedMac = CryptoUtils.hmac( + key: Uint8List.fromList(hmacKey), + input: ciphertext, + ); + + final expected = args.mac.replaceAll(RegExp(r'=+$'), ''); + final actual = base64.encode(computedMac).replaceAll(RegExp(r'=+$'), ''); + return expected == actual; + } } /// a [NativeImplementations] based on Flutter's `compute` function @@ -213,4 +274,12 @@ class NativeImplementationsIsolate extends NativeImplementations { bytes, ); } + + @override + Future checkSecretStorageKey(CheckSecretStorageKeyArgs args) { + return runInBackground( + NativeImplementations.dummy.checkSecretStorageKey, + args, + ); + } } diff --git a/lib/src/utils/web_worker/native_implementations_web_worker.dart b/lib/src/utils/web_worker/native_implementations_web_worker.dart index 4c490abe8..3f832a65a 100644 --- a/lib/src/utils/web_worker/native_implementations_web_worker.dart +++ b/lib/src/utils/web_worker/native_implementations_web_worker.dart @@ -112,6 +112,12 @@ class NativeImplementationsWebWorker extends NativeImplementations { return NativeImplementations.dummy.shrinkImage(args); } } + + @override + FutureOr checkSecretStorageKey(CheckSecretStorageKeyArgs args) { + // Fallback: web worker only supports image computation in this SDK version. + return NativeImplementations.dummy.checkSecretStorageKey(args); + } } class WebWorkerData { diff --git a/lib/src/utils/web_worker/native_implementations_web_worker_stub.dart b/lib/src/utils/web_worker/native_implementations_web_worker_stub.dart index 2fb87c1af..4ba0fcbf4 100644 --- a/lib/src/utils/web_worker/native_implementations_web_worker_stub.dart +++ b/lib/src/utils/web_worker/native_implementations_web_worker_stub.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'package:matrix/matrix.dart'; +import '../native_implementations.dart'; class NativeImplementationsWebWorker extends NativeImplementations { /// the default handler for stackTraces in web workers @@ -13,6 +14,12 @@ class NativeImplementationsWebWorker extends NativeImplementations { Duration timeout = const Duration(seconds: 30), WebWorkerStackTraceCallback onStackTrace = defaultStackTraceHandler, }); + + @override + FutureOr checkSecretStorageKey(CheckSecretStorageKeyArgs args) { + // Fallback: stub is only used when web workers are unavailable. + return NativeImplementations.dummy.checkSecretStorageKey(args); + } } class WebWorkerError extends Error { diff --git a/test/encryption/ssss_test.dart b/test/encryption/ssss_test.dart index f493ce046..cf61a8f21 100644 --- a/test/encryption/ssss_test.dart +++ b/test/encryption/ssss_test.dart @@ -16,7 +16,6 @@ * along with this program. If not, see . */ -import 'dart:convert'; import 'dart:math'; import 'dart:typed_data'; @@ -103,19 +102,14 @@ void main() { expect(handle.isUnlocked, true); FakeMatrixApi.calledEndpoints.clear(); - // OpenSSSS store waits for accountdata to be updated before returning - // but we can't update that before the below endpoint is not hit. await handle.ssss .store('best animal', 'foxies', handle.keyId, handle.privateKey!); - final content = FakeMatrixApi - .calledEndpoints[ - '/client/v3/user/%40test%3AfakeServer.notExisting/account_data/best%20animal']! - .first; - client.accountData['best animal'] = BasicEvent.fromJson({ - 'type': 'best animal', - 'content': json.decode(content), - }); + expect( + FakeMatrixApi.calledEndpoints[ + '/client/v3/user/%40test%3AfakeServer.notExisting/account_data/best%20animal'], + isNotNull, + ); expect(await handle.getStored('best animal'), 'foxies'); }); @@ -516,6 +510,128 @@ void main() { await testKey.setPrivateKey(newKey.privateKey!); }); + test('migrateSecretsToKey strips non-allowed keys when requested', () async { + final ssss = client.encryption!.ssss; + final defaultKeyId = ssss.defaultKeyId!; + final defaultKey = ssss.open(defaultKeyId); + await defaultKey.unlock(recoveryKey: ssssKey); + + final passphraseKey = await ssss.createKey( + 'test-passphrase', + 'passphrase', + ); + final migratedSecretTypes = await ssss.migrateSecretsToKey( + primaryUnlockedKey: defaultKey, + destinationKey: passphraseKey, + stripKeys: true, + stripAsDefaultKey: false, + ); + expect(migratedSecretTypes, isNotEmpty); + + final migratedType = migratedSecretTypes.first; + final encrypted = client.accountData[migratedType]!.content + .tryGetMap('encrypted')!; + expect(encrypted.containsKey(defaultKeyId), true); + expect(encrypted.containsKey(passphraseKey.keyId), true); + + final allowed = {defaultKeyId, passphraseKey.keyId}; + final analyzed = ssss.analyzeEncryptedSecrets()[migratedType]; + expect(analyzed, isNotNull); + expect(analyzed!.difference(allowed), isEmpty); + }); + + test('migrateSecretsToKey without migrated types does not strip', () async { + final ssss = client.encryption!.ssss; + final defaultKeyId = ssss.defaultKeyId!; + final defaultKey = ssss.open(defaultKeyId); + await defaultKey.unlock(recoveryKey: ssssKey); + + final passphraseKey = await ssss.createKey( + 'test-passphrase-new', + 'passphrase-new', + ); + final staleKey = await ssss.createKey('test-passphrase-old', 'passphrase-old'); + + const secretType = EventTypes.CrossSigningSelfSigning; + final secret = await defaultKey.getStored(secretType); + await passphraseKey.store(secretType, secret, add: true); + await staleKey.store(secretType, secret, add: true); + await ssss.migrateSecretsToKey( + primaryUnlockedKey: defaultKey, + destinationKey: passphraseKey, + stripKeys: true, + stripAsDefaultKey: false, + ); + + final encrypted = client.accountData[secretType]!.content + .tryGetMap('encrypted')!; + expect(encrypted.keys.toSet(), { + defaultKeyId, + passphraseKey.keyId, + staleKey.keyId, + }); + final analyzed = ssss.analyzeEncryptedSecrets()[secretType]; + expect(analyzed, isNotNull); + expect( + analyzed!.contains(passphraseKey.keyId), + true, + ); + expect( + analyzed.contains(staleKey.keyId), + true, + ); + }); + + test( + 'keyIdForNamedSecretStorageKey prefers key used in encrypted secrets ' + 'when names collide', + () async { + final ssss = client.encryption!.ssss; + final defaultKeyId = ssss.defaultKeyId!; + final defaultKey = ssss.open(defaultKeyId); + await defaultKey.unlock(recoveryKey: ssssKey); + + const dupName = 'duplicate-name-ssss-test'; + final orphan = await ssss.createKey('orphan-pass', dupName); + final used = await ssss.createKey('used-pass', dupName); + await ssss.migrateSecretsToKey( + primaryUnlockedKey: defaultKey, + destinationKey: used, + ); + + expect( + ssss.keyIdForNamedSecretStorageKey(dupName), + used.keyId, + reason: 'Orphan key shares name but is not referenced by secrets', + ); + expect(orphan.keyId, isNot(used.keyId)); + }, + ); + + test('hasInvalidEncryptedEntries detects invalid key ids', () async { + final ssss = client.encryption!.ssss; + final defaultKeyId = ssss.defaultKeyId!; + final encrypted = client + .accountData[EventTypes.CrossSigningSelfSigning]!.content + .tryGetMap('encrypted')!; + final validPayload = Map.from( + encrypted[defaultKeyId] as Map, + ); + + await client.setAccountData(client.userID!, 'm.test.valid.secret', { + 'encrypted': {defaultKeyId: validPayload}, + }); + expect(ssss.hasInvalidEncryptedEntries('m.test.valid.secret'), false); + + await client.setAccountData(client.userID!, 'm.test.invalid.secret', { + 'encrypted': { + 'missing-fields': {'iv': 'a'}, + 'invalid-key-id': validPayload, + }, + }); + expect(ssss.hasInvalidEncryptedEntries('m.test.invalid.secret'), true); + }); + test('dispose client', () async { await client.dispose(closeDatabase: true); });