Initial commit version 0.8.13
This commit is contained in:
commit
9526dfa4f2
111 changed files with 35074 additions and 0 deletions
210
lib/encryption/cross_signing.dart
Normal file
210
lib/encryption/cross_signing.dart
Normal file
|
|
@ -0,0 +1,210 @@
|
|||
/*
|
||||
* Famedly Matrix SDK
|
||||
* Copyright (C) 2020, 2021 Famedly GmbH
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as
|
||||
* published by the Free Software Foundation, either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:matrix/encryption/utils/base64_unpadded.dart';
|
||||
import 'package:olm/olm.dart' as olm;
|
||||
|
||||
import '../matrix.dart';
|
||||
import 'encryption.dart';
|
||||
import 'ssss.dart';
|
||||
|
||||
class CrossSigning {
|
||||
final Encryption encryption;
|
||||
Client get client => encryption.client;
|
||||
CrossSigning(this.encryption) {
|
||||
encryption.ssss.setValidator(EventTypes.CrossSigningSelfSigning,
|
||||
(String secret) async {
|
||||
final keyObj = olm.PkSigning();
|
||||
try {
|
||||
return keyObj.init_with_seed(base64decodeUnpadded(secret)) ==
|
||||
client.userDeviceKeys[client.userID]!.selfSigningKey!.ed25519Key;
|
||||
} catch (_) {
|
||||
return false;
|
||||
} finally {
|
||||
keyObj.free();
|
||||
}
|
||||
});
|
||||
encryption.ssss.setValidator(EventTypes.CrossSigningUserSigning,
|
||||
(String secret) async {
|
||||
final keyObj = olm.PkSigning();
|
||||
try {
|
||||
return keyObj.init_with_seed(base64decodeUnpadded(secret)) ==
|
||||
client.userDeviceKeys[client.userID]!.userSigningKey!.ed25519Key;
|
||||
} catch (_) {
|
||||
return false;
|
||||
} finally {
|
||||
keyObj.free();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
bool get enabled =>
|
||||
encryption.ssss.isSecret(EventTypes.CrossSigningSelfSigning) &&
|
||||
encryption.ssss.isSecret(EventTypes.CrossSigningUserSigning) &&
|
||||
encryption.ssss.isSecret(EventTypes.CrossSigningMasterKey);
|
||||
|
||||
Future<bool> isCached() async {
|
||||
if (!enabled) {
|
||||
return false;
|
||||
}
|
||||
return (await encryption.ssss
|
||||
.getCached(EventTypes.CrossSigningSelfSigning)) !=
|
||||
null &&
|
||||
(await encryption.ssss.getCached(EventTypes.CrossSigningUserSigning)) !=
|
||||
null;
|
||||
}
|
||||
|
||||
Future<void> selfSign(
|
||||
{String? passphrase,
|
||||
String? recoveryKey,
|
||||
String? keyOrPassphrase,
|
||||
OpenSSSS? openSsss}) async {
|
||||
var handle = openSsss;
|
||||
if (handle == null) {
|
||||
handle = encryption.ssss.open(EventTypes.CrossSigningMasterKey);
|
||||
await handle.unlock(
|
||||
passphrase: passphrase,
|
||||
recoveryKey: recoveryKey,
|
||||
keyOrPassphrase: keyOrPassphrase,
|
||||
postUnlock: false,
|
||||
);
|
||||
await handle.maybeCacheAll();
|
||||
}
|
||||
final masterPrivateKey = base64decodeUnpadded(
|
||||
await handle.getStored(EventTypes.CrossSigningMasterKey));
|
||||
final keyObj = olm.PkSigning();
|
||||
String? masterPubkey;
|
||||
try {
|
||||
masterPubkey = keyObj.init_with_seed(masterPrivateKey);
|
||||
} catch (e) {
|
||||
masterPubkey = null;
|
||||
} finally {
|
||||
keyObj.free();
|
||||
}
|
||||
final userDeviceKeys =
|
||||
client.userDeviceKeys[client.userID]?.deviceKeys[client.deviceID];
|
||||
if (masterPubkey == null || userDeviceKeys == null) {
|
||||
throw Exception('Master or user keys not found');
|
||||
}
|
||||
final masterKey = client.userDeviceKeys[client.userID]?.masterKey;
|
||||
if (masterKey == null || masterKey.ed25519Key != masterPubkey) {
|
||||
throw Exception('Master pubkey key doesn\'t match');
|
||||
}
|
||||
// master key is valid, set it to verified
|
||||
await masterKey.setVerified(true, false);
|
||||
// and now sign both our own key and our master key
|
||||
await sign([
|
||||
masterKey,
|
||||
userDeviceKeys,
|
||||
]);
|
||||
}
|
||||
|
||||
bool signable(List<SignableKey> keys) => keys.any((key) =>
|
||||
key is CrossSigningKey && key.usage.contains('master') ||
|
||||
key is DeviceKeys &&
|
||||
key.userId == client.userID &&
|
||||
key.identifier != client.deviceID);
|
||||
|
||||
Future<void> sign(List<SignableKey> keys) async {
|
||||
final signedKeys = <MatrixSignableKey>[];
|
||||
Uint8List? selfSigningKey;
|
||||
Uint8List? userSigningKey;
|
||||
final userKeys = client.userDeviceKeys[client.userID];
|
||||
if (userKeys == null) {
|
||||
throw Exception('[sign] keys are not in cache but sign was called');
|
||||
}
|
||||
|
||||
final addSignature =
|
||||
(SignableKey key, SignableKey signedWith, String signature) {
|
||||
final signedKey = key.cloneForSigning();
|
||||
((signedKey.signatures ??=
|
||||
<String, Map<String, String>>{})[signedWith.userId] ??=
|
||||
<String, String>{})['ed25519:${signedWith.identifier}'] = signature;
|
||||
signedKeys.add(signedKey);
|
||||
};
|
||||
|
||||
for (final key in keys) {
|
||||
if (key.userId == client.userID) {
|
||||
// we are singing a key of ourself
|
||||
if (key is CrossSigningKey) {
|
||||
if (key.usage.contains('master')) {
|
||||
// okay, we'll sign our own master key
|
||||
final signature =
|
||||
encryption.olmManager.signString(key.signingContent);
|
||||
addSignature(key, userKeys.deviceKeys[client.deviceID]!, signature);
|
||||
}
|
||||
// we don't care about signing other cross-signing keys
|
||||
} else {
|
||||
// okay, we'll sign a device key with our self signing key
|
||||
selfSigningKey ??= base64decodeUnpadded(await encryption.ssss
|
||||
.getCached(EventTypes.CrossSigningSelfSigning) ??
|
||||
'');
|
||||
if (selfSigningKey.isNotEmpty) {
|
||||
final signature = _sign(key.signingContent, selfSigningKey);
|
||||
addSignature(key, userKeys.selfSigningKey!, signature);
|
||||
}
|
||||
}
|
||||
} else if (key is CrossSigningKey && key.usage.contains('master')) {
|
||||
// we are signing someone elses master key
|
||||
userSigningKey ??= base64decodeUnpadded(await encryption.ssss
|
||||
.getCached(EventTypes.CrossSigningUserSigning) ??
|
||||
'');
|
||||
if (userSigningKey.isNotEmpty) {
|
||||
final signature = _sign(key.signingContent, userSigningKey);
|
||||
addSignature(key, userKeys.userSigningKey!, signature);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (signedKeys.isNotEmpty) {
|
||||
// post our new keys!
|
||||
final payload = <String, Map<String, Map<String, dynamic>>>{};
|
||||
for (final key in signedKeys) {
|
||||
if (key.identifier == null ||
|
||||
key.signatures == null ||
|
||||
key.signatures?.isEmpty != false) {
|
||||
continue;
|
||||
}
|
||||
if (!payload.containsKey(key.userId)) {
|
||||
payload[key.userId] = <String, Map<String, dynamic>>{};
|
||||
}
|
||||
if (payload[key.userId]?[key.identifier]?['signatures'] != null) {
|
||||
// we need to merge signature objects
|
||||
payload[key.userId]![key.identifier]!['signatures']
|
||||
.addAll(key.signatures);
|
||||
} else {
|
||||
// we can just add signatures
|
||||
payload[key.userId]![key.identifier!] = key.toJson();
|
||||
}
|
||||
}
|
||||
|
||||
await client.uploadCrossSigningSignatures(payload);
|
||||
}
|
||||
}
|
||||
|
||||
String _sign(String canonicalJson, Uint8List key) {
|
||||
final keyObj = olm.PkSigning();
|
||||
try {
|
||||
keyObj.init_with_seed(key);
|
||||
return keyObj.sign(canonicalJson);
|
||||
} finally {
|
||||
keyObj.free();
|
||||
}
|
||||
}
|
||||
}
|
||||
455
lib/encryption/encryption.dart
Normal file
455
lib/encryption/encryption.dart
Normal file
|
|
@ -0,0 +1,455 @@
|
|||
/*
|
||||
* Famedly Matrix SDK
|
||||
* Copyright (C) 2019, 2020, 2021 Famedly GmbH
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as
|
||||
* published by the Free Software Foundation, either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import 'dart:convert';
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:olm/olm.dart' as olm;
|
||||
|
||||
import '../matrix.dart';
|
||||
import '../src/utils/run_in_root.dart';
|
||||
import 'cross_signing.dart';
|
||||
import 'key_manager.dart';
|
||||
import 'key_verification_manager.dart';
|
||||
import 'olm_manager.dart';
|
||||
import 'ssss.dart';
|
||||
import 'utils/bootstrap.dart';
|
||||
|
||||
class Encryption {
|
||||
final Client client;
|
||||
final bool debug;
|
||||
|
||||
bool get enabled => olmManager.enabled;
|
||||
|
||||
/// Returns the base64 encoded keys to store them in a store.
|
||||
/// This String should **never** leave the device!
|
||||
String? get pickledOlmAccount => olmManager.pickledOlmAccount;
|
||||
|
||||
String? get fingerprintKey => olmManager.fingerprintKey;
|
||||
String? get identityKey => olmManager.identityKey;
|
||||
|
||||
late KeyManager keyManager;
|
||||
late OlmManager olmManager;
|
||||
late KeyVerificationManager keyVerificationManager;
|
||||
late CrossSigning crossSigning;
|
||||
late SSSS ssss;
|
||||
|
||||
Encryption({
|
||||
required this.client,
|
||||
this.debug = false,
|
||||
}) {
|
||||
ssss = SSSS(this);
|
||||
keyManager = KeyManager(this);
|
||||
olmManager = OlmManager(this);
|
||||
keyVerificationManager = KeyVerificationManager(this);
|
||||
crossSigning = CrossSigning(this);
|
||||
}
|
||||
|
||||
// initial login passes null to init a new olm account
|
||||
Future<void> init(String? olmAccount) async {
|
||||
await olmManager.init(olmAccount);
|
||||
_backgroundTasksRunning = true;
|
||||
_backgroundTasks(); // start the background tasks
|
||||
}
|
||||
|
||||
bool isMinOlmVersion(int major, int minor, int patch) {
|
||||
try {
|
||||
final version = olm.get_library_version();
|
||||
return version[0] > major ||
|
||||
(version[0] == major &&
|
||||
(version[1] > minor ||
|
||||
(version[1] == minor && version[2] >= patch)));
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
Bootstrap bootstrap({void Function()? onUpdate}) => Bootstrap(
|
||||
encryption: this,
|
||||
onUpdate: onUpdate,
|
||||
);
|
||||
|
||||
void handleDeviceOneTimeKeysCount(
|
||||
Map<String, int>? countJson, List<String>? unusedFallbackKeyTypes) {
|
||||
runInRoot(() => olmManager.handleDeviceOneTimeKeysCount(
|
||||
countJson, unusedFallbackKeyTypes));
|
||||
}
|
||||
|
||||
void onSync() {
|
||||
keyVerificationManager.cleanup();
|
||||
}
|
||||
|
||||
Future<void> handleToDeviceEvent(ToDeviceEvent event) async {
|
||||
if (event.type == EventTypes.RoomKey) {
|
||||
// a new room key. We need to handle this asap, before other
|
||||
// events in /sync are handled
|
||||
await keyManager.handleToDeviceEvent(event);
|
||||
}
|
||||
if ([EventTypes.RoomKeyRequest, EventTypes.ForwardedRoomKey]
|
||||
.contains(event.type)) {
|
||||
// "just" room key request things. We don't need these asap, so we handle
|
||||
// them in the background
|
||||
// ignore: unawaited_futures
|
||||
runInRoot(() => keyManager.handleToDeviceEvent(event));
|
||||
}
|
||||
if (event.type == EventTypes.Dummy) {
|
||||
// the previous device just had to create a new olm session, due to olm session
|
||||
// corruption. We want to try to send it the last message we just sent it, if possible
|
||||
// ignore: unawaited_futures
|
||||
runInRoot(() => olmManager.handleToDeviceEvent(event));
|
||||
}
|
||||
if (event.type.startsWith('m.key.verification.')) {
|
||||
// some key verification event. No need to handle it now, we can easily
|
||||
// do this in the background
|
||||
|
||||
// ignore: unawaited_futures
|
||||
runInRoot(() => keyVerificationManager.handleToDeviceEvent(event));
|
||||
}
|
||||
if (event.type.startsWith('m.secret.')) {
|
||||
// some ssss thing. We can do this in the background
|
||||
// ignore: unawaited_futures
|
||||
runInRoot(() => ssss.handleToDeviceEvent(event));
|
||||
}
|
||||
if (event.sender == client.userID) {
|
||||
// maybe we need to re-try SSSS secrets
|
||||
// ignore: unawaited_futures
|
||||
runInRoot(() => ssss.periodicallyRequestMissingCache());
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> handleEventUpdate(EventUpdate update) async {
|
||||
if (update.type == EventUpdateType.ephemeral ||
|
||||
update.type == EventUpdateType.history) {
|
||||
return;
|
||||
}
|
||||
if (update.content['type'].startsWith('m.key.verification.') ||
|
||||
(update.content['type'] == EventTypes.Message &&
|
||||
(update.content['content']['msgtype'] is String) &&
|
||||
update.content['content']['msgtype']
|
||||
.startsWith('m.key.verification.'))) {
|
||||
// "just" key verification, no need to do this in sync
|
||||
|
||||
// ignore: unawaited_futures
|
||||
runInRoot(() => keyVerificationManager.handleEventUpdate(update));
|
||||
}
|
||||
if (update.content['sender'] == client.userID &&
|
||||
update.content['unsigned']?['transaction_id'] == null) {
|
||||
// maybe we need to re-try SSSS secrets
|
||||
// ignore: unawaited_futures
|
||||
runInRoot(() => ssss.periodicallyRequestMissingCache());
|
||||
}
|
||||
}
|
||||
|
||||
Future<ToDeviceEvent> decryptToDeviceEvent(ToDeviceEvent event) async {
|
||||
try {
|
||||
return await olmManager.decryptToDeviceEvent(event);
|
||||
} catch (e, s) {
|
||||
Logs().w(
|
||||
'[LibOlm] Could not decrypt to device event from ${event.sender} with content: ${event.content}',
|
||||
e,
|
||||
s);
|
||||
client.onEncryptionError.add(
|
||||
SdkError(
|
||||
exception: e is Exception ? e : Exception(e),
|
||||
stackTrace: s,
|
||||
),
|
||||
);
|
||||
return event;
|
||||
}
|
||||
}
|
||||
|
||||
Event decryptRoomEventSync(String roomId, Event event) {
|
||||
final content = event.parsedRoomEncryptedContent;
|
||||
if (event.type != EventTypes.Encrypted ||
|
||||
content.ciphertextMegolm == null) {
|
||||
return event;
|
||||
}
|
||||
Map<String, dynamic> decryptedPayload;
|
||||
var canRequestSession = false;
|
||||
try {
|
||||
if (content.algorithm != AlgorithmTypes.megolmV1AesSha2) {
|
||||
throw DecryptException(DecryptException.unknownAlgorithm);
|
||||
}
|
||||
final sessionId = content.sessionId;
|
||||
final senderKey = content.senderKey;
|
||||
if (sessionId == null) {
|
||||
throw DecryptException(DecryptException.unknownSession);
|
||||
}
|
||||
|
||||
final inboundGroupSession =
|
||||
keyManager.getInboundGroupSession(roomId, sessionId, senderKey);
|
||||
if (!(inboundGroupSession?.isValid ?? false)) {
|
||||
canRequestSession = true;
|
||||
throw DecryptException(DecryptException.unknownSession);
|
||||
}
|
||||
|
||||
// decrypt errors here may mean we have a bad session key - others might have a better one
|
||||
canRequestSession = true;
|
||||
|
||||
final decryptResult = inboundGroupSession!.inboundGroupSession!
|
||||
.decrypt(content.ciphertextMegolm!);
|
||||
canRequestSession = false;
|
||||
|
||||
// we can't have the key be an int, else json-serializing will fail, thus we need it to be a string
|
||||
final messageIndexKey = 'key-' + decryptResult.message_index.toString();
|
||||
final messageIndexValue = event.eventId +
|
||||
'|' +
|
||||
event.originServerTs.millisecondsSinceEpoch.toString();
|
||||
final haveIndex =
|
||||
inboundGroupSession.indexes.containsKey(messageIndexKey);
|
||||
if (haveIndex &&
|
||||
inboundGroupSession.indexes[messageIndexKey] != messageIndexValue) {
|
||||
Logs().e('[Decrypt] Could not decrypt due to a corrupted session.');
|
||||
throw DecryptException(DecryptException.channelCorrupted);
|
||||
}
|
||||
|
||||
inboundGroupSession.indexes[messageIndexKey] = messageIndexValue;
|
||||
if (!haveIndex) {
|
||||
// now we persist the udpated indexes into the database.
|
||||
// the entry should always exist. In the case it doesn't, the following
|
||||
// line *could* throw an error. As that is a future, though, and we call
|
||||
// it un-awaited here, nothing happens, which is exactly the result we want
|
||||
client.database?.updateInboundGroupSessionIndexes(
|
||||
json.encode(inboundGroupSession.indexes), roomId, sessionId);
|
||||
}
|
||||
decryptedPayload = json.decode(decryptResult.plaintext);
|
||||
} catch (exception) {
|
||||
// alright, if this was actually by our own outbound group session, we might as well clear it
|
||||
if (exception.toString() != DecryptException.unknownSession &&
|
||||
(keyManager
|
||||
.getOutboundGroupSession(roomId)
|
||||
?.outboundGroupSession
|
||||
?.session_id() ??
|
||||
'') ==
|
||||
content.sessionId) {
|
||||
runInRoot(() =>
|
||||
keyManager.clearOrUseOutboundGroupSession(roomId, wipe: true));
|
||||
}
|
||||
if (canRequestSession) {
|
||||
decryptedPayload = {
|
||||
'content': event.content,
|
||||
'type': EventTypes.Encrypted,
|
||||
};
|
||||
decryptedPayload['content']['body'] = exception.toString();
|
||||
decryptedPayload['content']['msgtype'] = MessageTypes.BadEncrypted;
|
||||
decryptedPayload['content']['can_request_session'] = true;
|
||||
} else {
|
||||
decryptedPayload = {
|
||||
'content': <String, dynamic>{
|
||||
'msgtype': MessageTypes.BadEncrypted,
|
||||
'body': exception.toString(),
|
||||
},
|
||||
'type': EventTypes.Encrypted,
|
||||
};
|
||||
}
|
||||
}
|
||||
if (event.content['m.relates_to'] != null) {
|
||||
decryptedPayload['content']['m.relates_to'] =
|
||||
event.content['m.relates_to'];
|
||||
}
|
||||
return Event(
|
||||
content: decryptedPayload['content'],
|
||||
type: decryptedPayload['type'],
|
||||
senderId: event.senderId,
|
||||
eventId: event.eventId,
|
||||
room: event.room,
|
||||
originServerTs: event.originServerTs,
|
||||
unsigned: event.unsigned,
|
||||
stateKey: event.stateKey,
|
||||
prevContent: event.prevContent,
|
||||
status: event.status,
|
||||
);
|
||||
}
|
||||
|
||||
Future<Event> decryptRoomEvent(String roomId, Event event,
|
||||
{bool store = false,
|
||||
EventUpdateType updateType = EventUpdateType.timeline}) async {
|
||||
if (event.type != EventTypes.Encrypted) {
|
||||
return event;
|
||||
}
|
||||
final content = event.parsedRoomEncryptedContent;
|
||||
final sessionId = content.sessionId;
|
||||
try {
|
||||
if (client.database != null &&
|
||||
sessionId != null &&
|
||||
!(keyManager
|
||||
.getInboundGroupSession(
|
||||
roomId,
|
||||
sessionId,
|
||||
content.senderKey,
|
||||
)
|
||||
?.isValid ??
|
||||
false)) {
|
||||
await keyManager.loadInboundGroupSession(
|
||||
roomId,
|
||||
sessionId,
|
||||
content.senderKey,
|
||||
);
|
||||
}
|
||||
event = decryptRoomEventSync(roomId, event);
|
||||
if (event.type == EventTypes.Encrypted &&
|
||||
event.content['can_request_session'] == true &&
|
||||
sessionId != null) {
|
||||
keyManager.maybeAutoRequest(
|
||||
roomId,
|
||||
sessionId,
|
||||
content.senderKey,
|
||||
);
|
||||
}
|
||||
if (event.type != EventTypes.Encrypted && store) {
|
||||
if (updateType != EventUpdateType.history) {
|
||||
event.room.setState(event);
|
||||
}
|
||||
await client.database?.storeEventUpdate(
|
||||
EventUpdate(
|
||||
content: event.toJson(),
|
||||
roomID: roomId,
|
||||
type: updateType,
|
||||
),
|
||||
client,
|
||||
);
|
||||
}
|
||||
return event;
|
||||
} catch (e, s) {
|
||||
Logs().e('[Decrypt] Could not decrpyt event', e, s);
|
||||
return event;
|
||||
}
|
||||
}
|
||||
|
||||
/// Encrypts the given json payload and creates a send-ready m.room.encrypted
|
||||
/// payload. This will create a new outgoingGroupSession if necessary.
|
||||
Future<Map<String, dynamic>> encryptGroupMessagePayload(
|
||||
String roomId, Map<String, dynamic> payload,
|
||||
{String type = EventTypes.Message}) async {
|
||||
final Map<String, dynamic>? mRelatesTo = payload.remove('m.relates_to');
|
||||
// Events which only contain a m.relates_to like reactions don't need to
|
||||
// be encrypted.
|
||||
if (payload.isEmpty && mRelatesTo != null) {
|
||||
return {'m.relates_to': mRelatesTo};
|
||||
}
|
||||
final room = client.getRoomById(roomId);
|
||||
if (room == null || !room.encrypted || !enabled) {
|
||||
return payload;
|
||||
}
|
||||
if (room.encryptionAlgorithm != AlgorithmTypes.megolmV1AesSha2) {
|
||||
throw ('Unknown encryption algorithm');
|
||||
}
|
||||
if (keyManager.getOutboundGroupSession(roomId)?.isValid != true) {
|
||||
await keyManager.loadOutboundGroupSession(roomId);
|
||||
}
|
||||
await keyManager.clearOrUseOutboundGroupSession(roomId);
|
||||
if (keyManager.getOutboundGroupSession(roomId)?.isValid != true) {
|
||||
await keyManager.createOutboundGroupSession(roomId);
|
||||
}
|
||||
final sess = keyManager.getOutboundGroupSession(roomId);
|
||||
if (sess?.isValid != true) {
|
||||
throw ('Unable to create new outbound group session');
|
||||
}
|
||||
// we clone the payload as we do not want to remove 'm.relates_to' from the
|
||||
// original payload passed into this function
|
||||
payload = payload.copy();
|
||||
final payloadContent = {
|
||||
'content': payload,
|
||||
'type': type,
|
||||
'room_id': roomId,
|
||||
};
|
||||
final encryptedPayload = <String, dynamic>{
|
||||
'algorithm': AlgorithmTypes.megolmV1AesSha2,
|
||||
'ciphertext':
|
||||
sess!.outboundGroupSession!.encrypt(json.encode(payloadContent)),
|
||||
'device_id': client.deviceID,
|
||||
'sender_key': identityKey,
|
||||
'session_id': sess.outboundGroupSession!.session_id(),
|
||||
if (mRelatesTo != null) 'm.relates_to': mRelatesTo,
|
||||
};
|
||||
await keyManager.storeOutboundGroupSession(roomId, sess);
|
||||
return encryptedPayload;
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> encryptToDeviceMessage(
|
||||
List<DeviceKeys> deviceKeys,
|
||||
String type,
|
||||
Map<String, dynamic> payload) async {
|
||||
return await olmManager.encryptToDeviceMessage(deviceKeys, type, payload);
|
||||
}
|
||||
|
||||
Future<void> autovalidateMasterOwnKey() async {
|
||||
// check if we can set our own master key as verified, if it isn't yet
|
||||
final userId = client.userID;
|
||||
final masterKey = client.userDeviceKeys[userId]?.masterKey;
|
||||
if (client.database != null &&
|
||||
masterKey != null &&
|
||||
userId != null &&
|
||||
!masterKey.directVerified &&
|
||||
masterKey.hasValidSignatureChain(onlyValidateUserIds: {userId})) {
|
||||
await masterKey.setVerified(true);
|
||||
}
|
||||
}
|
||||
|
||||
// this method is responsible for all background tasks, such as uploading online key backups
|
||||
bool _backgroundTasksRunning = true;
|
||||
void _backgroundTasks() {
|
||||
if (!_backgroundTasksRunning || !client.isLogged()) {
|
||||
return;
|
||||
}
|
||||
|
||||
keyManager.backgroundTasks();
|
||||
|
||||
// autovalidateMasterOwnKey();
|
||||
|
||||
if (_backgroundTasksRunning) {
|
||||
Timer(Duration(seconds: 10), _backgroundTasks);
|
||||
}
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
_backgroundTasksRunning = false;
|
||||
keyManager.dispose();
|
||||
olmManager.dispose();
|
||||
keyVerificationManager.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
class DecryptException implements Exception {
|
||||
String cause;
|
||||
String? libolmMessage;
|
||||
DecryptException(this.cause, [this.libolmMessage]);
|
||||
|
||||
@override
|
||||
String toString() =>
|
||||
cause + (libolmMessage != null ? ': $libolmMessage' : '');
|
||||
|
||||
static const String notEnabled = 'Encryption is not enabled in your client.';
|
||||
static const String unknownAlgorithm = 'Unknown encryption algorithm.';
|
||||
static const String unknownSession =
|
||||
'The sender has not sent us the session key.';
|
||||
static const String channelCorrupted =
|
||||
'The secure channel with the sender was corrupted.';
|
||||
static const String unableToDecryptWithAnyOlmSession =
|
||||
'Unable to decrypt with any existing OLM session';
|
||||
static const String senderDoesntMatch =
|
||||
"Message was decrypted but sender doesn't match";
|
||||
static const String recipientDoesntMatch =
|
||||
"Message was decrypted but recipient doesn't match";
|
||||
static const String ownFingerprintDoesntMatch =
|
||||
"Message was decrypted but own fingerprint Key doesn't match";
|
||||
static const String isntSentForThisDevice =
|
||||
"The message isn't sent for this device";
|
||||
static const String unknownMessageType = 'Unknown message type';
|
||||
static const String decryptionFailed = 'Decryption failed';
|
||||
}
|
||||
1093
lib/encryption/key_manager.dart
Normal file
1093
lib/encryption/key_manager.dart
Normal file
File diff suppressed because it is too large
Load diff
148
lib/encryption/key_verification_manager.dart
Normal file
148
lib/encryption/key_verification_manager.dart
Normal file
|
|
@ -0,0 +1,148 @@
|
|||
/*
|
||||
* Famedly Matrix SDK
|
||||
* Copyright (C) 2020, 2021 Famedly GmbH
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as
|
||||
* published by the Free Software Foundation, either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import '../matrix.dart';
|
||||
import 'encryption.dart';
|
||||
import 'utils/key_verification.dart';
|
||||
|
||||
class KeyVerificationManager {
|
||||
final Encryption encryption;
|
||||
Client get client => encryption.client;
|
||||
|
||||
KeyVerificationManager(this.encryption);
|
||||
|
||||
final Map<String, KeyVerification> _requests = {};
|
||||
|
||||
Future<void> cleanup() async {
|
||||
final Set entriesToDispose = <String>{};
|
||||
for (final entry in _requests.entries) {
|
||||
var dispose = entry.value.canceled ||
|
||||
entry.value.state == KeyVerificationState.done ||
|
||||
entry.value.state == KeyVerificationState.error;
|
||||
if (!dispose) {
|
||||
dispose = !(await entry.value.verifyActivity());
|
||||
}
|
||||
if (dispose) {
|
||||
entry.value.dispose();
|
||||
entriesToDispose.add(entry.key);
|
||||
}
|
||||
}
|
||||
entriesToDispose.forEach(_requests.remove);
|
||||
}
|
||||
|
||||
void addRequest(KeyVerification request) {
|
||||
if (request.transactionId == null) {
|
||||
return;
|
||||
}
|
||||
_requests[request.transactionId!] = request;
|
||||
}
|
||||
|
||||
KeyVerification? getRequest(String requestId) => _requests[requestId];
|
||||
|
||||
Future<void> handleToDeviceEvent(ToDeviceEvent event) async {
|
||||
if (!event.type.startsWith('m.key.verification.') ||
|
||||
client.verificationMethods.isEmpty) {
|
||||
return;
|
||||
}
|
||||
// we have key verification going on!
|
||||
final transactionId = KeyVerification.getTransactionId(event.content);
|
||||
if (transactionId == null) {
|
||||
return; // TODO: send cancel with unknown transaction id
|
||||
}
|
||||
final request = _requests[transactionId];
|
||||
if (request != null) {
|
||||
// make sure that new requests can't come from ourself
|
||||
if (!{EventTypes.KeyVerificationRequest}.contains(event.type)) {
|
||||
await request.handlePayload(event.type, event.content);
|
||||
}
|
||||
} else {
|
||||
if (!{EventTypes.KeyVerificationRequest, EventTypes.KeyVerificationStart}
|
||||
.contains(event.type)) {
|
||||
return; // we can only start on these
|
||||
}
|
||||
final newKeyRequest =
|
||||
KeyVerification(encryption: encryption, userId: event.sender);
|
||||
await newKeyRequest.handlePayload(event.type, event.content);
|
||||
if (newKeyRequest.state != KeyVerificationState.askAccept) {
|
||||
// okay, something went wrong (unknown transaction id?), just dispose it
|
||||
newKeyRequest.dispose();
|
||||
} else {
|
||||
_requests[transactionId] = newKeyRequest;
|
||||
client.onKeyVerificationRequest.add(newKeyRequest);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> handleEventUpdate(EventUpdate update) async {
|
||||
final event = update.content;
|
||||
final type = event['type'].startsWith('m.key.verification.')
|
||||
? event['type']
|
||||
: event['content']['msgtype'];
|
||||
if (type == null ||
|
||||
!type.startsWith('m.key.verification.') ||
|
||||
client.verificationMethods.isEmpty) {
|
||||
return;
|
||||
}
|
||||
if (type == EventTypes.KeyVerificationRequest) {
|
||||
event['content']['timestamp'] = event['origin_server_ts'];
|
||||
}
|
||||
|
||||
final transactionId =
|
||||
KeyVerification.getTransactionId(event['content']) ?? event['event_id'];
|
||||
|
||||
final req = _requests[transactionId];
|
||||
if (req != null) {
|
||||
final otherDeviceId = event['content']['from_device'];
|
||||
if (event['sender'] != client.userID) {
|
||||
await req.handlePayload(type, event['content'], event['event_id']);
|
||||
} else if (event['sender'] == client.userID &&
|
||||
otherDeviceId != null &&
|
||||
otherDeviceId != client.deviceID) {
|
||||
// okay, another of our devices answered
|
||||
req.otherDeviceAccepted();
|
||||
req.dispose();
|
||||
_requests.remove(transactionId);
|
||||
}
|
||||
} else if (event['sender'] != client.userID) {
|
||||
if (!{EventTypes.KeyVerificationRequest, EventTypes.KeyVerificationStart}
|
||||
.contains(type)) {
|
||||
return; // we can only start on these
|
||||
}
|
||||
final room = client.getRoomById(update.roomID) ??
|
||||
Room(id: update.roomID, client: client);
|
||||
final newKeyRequest = KeyVerification(
|
||||
encryption: encryption, userId: event['sender'], room: room);
|
||||
await newKeyRequest.handlePayload(
|
||||
type, event['content'], event['event_id']);
|
||||
if (newKeyRequest.state != KeyVerificationState.askAccept) {
|
||||
// something went wrong, let's just dispose the request
|
||||
newKeyRequest.dispose();
|
||||
} else {
|
||||
// new request! Let's notify it and stuff
|
||||
_requests[transactionId] = newKeyRequest;
|
||||
client.onKeyVerificationRequest.add(newKeyRequest);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
for (final req in _requests.values) {
|
||||
req.dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
690
lib/encryption/olm_manager.dart
Normal file
690
lib/encryption/olm_manager.dart
Normal file
|
|
@ -0,0 +1,690 @@
|
|||
/*
|
||||
* Famedly Matrix SDK
|
||||
* Copyright (C) 2019, 2020, 2021 Famedly GmbH
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as
|
||||
* published by the Free Software Foundation, either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:canonical_json/canonical_json.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:matrix/matrix.dart';
|
||||
import 'package:olm/olm.dart' as olm;
|
||||
|
||||
import '../encryption/utils/json_signature_check_extension.dart';
|
||||
import '../src/utils/run_in_root.dart';
|
||||
import 'encryption.dart';
|
||||
import 'utils/olm_session.dart';
|
||||
|
||||
class OlmManager {
|
||||
final Encryption encryption;
|
||||
Client get client => encryption.client;
|
||||
olm.Account? _olmAccount;
|
||||
|
||||
/// Returns the base64 encoded keys to store them in a store.
|
||||
/// This String should **never** leave the device!
|
||||
String? get pickledOlmAccount =>
|
||||
enabled ? _olmAccount!.pickle(client.userID!) : null;
|
||||
String? get fingerprintKey =>
|
||||
enabled ? json.decode(_olmAccount!.identity_keys())['ed25519'] : null;
|
||||
String? get identityKey =>
|
||||
enabled ? json.decode(_olmAccount!.identity_keys())['curve25519'] : null;
|
||||
|
||||
bool get enabled => _olmAccount != null;
|
||||
|
||||
OlmManager(this.encryption);
|
||||
|
||||
/// A map from Curve25519 identity keys to existing olm sessions.
|
||||
Map<String, List<OlmSession>> get olmSessions => _olmSessions;
|
||||
final Map<String, List<OlmSession>> _olmSessions = {};
|
||||
|
||||
// NOTE(Nico): On initial login we pass null to create a new account
|
||||
Future<void> init(String? olmAccount) async {
|
||||
if (olmAccount == null) {
|
||||
try {
|
||||
await olm.init();
|
||||
_olmAccount = olm.Account();
|
||||
_olmAccount!.create();
|
||||
if (!await uploadKeys(uploadDeviceKeys: true, updateDatabase: false)) {
|
||||
throw ('Upload key failed');
|
||||
}
|
||||
} catch (_) {
|
||||
_olmAccount?.free();
|
||||
_olmAccount = null;
|
||||
rethrow;
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
await olm.init();
|
||||
_olmAccount = olm.Account();
|
||||
_olmAccount!.unpickle(client.userID!, olmAccount);
|
||||
} catch (_) {
|
||||
_olmAccount?.free();
|
||||
_olmAccount = null;
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Adds a signature to this json from this olm account and returns the signed
|
||||
/// json.
|
||||
Map<String, dynamic> signJson(Map<String, dynamic> payload) {
|
||||
if (!enabled) throw ('Encryption is disabled');
|
||||
final Map<String, dynamic>? unsigned = payload['unsigned'];
|
||||
final Map<String, dynamic>? signatures = payload['signatures'];
|
||||
payload.remove('unsigned');
|
||||
payload.remove('signatures');
|
||||
final canonical = canonicalJson.encode(payload);
|
||||
final signature = _olmAccount!.sign(String.fromCharCodes(canonical));
|
||||
if (signatures != null) {
|
||||
payload['signatures'] = signatures;
|
||||
} else {
|
||||
payload['signatures'] = <String, dynamic>{};
|
||||
}
|
||||
if (!payload['signatures'].containsKey(client.userID)) {
|
||||
payload['signatures'][client.userID] = <String, dynamic>{};
|
||||
}
|
||||
payload['signatures'][client.userID]['ed25519:${client.deviceID}'] =
|
||||
signature;
|
||||
if (unsigned != null) {
|
||||
payload['unsigned'] = unsigned;
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
String signString(String s) {
|
||||
return _olmAccount!.sign(s);
|
||||
}
|
||||
|
||||
/// Checks the signature of a signed json object.
|
||||
@deprecated
|
||||
bool checkJsonSignature(String key, Map<String, dynamic> signedJson,
|
||||
String userId, String deviceId) {
|
||||
if (!enabled) throw ('Encryption is disabled');
|
||||
final Map<String, dynamic>? signatures = signedJson['signatures'];
|
||||
if (signatures == null || !signatures.containsKey(userId)) return false;
|
||||
signedJson.remove('unsigned');
|
||||
signedJson.remove('signatures');
|
||||
if (!signatures[userId].containsKey('ed25519:$deviceId')) return false;
|
||||
final String signature = signatures[userId]['ed25519:$deviceId'];
|
||||
final canonical = canonicalJson.encode(signedJson);
|
||||
final message = String.fromCharCodes(canonical);
|
||||
var isValid = false;
|
||||
final olmutil = olm.Utility();
|
||||
try {
|
||||
olmutil.ed25519_verify(key, message, signature);
|
||||
isValid = true;
|
||||
} catch (e, s) {
|
||||
isValid = false;
|
||||
Logs().w('[LibOlm] Signature check failed', e, s);
|
||||
} finally {
|
||||
olmutil.free();
|
||||
}
|
||||
return isValid;
|
||||
}
|
||||
|
||||
bool _uploadKeysLock = false;
|
||||
|
||||
/// Generates new one time keys, signs everything and upload it to the server.
|
||||
Future<bool> uploadKeys({
|
||||
bool uploadDeviceKeys = false,
|
||||
int? oldKeyCount = 0,
|
||||
bool updateDatabase = true,
|
||||
bool? unusedFallbackKey = false,
|
||||
}) async {
|
||||
final _olmAccount = this._olmAccount;
|
||||
if (_olmAccount == null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (_uploadKeysLock) {
|
||||
return false;
|
||||
}
|
||||
_uploadKeysLock = true;
|
||||
|
||||
try {
|
||||
final signedOneTimeKeys = <String, dynamic>{};
|
||||
int? uploadedOneTimeKeysCount;
|
||||
if (oldKeyCount != null) {
|
||||
// check if we have OTKs that still need uploading. If we do, we don't try to generate new ones,
|
||||
// instead we try to upload the old ones first
|
||||
final oldOTKsNeedingUpload = json
|
||||
.decode(_olmAccount.one_time_keys())['curve25519']
|
||||
.entries
|
||||
.length as int;
|
||||
// generate one-time keys
|
||||
// we generate 2/3rds of max, so that other keys people may still have can
|
||||
// still be used
|
||||
final oneTimeKeysCount =
|
||||
(_olmAccount.max_number_of_one_time_keys() * 2 / 3).floor() -
|
||||
oldKeyCount -
|
||||
oldOTKsNeedingUpload;
|
||||
if (oneTimeKeysCount > 0) {
|
||||
_olmAccount.generate_one_time_keys(oneTimeKeysCount);
|
||||
}
|
||||
uploadedOneTimeKeysCount = oneTimeKeysCount + oldOTKsNeedingUpload;
|
||||
final Map<String, dynamic> oneTimeKeys =
|
||||
json.decode(_olmAccount.one_time_keys());
|
||||
|
||||
// now sign all the one-time keys
|
||||
for (final entry in oneTimeKeys['curve25519'].entries) {
|
||||
final key = entry.key;
|
||||
final value = entry.value;
|
||||
signedOneTimeKeys['signed_curve25519:$key'] = signJson({
|
||||
'key': value,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
final signedFallbackKeys = <String, dynamic>{};
|
||||
if (encryption.isMinOlmVersion(3, 2, 0) && unusedFallbackKey == false) {
|
||||
// we don't have an unused fallback key uploaded....so let's change that!
|
||||
_olmAccount.generate_fallback_key();
|
||||
final fallbackKey = json.decode(_olmAccount.fallback_key());
|
||||
// now sign all the fallback keys
|
||||
for (final entry in fallbackKey['curve25519'].entries) {
|
||||
final key = entry.key;
|
||||
final value = entry.value;
|
||||
signedFallbackKeys['signed_curve25519:$key'] = signJson({
|
||||
'key': value,
|
||||
'fallback': true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// and now generate the payload to upload
|
||||
final keysContent = <String, dynamic>{
|
||||
if (uploadDeviceKeys)
|
||||
'device_keys': {
|
||||
'user_id': client.userID,
|
||||
'device_id': client.deviceID,
|
||||
'algorithms': [
|
||||
AlgorithmTypes.olmV1Curve25519AesSha2,
|
||||
AlgorithmTypes.megolmV1AesSha2
|
||||
],
|
||||
'keys': <String, dynamic>{},
|
||||
},
|
||||
};
|
||||
if (uploadDeviceKeys) {
|
||||
final Map<String, dynamic> keys =
|
||||
json.decode(_olmAccount.identity_keys());
|
||||
for (final entry in keys.entries) {
|
||||
final algorithm = entry.key;
|
||||
final value = entry.value;
|
||||
keysContent['device_keys']['keys']['$algorithm:${client.deviceID}'] =
|
||||
value;
|
||||
}
|
||||
keysContent['device_keys'] =
|
||||
signJson(keysContent['device_keys'] as Map<String, dynamic>);
|
||||
}
|
||||
|
||||
// we save the generated OTKs into the database.
|
||||
// in case the app gets killed during upload or the upload fails due to bad network
|
||||
// we can still re-try later
|
||||
if (updateDatabase) {
|
||||
await client.database?.updateClientKeys(pickledOlmAccount!);
|
||||
}
|
||||
// Workaround: Make sure we stop if we got logged out in the meantime.
|
||||
if (!client.isLogged()) return true;
|
||||
final response = await client.uploadKeys(
|
||||
deviceKeys: uploadDeviceKeys
|
||||
? MatrixDeviceKeys.fromJson(keysContent['device_keys'])
|
||||
: null,
|
||||
oneTimeKeys: signedOneTimeKeys,
|
||||
fallbackKeys: signedFallbackKeys,
|
||||
);
|
||||
// mark the OTKs as published and save that to datbase
|
||||
_olmAccount.mark_keys_as_published();
|
||||
if (updateDatabase) {
|
||||
await client.database?.updateClientKeys(pickledOlmAccount!);
|
||||
}
|
||||
return (uploadedOneTimeKeysCount != null &&
|
||||
response['signed_curve25519'] == uploadedOneTimeKeysCount) ||
|
||||
uploadedOneTimeKeysCount == null;
|
||||
} finally {
|
||||
_uploadKeysLock = false;
|
||||
}
|
||||
}
|
||||
|
||||
void handleDeviceOneTimeKeysCount(
|
||||
Map<String, int>? countJson, List<String>? unusedFallbackKeyTypes) {
|
||||
if (!enabled) {
|
||||
return;
|
||||
}
|
||||
final haveFallbackKeys = encryption.isMinOlmVersion(3, 2, 0);
|
||||
// Check if there are at least half of max_number_of_one_time_keys left on the server
|
||||
// and generate and upload more if not.
|
||||
|
||||
// If the server did not send us a count, assume it is 0
|
||||
final keyCount = countJson?.tryGet<int>('signed_curve25519') ?? 0;
|
||||
|
||||
// If the server does not support fallback keys, it will not tell us about them.
|
||||
// If the server supports them but has no key, upload a new one.
|
||||
var unusedFallbackKey = true;
|
||||
if (unusedFallbackKeyTypes?.contains('signed_curve25519') == false) {
|
||||
unusedFallbackKey = false;
|
||||
}
|
||||
|
||||
// fixup accidental too many uploads. We delete only one of them so that the server has time to update the counts and because we will get rate limited anyway.
|
||||
if (keyCount > _olmAccount!.max_number_of_one_time_keys()) {
|
||||
final requestingKeysFrom = {
|
||||
client.userID!: {client.deviceID!: 'signed_curve25519'}
|
||||
};
|
||||
client.claimKeys(requestingKeysFrom, timeout: 10000);
|
||||
}
|
||||
|
||||
// Only upload keys if they are less than half of the max or we have no unused fallback key
|
||||
if (keyCount < (_olmAccount!.max_number_of_one_time_keys() / 2) ||
|
||||
!unusedFallbackKey) {
|
||||
uploadKeys(
|
||||
oldKeyCount: keyCount < (_olmAccount!.max_number_of_one_time_keys() / 2)
|
||||
? keyCount
|
||||
: null,
|
||||
unusedFallbackKey: haveFallbackKeys ? unusedFallbackKey : null,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> storeOlmSession(OlmSession session) async {
|
||||
if (session.sessionId == null || session.pickledSession == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
_olmSessions[session.identityKey] ??= <OlmSession>[];
|
||||
final ix = _olmSessions[session.identityKey]!
|
||||
.indexWhere((s) => s.sessionId == session.sessionId);
|
||||
if (ix == -1) {
|
||||
// add a new session
|
||||
_olmSessions[session.identityKey]!.add(session);
|
||||
} else {
|
||||
// update an existing session
|
||||
_olmSessions[session.identityKey]![ix] = session;
|
||||
}
|
||||
await client.database?.storeOlmSession(
|
||||
session.identityKey,
|
||||
session.sessionId!,
|
||||
session.pickledSession!,
|
||||
session.lastReceived?.millisecondsSinceEpoch ??
|
||||
DateTime.now().millisecondsSinceEpoch);
|
||||
}
|
||||
|
||||
ToDeviceEvent _decryptToDeviceEvent(ToDeviceEvent event) {
|
||||
if (event.type != EventTypes.Encrypted) {
|
||||
return event;
|
||||
}
|
||||
final content = event.parsedRoomEncryptedContent;
|
||||
if (content.algorithm != AlgorithmTypes.olmV1Curve25519AesSha2) {
|
||||
throw DecryptException(DecryptException.unknownAlgorithm);
|
||||
}
|
||||
if (content.ciphertextOlm == null ||
|
||||
!content.ciphertextOlm!.containsKey(identityKey)) {
|
||||
throw DecryptException(DecryptException.isntSentForThisDevice);
|
||||
}
|
||||
String? plaintext;
|
||||
final senderKey = content.senderKey;
|
||||
final body = content.ciphertextOlm![identityKey]!.body;
|
||||
final type = content.ciphertextOlm![identityKey]!.type;
|
||||
if (type != 0 && type != 1) {
|
||||
throw DecryptException(DecryptException.unknownMessageType);
|
||||
}
|
||||
final device = client.userDeviceKeys[event.sender]?.deviceKeys.values
|
||||
.firstWhereOrNull((d) => d.curve25519Key == senderKey);
|
||||
final existingSessions = olmSessions[senderKey];
|
||||
final updateSessionUsage = ([OlmSession? session]) => runInRoot(() async {
|
||||
if (session != null) {
|
||||
session.lastReceived = DateTime.now();
|
||||
await storeOlmSession(session);
|
||||
}
|
||||
if (device != null) {
|
||||
device.lastActive = DateTime.now();
|
||||
await client.database?.setLastActiveUserDeviceKey(
|
||||
device.lastActive.millisecondsSinceEpoch,
|
||||
device.userId,
|
||||
device.deviceId!);
|
||||
}
|
||||
});
|
||||
if (existingSessions != null) {
|
||||
for (final session in existingSessions) {
|
||||
if (session.session == null) {
|
||||
continue;
|
||||
}
|
||||
if (type == 0 && session.session!.matches_inbound(body)) {
|
||||
try {
|
||||
plaintext = session.session!.decrypt(type, body);
|
||||
} catch (e) {
|
||||
// The message was encrypted during this session, but is unable to decrypt
|
||||
throw DecryptException(
|
||||
DecryptException.decryptionFailed, e.toString());
|
||||
}
|
||||
updateSessionUsage(session);
|
||||
break;
|
||||
} else if (type == 1) {
|
||||
try {
|
||||
plaintext = session.session!.decrypt(type, body);
|
||||
updateSessionUsage(session);
|
||||
break;
|
||||
} catch (_) {
|
||||
plaintext = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (plaintext == null && type != 0) {
|
||||
throw DecryptException(DecryptException.unableToDecryptWithAnyOlmSession);
|
||||
}
|
||||
|
||||
if (plaintext == null) {
|
||||
final newSession = olm.Session();
|
||||
try {
|
||||
newSession.create_inbound_from(_olmAccount!, senderKey, body);
|
||||
_olmAccount!.remove_one_time_keys(newSession);
|
||||
client.database?.updateClientKeys(pickledOlmAccount!);
|
||||
plaintext = newSession.decrypt(type, body);
|
||||
runInRoot(() => storeOlmSession(OlmSession(
|
||||
key: client.userID!,
|
||||
identityKey: senderKey,
|
||||
sessionId: newSession.session_id(),
|
||||
session: newSession,
|
||||
lastReceived: DateTime.now(),
|
||||
)));
|
||||
updateSessionUsage();
|
||||
} catch (e) {
|
||||
newSession.free();
|
||||
throw DecryptException(DecryptException.decryptionFailed, e.toString());
|
||||
}
|
||||
}
|
||||
final Map<String, dynamic> plainContent = json.decode(plaintext);
|
||||
if (plainContent['sender'] != event.sender) {
|
||||
throw DecryptException(DecryptException.senderDoesntMatch);
|
||||
}
|
||||
if (plainContent['recipient'] != client.userID) {
|
||||
throw DecryptException(DecryptException.recipientDoesntMatch);
|
||||
}
|
||||
if (plainContent['recipient_keys'] is Map &&
|
||||
plainContent['recipient_keys']['ed25519'] is String &&
|
||||
plainContent['recipient_keys']['ed25519'] != fingerprintKey) {
|
||||
throw DecryptException(DecryptException.ownFingerprintDoesntMatch);
|
||||
}
|
||||
return ToDeviceEvent(
|
||||
content: plainContent['content'],
|
||||
encryptedContent: event.content,
|
||||
type: plainContent['type'],
|
||||
sender: event.sender,
|
||||
);
|
||||
}
|
||||
|
||||
Future<List<OlmSession>> getOlmSessionsFromDatabase(String senderKey) async {
|
||||
final olmSessions =
|
||||
await client.database?.getOlmSessions(senderKey, client.userID!);
|
||||
return olmSessions?.where((sess) => sess.isValid).toList() ?? [];
|
||||
}
|
||||
|
||||
Future<void> getOlmSessionsForDevicesFromDatabase(
|
||||
List<String> senderKeys) async {
|
||||
final rows = await client.database?.getOlmSessionsForDevices(
|
||||
senderKeys,
|
||||
client.userID!,
|
||||
);
|
||||
final res = <String, List<OlmSession>>{};
|
||||
for (final sess in rows ?? []) {
|
||||
res[sess.identityKey] ??= <OlmSession>[];
|
||||
if (sess.isValid) {
|
||||
res[sess.identityKey]!.add(sess);
|
||||
}
|
||||
}
|
||||
for (final entry in res.entries) {
|
||||
_olmSessions[entry.key] = entry.value;
|
||||
}
|
||||
}
|
||||
|
||||
Future<List<OlmSession>> getOlmSessions(String senderKey,
|
||||
{bool getFromDb = true}) async {
|
||||
var sess = olmSessions[senderKey];
|
||||
if ((getFromDb) && (sess == null || sess.isEmpty)) {
|
||||
final sessions = await getOlmSessionsFromDatabase(senderKey);
|
||||
if (sessions.isEmpty) {
|
||||
return [];
|
||||
}
|
||||
sess = _olmSessions[senderKey] = sessions;
|
||||
}
|
||||
if (sess == null) {
|
||||
return [];
|
||||
}
|
||||
sess.sort((a, b) => a.lastReceived == b.lastReceived
|
||||
? (a.sessionId ?? '').compareTo(b.sessionId ?? '')
|
||||
: (b.lastReceived ?? DateTime(0))
|
||||
.compareTo(a.lastReceived ?? DateTime(0)));
|
||||
return sess;
|
||||
}
|
||||
|
||||
final Map<String, DateTime> _restoredOlmSessionsTime = {};
|
||||
|
||||
Future<void> restoreOlmSession(String userId, String senderKey) async {
|
||||
if (!client.userDeviceKeys.containsKey(userId)) {
|
||||
return;
|
||||
}
|
||||
final device = client.userDeviceKeys[userId]!.deviceKeys.values
|
||||
.firstWhereOrNull((d) => d.curve25519Key == senderKey);
|
||||
if (device == null) {
|
||||
return;
|
||||
}
|
||||
// per device only one olm session per hour should be restored
|
||||
final mapKey = '$userId;$senderKey';
|
||||
if (_restoredOlmSessionsTime.containsKey(mapKey) &&
|
||||
DateTime.now()
|
||||
.subtract(Duration(hours: 1))
|
||||
.isBefore(_restoredOlmSessionsTime[mapKey]!)) {
|
||||
return;
|
||||
}
|
||||
_restoredOlmSessionsTime[mapKey] = DateTime.now();
|
||||
await startOutgoingOlmSessions([device]);
|
||||
await client.sendToDeviceEncrypted([device], EventTypes.Dummy, {});
|
||||
}
|
||||
|
||||
Future<ToDeviceEvent> decryptToDeviceEvent(ToDeviceEvent event) async {
|
||||
if (event.type != EventTypes.Encrypted) {
|
||||
return event;
|
||||
}
|
||||
final senderKey = event.parsedRoomEncryptedContent.senderKey;
|
||||
final loadFromDb = () async {
|
||||
final sessions = await getOlmSessions(senderKey);
|
||||
return sessions.isNotEmpty;
|
||||
};
|
||||
if (!_olmSessions.containsKey(senderKey)) {
|
||||
await loadFromDb();
|
||||
}
|
||||
try {
|
||||
event = _decryptToDeviceEvent(event);
|
||||
if (event.type != EventTypes.Encrypted || !(await loadFromDb())) {
|
||||
return event;
|
||||
}
|
||||
// retry to decrypt!
|
||||
return _decryptToDeviceEvent(event);
|
||||
} catch (_) {
|
||||
// okay, the thing errored while decrypting. It is safe to assume that the olm session is corrupt and we should generate a new one
|
||||
|
||||
// ignore: unawaited_futures
|
||||
runInRoot(() => restoreOlmSession(event.senderId, senderKey));
|
||||
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> startOutgoingOlmSessions(List<DeviceKeys> deviceKeys) async {
|
||||
Logs().v(
|
||||
'[OlmManager] Starting session with ${deviceKeys.length} devices...');
|
||||
final requestingKeysFrom = <String, Map<String, String>>{};
|
||||
for (final device in deviceKeys) {
|
||||
if (requestingKeysFrom[device.userId] == null) {
|
||||
requestingKeysFrom[device.userId] = {};
|
||||
}
|
||||
requestingKeysFrom[device.userId]![device.deviceId!] =
|
||||
'signed_curve25519';
|
||||
}
|
||||
|
||||
final response = await client.claimKeys(requestingKeysFrom, timeout: 10000);
|
||||
|
||||
for (final userKeysEntry in response.oneTimeKeys.entries) {
|
||||
final userId = userKeysEntry.key;
|
||||
for (final deviceKeysEntry in userKeysEntry.value.entries) {
|
||||
final deviceId = deviceKeysEntry.key;
|
||||
final fingerprintKey =
|
||||
client.userDeviceKeys[userId]!.deviceKeys[deviceId]!.ed25519Key;
|
||||
final identityKey =
|
||||
client.userDeviceKeys[userId]!.deviceKeys[deviceId]!.curve25519Key;
|
||||
for (final Map<String, dynamic> deviceKey
|
||||
in deviceKeysEntry.value.values) {
|
||||
if (fingerprintKey == null ||
|
||||
identityKey == null ||
|
||||
!deviceKey.checkJsonSignature(fingerprintKey, userId, deviceId)) {
|
||||
continue;
|
||||
}
|
||||
Logs().v('[OlmManager] Starting session with $userId:$deviceId');
|
||||
final session = olm.Session();
|
||||
try {
|
||||
session.create_outbound(
|
||||
_olmAccount!, identityKey, deviceKey['key']);
|
||||
await storeOlmSession(OlmSession(
|
||||
key: client.userID!,
|
||||
identityKey: identityKey,
|
||||
sessionId: session.session_id(),
|
||||
session: session,
|
||||
lastReceived:
|
||||
DateTime.now(), // we want to use a newly created session
|
||||
));
|
||||
} catch (e, s) {
|
||||
session.free();
|
||||
Logs()
|
||||
.e('[LibOlm] Could not create new outbound olm session', e, s);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> encryptToDeviceMessagePayload(
|
||||
DeviceKeys device, String type, Map<String, dynamic> payload,
|
||||
{bool getFromDb = true}) async {
|
||||
final sess =
|
||||
await getOlmSessions(device.curve25519Key!, getFromDb: getFromDb);
|
||||
if (sess.isEmpty) {
|
||||
throw ('No olm session found for ${device.userId}:${device.deviceId}');
|
||||
}
|
||||
final fullPayload = {
|
||||
'type': type,
|
||||
'content': payload,
|
||||
'sender': client.userID,
|
||||
'keys': {'ed25519': fingerprintKey},
|
||||
'recipient': device.userId,
|
||||
'recipient_keys': {'ed25519': device.ed25519Key},
|
||||
};
|
||||
final encryptResult = sess.first.session!.encrypt(json.encode(fullPayload));
|
||||
await storeOlmSession(sess.first);
|
||||
if (client.database != null) {
|
||||
// ignore: unawaited_futures
|
||||
runInRoot(() => client.database?.setLastSentMessageUserDeviceKey(
|
||||
json.encode({
|
||||
'type': type,
|
||||
'content': payload,
|
||||
}),
|
||||
device.userId,
|
||||
device.deviceId!));
|
||||
}
|
||||
final encryptedBody = <String, dynamic>{
|
||||
'algorithm': AlgorithmTypes.olmV1Curve25519AesSha2,
|
||||
'sender_key': identityKey,
|
||||
'ciphertext': <String, dynamic>{},
|
||||
};
|
||||
encryptedBody['ciphertext'][device.curve25519Key] = {
|
||||
'type': encryptResult.type,
|
||||
'body': encryptResult.body,
|
||||
};
|
||||
return encryptedBody;
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> encryptToDeviceMessage(
|
||||
List<DeviceKeys> deviceKeys,
|
||||
String type,
|
||||
Map<String, dynamic> payload) async {
|
||||
final data = <String, Map<String, Map<String, dynamic>>>{};
|
||||
// first check if any of our sessions we want to encrypt for are in the database
|
||||
if (client.database != null) {
|
||||
await getOlmSessionsForDevicesFromDatabase(
|
||||
deviceKeys.map((d) => d.curve25519Key!).toList());
|
||||
}
|
||||
final deviceKeysWithoutSession = List<DeviceKeys>.from(deviceKeys);
|
||||
deviceKeysWithoutSession.removeWhere((DeviceKeys deviceKeys) =>
|
||||
olmSessions[deviceKeys.curve25519Key]?.isNotEmpty ?? false);
|
||||
if (deviceKeysWithoutSession.isNotEmpty) {
|
||||
await startOutgoingOlmSessions(deviceKeysWithoutSession);
|
||||
}
|
||||
for (final device in deviceKeys) {
|
||||
final userData = data[device.userId] ??= {};
|
||||
try {
|
||||
userData[device.deviceId!] = await encryptToDeviceMessagePayload(
|
||||
device, type, payload,
|
||||
getFromDb: false);
|
||||
} catch (e, s) {
|
||||
Logs().w('[LibOlm] Error encrypting to-device event', e, s);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
Future<void> handleToDeviceEvent(ToDeviceEvent event) async {
|
||||
if (event.type == EventTypes.Dummy) {
|
||||
// We receive dan encrypted m.dummy. This means that the other end was not able to
|
||||
// decrypt our last message. So, we re-send it.
|
||||
final encryptedContent = event.encryptedContent;
|
||||
if (encryptedContent == null || client.database == null) {
|
||||
return;
|
||||
}
|
||||
final device = client.getUserDeviceKeysByCurve25519Key(
|
||||
encryptedContent.tryGet<String>('sender_key') ?? '');
|
||||
if (device == null) {
|
||||
return; // device not found
|
||||
}
|
||||
Logs().v(
|
||||
'[OlmManager] Device ${device.userId}:${device.deviceId} generated a new olm session, replaying last sent message...');
|
||||
final lastSentMessageRes = await client.database
|
||||
?.getLastSentMessageUserDeviceKey(device.userId, device.deviceId!);
|
||||
if (lastSentMessageRes == null ||
|
||||
lastSentMessageRes.isEmpty ||
|
||||
lastSentMessageRes.first.isEmpty) {
|
||||
return;
|
||||
}
|
||||
final lastSentMessage = json.decode(lastSentMessageRes.first);
|
||||
// We do *not* want to re-play m.dummy events, as they hold no value except of saying
|
||||
// what olm session is the most recent one. In fact, if we *do* replay them, then
|
||||
// we can easily land in an infinite ping-pong trap!
|
||||
if (lastSentMessage['type'] != EventTypes.Dummy) {
|
||||
// okay, time to send the message!
|
||||
await client.sendToDeviceEncrypted(
|
||||
[device], lastSentMessage['type'], lastSentMessage['content']);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
for (final sessions in olmSessions.values) {
|
||||
for (final sess in sessions) {
|
||||
sess.dispose();
|
||||
}
|
||||
}
|
||||
_olmAccount?.free();
|
||||
_olmAccount = null;
|
||||
}
|
||||
}
|
||||
755
lib/encryption/ssss.dart
Normal file
755
lib/encryption/ssss.dart
Normal file
|
|
@ -0,0 +1,755 @@
|
|||
/*
|
||||
* Famedly Matrix SDK
|
||||
* Copyright (C) 2020, 2021 Famedly GmbH
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as
|
||||
* published by the Free Software Foundation, either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:core';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:base58check/base58.dart';
|
||||
import 'package:crypto/crypto.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:matrix/encryption/utils/base64_unpadded.dart';
|
||||
|
||||
import '../matrix.dart';
|
||||
import '../src/utils/crypto/crypto.dart' as uc;
|
||||
import '../src/utils/run_in_root.dart';
|
||||
import 'encryption.dart';
|
||||
import 'utils/ssss_cache.dart';
|
||||
|
||||
const cacheTypes = <String>{
|
||||
EventTypes.CrossSigningSelfSigning,
|
||||
EventTypes.CrossSigningUserSigning,
|
||||
EventTypes.MegolmBackup,
|
||||
};
|
||||
|
||||
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';
|
||||
const base58Alphabet =
|
||||
'123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz';
|
||||
const base58 = Base58Codec(base58Alphabet);
|
||||
const olmRecoveryKeyPrefix = [0x8B, 0x01];
|
||||
const ssssKeyLength = 32;
|
||||
const pbkdf2DefaultIterations = 500000;
|
||||
const pbkdf2SaltLength = 64;
|
||||
|
||||
/// SSSS: **S**ecure **S**ecret **S**torage and **S**haring
|
||||
/// Read more about SSSS at:
|
||||
/// https://matrix.org/docs/guides/implementing-more-advanced-e-2-ee-features-such-as-cross-signing#3-implementing-ssss
|
||||
class SSSS {
|
||||
final Encryption encryption;
|
||||
|
||||
Client get client => encryption.client;
|
||||
final pendingShareRequests = <String, _ShareRequest>{};
|
||||
final _validators = <String, FutureOr<bool> Function(String)>{};
|
||||
final _cacheCallbacks = <String, FutureOr<void> Function(String)>{};
|
||||
final Map<String, SSSSCache> _cache = <String, SSSSCache>{};
|
||||
|
||||
SSSS(this.encryption);
|
||||
|
||||
// for testing
|
||||
Future<void> clearCache() async {
|
||||
await client.database?.clearSSSSCache();
|
||||
_cache.clear();
|
||||
}
|
||||
|
||||
static _DerivedKeys deriveKeys(Uint8List key, String name) {
|
||||
final zerosalt = Uint8List(8);
|
||||
final prk = Hmac(sha256, zerosalt).convert(key);
|
||||
final b = Uint8List(1);
|
||||
b[0] = 1;
|
||||
final aesKey = Hmac(sha256, prk.bytes).convert(utf8.encode(name) + b);
|
||||
b[0] = 2;
|
||||
final hmacKey =
|
||||
Hmac(sha256, prk.bytes).convert(aesKey.bytes + utf8.encode(name) + b);
|
||||
return _DerivedKeys(
|
||||
aesKey: Uint8List.fromList(aesKey.bytes),
|
||||
hmacKey: Uint8List.fromList(hmacKey.bytes));
|
||||
}
|
||||
|
||||
static Future<_Encrypted> encryptAes(String data, Uint8List key, String name,
|
||||
[String? ivStr]) async {
|
||||
Uint8List iv;
|
||||
if (ivStr != null) {
|
||||
iv = base64decodeUnpadded(ivStr);
|
||||
} else {
|
||||
iv = Uint8List.fromList(uc.secureRandomBytes(16));
|
||||
}
|
||||
// we need to clear bit 63 of the IV
|
||||
iv[8] &= 0x7f;
|
||||
|
||||
final keys = deriveKeys(key, name);
|
||||
|
||||
final plain = Uint8List.fromList(utf8.encode(data));
|
||||
final ciphertext = await uc.aesCtr.encrypt(plain, keys.aesKey, iv);
|
||||
|
||||
final hmac = Hmac(sha256, keys.hmacKey).convert(ciphertext);
|
||||
|
||||
return _Encrypted(
|
||||
iv: base64.encode(iv),
|
||||
ciphertext: base64.encode(ciphertext),
|
||||
mac: base64.encode(hmac.bytes));
|
||||
}
|
||||
|
||||
static Future<String> decryptAes(
|
||||
_Encrypted data, Uint8List key, String name) async {
|
||||
final keys = deriveKeys(key, name);
|
||||
final cipher = base64decodeUnpadded(data.ciphertext);
|
||||
final hmac = base64
|
||||
.encode(Hmac(sha256, keys.hmacKey).convert(cipher).bytes)
|
||||
.replaceAll(RegExp(r'=+$'), '');
|
||||
if (hmac != data.mac.replaceAll(RegExp(r'=+$'), '')) {
|
||||
throw Exception('Bad MAC');
|
||||
}
|
||||
final decipher = await uc.aesCtr
|
||||
.encrypt(cipher, keys.aesKey, base64decodeUnpadded(data.iv));
|
||||
return String.fromCharCodes(decipher);
|
||||
}
|
||||
|
||||
static Uint8List decodeRecoveryKey(String recoveryKey) {
|
||||
final result = base58.decode(recoveryKey.replaceAll(RegExp(r'\s'), ''));
|
||||
|
||||
final parity = result.fold<int>(0, (a, b) => a ^ b);
|
||||
if (parity != 0) {
|
||||
throw Exception('Incorrect parity');
|
||||
}
|
||||
|
||||
for (var i = 0; i < olmRecoveryKeyPrefix.length; i++) {
|
||||
if (result[i] != olmRecoveryKeyPrefix[i]) {
|
||||
throw Exception('Incorrect prefix');
|
||||
}
|
||||
}
|
||||
|
||||
if (result.length != olmRecoveryKeyPrefix.length + ssssKeyLength + 1) {
|
||||
throw Exception('Incorrect length');
|
||||
}
|
||||
|
||||
return Uint8List.fromList(result.sublist(olmRecoveryKeyPrefix.length,
|
||||
olmRecoveryKeyPrefix.length + ssssKeyLength));
|
||||
}
|
||||
|
||||
static String encodeRecoveryKey(Uint8List recoveryKey) {
|
||||
final keyToEncode = <int>[...olmRecoveryKeyPrefix, ...recoveryKey];
|
||||
final parity = keyToEncode.fold<int>(0, (a, b) => a ^ b);
|
||||
keyToEncode.add(parity);
|
||||
// base58-encode and add a space every four chars
|
||||
return base58
|
||||
.encode(keyToEncode)
|
||||
.replaceAllMapped(RegExp(r'.{4}'), (s) => '${s.group(0)} ')
|
||||
.trim();
|
||||
}
|
||||
|
||||
static Future<Uint8List> keyFromPassphrase(
|
||||
String passphrase, PassphraseInfo info) async {
|
||||
if (info.algorithm != AlgorithmTypes.pbkdf2) {
|
||||
throw Exception('Unknown algorithm');
|
||||
}
|
||||
if (info.iterations == null) {
|
||||
throw Exception('Passphrase info without iterations');
|
||||
}
|
||||
if (info.salt == null) {
|
||||
throw Exception('Passphrase info without salt');
|
||||
}
|
||||
return await uc.pbkdf2(
|
||||
Uint8List.fromList(utf8.encode(passphrase)),
|
||||
Uint8List.fromList(utf8.encode(info.salt!)),
|
||||
uc.sha512,
|
||||
info.iterations!,
|
||||
info.bits ?? 256);
|
||||
}
|
||||
|
||||
void setValidator(String type, FutureOr<bool> Function(String) validator) {
|
||||
_validators[type] = validator;
|
||||
}
|
||||
|
||||
void setCacheCallback(String type, FutureOr<void> Function(String) callback) {
|
||||
_cacheCallbacks[type] = callback;
|
||||
}
|
||||
|
||||
String? get defaultKeyId => client
|
||||
.accountData[EventTypes.SecretStorageDefaultKey]
|
||||
?.parsedSecretStorageDefaultKeyContent
|
||||
.key;
|
||||
|
||||
Future<void> setDefaultKeyId(String keyId) async {
|
||||
await client.setAccountData(
|
||||
client.userID!,
|
||||
EventTypes.SecretStorageDefaultKey,
|
||||
SecretStorageDefaultKeyContent(key: keyId).toJson(),
|
||||
);
|
||||
}
|
||||
|
||||
SecretStorageKeyContent? getKey(String keyId) {
|
||||
return client.accountData[EventTypes.secretStorageKey(keyId)]
|
||||
?.parsedSecretStorageKeyContent;
|
||||
}
|
||||
|
||||
bool isKeyValid(String keyId) =>
|
||||
getKey(keyId)?.algorithm == AlgorithmTypes.secretStorageV1AesHmcSha2;
|
||||
|
||||
/// Creates a new secret storage key, optional encrypts it with [passphrase]
|
||||
/// and stores it in the user's `accountData`.
|
||||
Future<OpenSSSS> createKey([String? passphrase]) async {
|
||||
Uint8List privateKey;
|
||||
final content = SecretStorageKeyContent();
|
||||
if (passphrase != null) {
|
||||
// we need to derive the key off of the passphrase
|
||||
content.passphrase = PassphraseInfo(
|
||||
iterations: pbkdf2DefaultIterations,
|
||||
salt: base64.encode(uc.secureRandomBytes(pbkdf2SaltLength)),
|
||||
algorithm: AlgorithmTypes.pbkdf2,
|
||||
bits: ssssKeyLength * 8,
|
||||
);
|
||||
privateKey = await client
|
||||
.runInBackground(
|
||||
_keyFromPassphrase,
|
||||
_KeyFromPassphraseArgs(
|
||||
passphrase: passphrase,
|
||||
info: content.passphrase!,
|
||||
),
|
||||
)
|
||||
.timeout(Duration(seconds: 10));
|
||||
} else {
|
||||
// we need to just generate a new key from scratch
|
||||
privateKey = Uint8List.fromList(uc.secureRandomBytes(ssssKeyLength));
|
||||
}
|
||||
// now that we have the private key, let's create the iv and mac
|
||||
final encrypted = await encryptAes(zeroStr, privateKey, '');
|
||||
content.iv = encrypted.iv;
|
||||
content.mac = encrypted.mac;
|
||||
content.algorithm = AlgorithmTypes.secretStorageV1AesHmcSha2;
|
||||
|
||||
const keyidByteLength = 24;
|
||||
|
||||
// make sure we generate a unique key id
|
||||
final keyId = () sync* {
|
||||
for (;;) {
|
||||
yield base64.encode(uc.secureRandomBytes(keyidByteLength));
|
||||
}
|
||||
}()
|
||||
.firstWhere((keyId) => getKey(keyId) == null);
|
||||
|
||||
final accountDataType = EventTypes.secretStorageKey(keyId);
|
||||
// noooow we set the account data
|
||||
final waitForAccountData = client.onSync.stream.firstWhere((syncUpdate) =>
|
||||
syncUpdate.accountData != null &&
|
||||
syncUpdate.accountData!
|
||||
.any((accountData) => accountData.type == accountDataType));
|
||||
await client.setAccountData(
|
||||
client.userID!, accountDataType, content.toJson());
|
||||
await waitForAccountData;
|
||||
|
||||
final key = open(keyId);
|
||||
await key.setPrivateKey(privateKey);
|
||||
return key;
|
||||
}
|
||||
|
||||
Future<bool> 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'=+$'), '');
|
||||
} else {
|
||||
// no real information about the key, assume it is valid
|
||||
return true;
|
||||
}
|
||||
} else {
|
||||
throw Exception('Unknown Algorithm');
|
||||
}
|
||||
}
|
||||
|
||||
bool isSecret(String type) =>
|
||||
client.accountData[type] != null &&
|
||||
client.accountData[type]!.content['encrypted'] is Map;
|
||||
|
||||
Future<String?> getCached(String type) async {
|
||||
if (client.database == null) {
|
||||
return null;
|
||||
}
|
||||
// check if it is still valid
|
||||
final keys = keyIdsFromType(type);
|
||||
if (keys == null) {
|
||||
return null;
|
||||
}
|
||||
final isValid = (dbEntry) =>
|
||||
keys.contains(dbEntry.keyId) &&
|
||||
dbEntry.ciphertext != null &&
|
||||
client.accountData[type]?.content['encrypted'][dbEntry.keyId]
|
||||
['ciphertext'] ==
|
||||
dbEntry.ciphertext;
|
||||
if (_cache.containsKey(type) && isValid(_cache[type])) {
|
||||
return _cache[type]?.content;
|
||||
}
|
||||
final ret = await client.database?.getSSSSCache(type);
|
||||
if (ret == null) {
|
||||
return null;
|
||||
}
|
||||
if (isValid(ret)) {
|
||||
_cache[type] = ret;
|
||||
return ret.content;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
Future<String> getStored(String type, String keyId, Uint8List key) async {
|
||||
final secretInfo = client.accountData[type];
|
||||
if (secretInfo == null) {
|
||||
throw Exception('Not found');
|
||||
}
|
||||
if (!(secretInfo.content['encrypted'] is Map)) {
|
||||
throw Exception('Content is not encrypted');
|
||||
}
|
||||
if (!(secretInfo.content['encrypted'][keyId] is Map)) {
|
||||
throw Exception('Wrong / unknown key');
|
||||
}
|
||||
final enc = secretInfo.content['encrypted'][keyId];
|
||||
final encryptInfo = _Encrypted(
|
||||
iv: enc['iv'], ciphertext: enc['ciphertext'], mac: enc['mac']);
|
||||
final decrypted = await decryptAes(encryptInfo, key, type);
|
||||
final db = client.database;
|
||||
if (cacheTypes.contains(type) && db != null) {
|
||||
// cache the thing
|
||||
await db.storeSSSSCache(type, keyId, enc['ciphertext'], decrypted);
|
||||
if (_cacheCallbacks.containsKey(type) && await getCached(type) == null) {
|
||||
_cacheCallbacks[type]!(decrypted);
|
||||
}
|
||||
}
|
||||
return decrypted;
|
||||
}
|
||||
|
||||
Future<void> store(String type, String secret, String keyId, Uint8List key,
|
||||
{bool add = false}) async {
|
||||
final encrypted = await encryptAes(secret, key, type);
|
||||
Map<String, dynamic>? content;
|
||||
if (add && client.accountData[type] != null) {
|
||||
content = client.accountData[type]!.content.copy();
|
||||
if (!(content['encrypted'] is Map)) {
|
||||
content['encrypted'] = <String, dynamic>{};
|
||||
}
|
||||
}
|
||||
content ??= <String, dynamic>{
|
||||
'encrypted': <String, dynamic>{},
|
||||
};
|
||||
content['encrypted'][keyId] = <String, dynamic>{
|
||||
'iv': encrypted.iv,
|
||||
'ciphertext': encrypted.ciphertext,
|
||||
'mac': encrypted.mac,
|
||||
};
|
||||
// store the thing in your account data
|
||||
await client.setAccountData(client.userID!, type, content);
|
||||
final db = client.database;
|
||||
if (cacheTypes.contains(type) && db != null) {
|
||||
// cache the thing
|
||||
await db.storeSSSSCache(type, keyId, encrypted.ciphertext, secret);
|
||||
if (_cacheCallbacks.containsKey(type) && await getCached(type) == null) {
|
||||
_cacheCallbacks[type]!(secret);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> validateAndStripOtherKeys(
|
||||
String type, String secret, String keyId, Uint8List key) async {
|
||||
if (await getStored(type, keyId, key) != secret) {
|
||||
throw Exception('Secrets do not match up!');
|
||||
}
|
||||
// now remove all other keys
|
||||
final content = client.accountData[type]?.content.copy();
|
||||
if (content == null) {
|
||||
throw Exception('Key has no content!');
|
||||
}
|
||||
|
||||
final otherKeys =
|
||||
Set<String>.from(content['encrypted'].keys.where((k) => k != keyId));
|
||||
content['encrypted'].removeWhere((k, v) => otherKeys.contains(k));
|
||||
// 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);
|
||||
if (cacheTypes.contains(type)) {
|
||||
// cache the thing
|
||||
await client.database?.storeSSSSCache(
|
||||
type, keyId, content['encrypted'][keyId]['ciphertext'], secret);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> maybeCacheAll(String keyId, Uint8List key) async {
|
||||
for (final type in cacheTypes) {
|
||||
final secret = await getCached(type);
|
||||
if (secret == null) {
|
||||
try {
|
||||
await getStored(type, keyId, key);
|
||||
} catch (_) {
|
||||
// the entry wasn't stored, just ignore it
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> maybeRequestAll([List<DeviceKeys>? devices]) async {
|
||||
for (final type in cacheTypes) {
|
||||
if (keyIdsFromType(type) != null) {
|
||||
final secret = await getCached(type);
|
||||
if (secret == null) {
|
||||
await request(type, devices);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> request(String type, [List<DeviceKeys>? devices]) async {
|
||||
// only send to own, verified devices
|
||||
Logs().i('[SSSS] Requesting type $type...');
|
||||
if (devices == null || devices.isEmpty) {
|
||||
if (!client.userDeviceKeys.containsKey(client.userID)) {
|
||||
Logs().w('[SSSS] User does not have any devices');
|
||||
return;
|
||||
}
|
||||
devices =
|
||||
client.userDeviceKeys[client.userID]!.deviceKeys.values.toList();
|
||||
}
|
||||
devices.removeWhere((DeviceKeys d) =>
|
||||
d.userId != client.userID ||
|
||||
!d.verified ||
|
||||
d.blocked ||
|
||||
d.deviceId == client.deviceID);
|
||||
if (devices.isEmpty) {
|
||||
Logs().w('[SSSS] No devices');
|
||||
return;
|
||||
}
|
||||
final requestId = client.generateUniqueTransactionId();
|
||||
final request = _ShareRequest(
|
||||
requestId: requestId,
|
||||
type: type,
|
||||
devices: devices,
|
||||
);
|
||||
pendingShareRequests[requestId] = request;
|
||||
await client.sendToDeviceEncrypted(devices, EventTypes.SecretRequest, {
|
||||
'action': 'request',
|
||||
'requesting_device_id': client.deviceID,
|
||||
'request_id': requestId,
|
||||
'name': type,
|
||||
});
|
||||
}
|
||||
|
||||
DateTime? _lastCacheRequest;
|
||||
bool _isPeriodicallyRequestingMissingCache = false;
|
||||
|
||||
Future<void> periodicallyRequestMissingCache() async {
|
||||
if (_isPeriodicallyRequestingMissingCache ||
|
||||
(_lastCacheRequest != null &&
|
||||
DateTime.now()
|
||||
.subtract(Duration(minutes: 15))
|
||||
.isBefore(_lastCacheRequest!)) ||
|
||||
client.isUnknownSession) {
|
||||
// we are already requesting right now or we attempted to within the last 15 min
|
||||
return;
|
||||
}
|
||||
_lastCacheRequest = DateTime.now();
|
||||
_isPeriodicallyRequestingMissingCache = true;
|
||||
try {
|
||||
await maybeRequestAll();
|
||||
} finally {
|
||||
_isPeriodicallyRequestingMissingCache = false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> handleToDeviceEvent(ToDeviceEvent event) async {
|
||||
if (event.type == EventTypes.SecretRequest) {
|
||||
// got a request to share a secret
|
||||
Logs().i('[SSSS] Received sharing request...');
|
||||
if (event.sender != client.userID ||
|
||||
!client.userDeviceKeys.containsKey(client.userID)) {
|
||||
Logs().i('[SSSS] Not sent by us');
|
||||
return; // we aren't asking for it ourselves, so ignore
|
||||
}
|
||||
if (event.content['action'] != 'request') {
|
||||
Logs().i('[SSSS] it is actually a cancelation');
|
||||
return; // not actually requesting, so ignore
|
||||
}
|
||||
final device = client.userDeviceKeys[client.userID]!
|
||||
.deviceKeys[event.content['requesting_device_id']];
|
||||
if (device == null || !device.verified || device.blocked) {
|
||||
Logs().i('[SSSS] Unknown / unverified devices, ignoring');
|
||||
return; // nope....unknown or untrusted device
|
||||
}
|
||||
// alright, all seems fine...let's check if we actually have the secret they are asking for
|
||||
final type = event.content['name'];
|
||||
final secret = await getCached(type);
|
||||
if (secret == null) {
|
||||
Logs()
|
||||
.i('[SSSS] We don\'t have the secret for $type ourself, ignoring');
|
||||
return; // seems like we don't have this, either
|
||||
}
|
||||
// okay, all checks out...time to share this secret!
|
||||
Logs().i('[SSSS] Replying with secret for $type');
|
||||
await client.sendToDeviceEncrypted(
|
||||
[device],
|
||||
EventTypes.SecretSend,
|
||||
{
|
||||
'request_id': event.content['request_id'],
|
||||
'secret': secret,
|
||||
});
|
||||
} else if (event.type == EventTypes.SecretSend) {
|
||||
// receiving a secret we asked for
|
||||
Logs().i('[SSSS] Received shared secret...');
|
||||
final encryptedContent = event.encryptedContent;
|
||||
if (event.sender != client.userID ||
|
||||
!pendingShareRequests.containsKey(event.content['request_id']) ||
|
||||
encryptedContent == null) {
|
||||
Logs().i('[SSSS] Not by us or unknown request');
|
||||
return; // we have no idea what we just received
|
||||
}
|
||||
final request = pendingShareRequests[event.content['request_id']]!;
|
||||
// alright, as we received a known request id, let's check if the sender is valid
|
||||
final device = request.devices.firstWhereOrNull((d) =>
|
||||
d.userId == event.sender &&
|
||||
d.curve25519Key == encryptedContent['sender_key']);
|
||||
if (device == null) {
|
||||
Logs().i('[SSSS] Someone else replied?');
|
||||
return; // someone replied whom we didn't send the share request to
|
||||
}
|
||||
final secret = event.content['secret'];
|
||||
if (!(event.content['secret'] is String)) {
|
||||
Logs().i('[SSSS] Secret wasn\'t a string');
|
||||
return; // the secret wasn't a string....wut?
|
||||
}
|
||||
// let's validate if the secret is, well, valid
|
||||
if (_validators.containsKey(request.type) &&
|
||||
!(await _validators[request.type]!(secret))) {
|
||||
Logs().i('[SSSS] The received secret was invalid');
|
||||
return; // didn't pass the validator
|
||||
}
|
||||
pendingShareRequests.remove(request.requestId);
|
||||
if (request.start.add(Duration(minutes: 15)).isBefore(DateTime.now())) {
|
||||
Logs().i('[SSSS] Request is too far in the past');
|
||||
return; // our request is more than 15min in the past...better not trust it anymore
|
||||
}
|
||||
Logs().i('[SSSS] Secret for type ${request.type} is ok, storing it');
|
||||
final db = client.database;
|
||||
if (db != null) {
|
||||
final keyId = keyIdFromType(request.type);
|
||||
if (keyId != null) {
|
||||
final ciphertext = client.accountData[request.type]!
|
||||
.content['encrypted'][keyId]['ciphertext'];
|
||||
await db.storeSSSSCache(request.type, keyId, ciphertext, secret);
|
||||
if (_cacheCallbacks.containsKey(request.type)) {
|
||||
_cacheCallbacks[request.type]!(secret);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Set<String>? keyIdsFromType(String type) {
|
||||
final data = client.accountData[type];
|
||||
if (data == null) {
|
||||
return null;
|
||||
}
|
||||
if (data.content['encrypted'] is Map) {
|
||||
return data.content['encrypted'].keys.toSet();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
String? keyIdFromType(String type) {
|
||||
final keys = keyIdsFromType(type);
|
||||
if (keys == null || keys.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
if (keys.contains(defaultKeyId)) {
|
||||
return defaultKeyId;
|
||||
}
|
||||
return keys.first;
|
||||
}
|
||||
|
||||
OpenSSSS open([String? identifier]) {
|
||||
identifier ??= defaultKeyId;
|
||||
if (identifier == null) {
|
||||
throw Exception('Dont know what to open');
|
||||
}
|
||||
final keyToOpen = keyIdFromType(identifier) ?? identifier;
|
||||
final key = getKey(keyToOpen);
|
||||
if (key == null) {
|
||||
throw Exception('Unknown key to open');
|
||||
}
|
||||
return OpenSSSS(ssss: this, keyId: keyToOpen, keyData: key);
|
||||
}
|
||||
}
|
||||
|
||||
class _ShareRequest {
|
||||
final String requestId;
|
||||
final String type;
|
||||
final List<DeviceKeys> devices;
|
||||
final DateTime start;
|
||||
|
||||
_ShareRequest(
|
||||
{required this.requestId, required this.type, required this.devices})
|
||||
: start = DateTime.now();
|
||||
}
|
||||
|
||||
class _Encrypted {
|
||||
final String iv;
|
||||
final String ciphertext;
|
||||
final String mac;
|
||||
|
||||
_Encrypted({required this.iv, required this.ciphertext, required this.mac});
|
||||
}
|
||||
|
||||
class _DerivedKeys {
|
||||
final Uint8List aesKey;
|
||||
final Uint8List hmacKey;
|
||||
|
||||
_DerivedKeys({required this.aesKey, required this.hmacKey});
|
||||
}
|
||||
|
||||
class OpenSSSS {
|
||||
final SSSS ssss;
|
||||
final String keyId;
|
||||
final SecretStorageKeyContent keyData;
|
||||
|
||||
OpenSSSS({required this.ssss, required this.keyId, required this.keyData});
|
||||
|
||||
Uint8List? privateKey;
|
||||
|
||||
bool get isUnlocked => privateKey != null;
|
||||
|
||||
bool get hasPassphrase => keyData.passphrase != null;
|
||||
|
||||
String? get recoveryKey =>
|
||||
isUnlocked ? SSSS.encodeRecoveryKey(privateKey!) : null;
|
||||
|
||||
Future<void> unlock(
|
||||
{String? passphrase,
|
||||
String? recoveryKey,
|
||||
String? keyOrPassphrase,
|
||||
bool postUnlock = true}) async {
|
||||
if (keyOrPassphrase != null) {
|
||||
try {
|
||||
await unlock(recoveryKey: keyOrPassphrase, postUnlock: postUnlock);
|
||||
} catch (_) {
|
||||
if (hasPassphrase) {
|
||||
await unlock(passphrase: keyOrPassphrase, postUnlock: postUnlock);
|
||||
} else {
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
return;
|
||||
} else if (passphrase != null) {
|
||||
if (!hasPassphrase) {
|
||||
throw Exception(
|
||||
'Tried to unlock with passphrase while key does not have a passphrase');
|
||||
}
|
||||
privateKey = await ssss.client
|
||||
.runInBackground(
|
||||
_keyFromPassphrase,
|
||||
_KeyFromPassphraseArgs(
|
||||
passphrase: passphrase,
|
||||
info: keyData.passphrase!,
|
||||
),
|
||||
)
|
||||
.timeout(Duration(seconds: 10));
|
||||
} else if (recoveryKey != null) {
|
||||
privateKey = SSSS.decodeRecoveryKey(recoveryKey);
|
||||
} else {
|
||||
throw Exception('Nothing specified');
|
||||
}
|
||||
// verify the validity of the key
|
||||
if (!await ssss.checkKey(privateKey!, keyData)) {
|
||||
privateKey = null;
|
||||
throw Exception('Inalid key');
|
||||
}
|
||||
if (postUnlock) {
|
||||
await runInRoot(() => _postUnlock());
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> setPrivateKey(Uint8List key) async {
|
||||
if (!await ssss.checkKey(key, keyData)) {
|
||||
throw Exception('Invalid key');
|
||||
}
|
||||
privateKey = key;
|
||||
}
|
||||
|
||||
Future<String> getStored(String type) async {
|
||||
final privateKey = this.privateKey;
|
||||
if (privateKey == null) {
|
||||
throw Exception('SSSS not unlocked');
|
||||
}
|
||||
return await ssss.getStored(type, keyId, privateKey);
|
||||
}
|
||||
|
||||
Future<void> store(String type, String secret, {bool add = false}) async {
|
||||
final privateKey = this.privateKey;
|
||||
if (privateKey == null) {
|
||||
throw Exception('SSSS not unlocked');
|
||||
}
|
||||
await ssss.store(type, secret, keyId, privateKey, add: add);
|
||||
}
|
||||
|
||||
Future<void> validateAndStripOtherKeys(String type, String secret) async {
|
||||
final privateKey = this.privateKey;
|
||||
if (privateKey == null) {
|
||||
throw Exception('SSSS not unlocked');
|
||||
}
|
||||
await ssss.validateAndStripOtherKeys(type, secret, keyId, privateKey);
|
||||
}
|
||||
|
||||
Future<void> maybeCacheAll() async {
|
||||
final privateKey = this.privateKey;
|
||||
if (privateKey == null) {
|
||||
throw Exception('SSSS not unlocked');
|
||||
}
|
||||
await ssss.maybeCacheAll(keyId, privateKey);
|
||||
}
|
||||
|
||||
Future<void> _postUnlock() async {
|
||||
// first try to cache all secrets that aren't cached yet
|
||||
await maybeCacheAll();
|
||||
// now try to self-sign
|
||||
if (ssss.encryption.crossSigning.enabled &&
|
||||
ssss.client.userDeviceKeys[ssss.client.userID]?.masterKey != null &&
|
||||
(ssss
|
||||
.keyIdsFromType(EventTypes.CrossSigningMasterKey)
|
||||
?.contains(keyId) ??
|
||||
false) &&
|
||||
(ssss.client.isUnknownSession ||
|
||||
ssss.client.userDeviceKeys[ssss.client.userID]!.masterKey
|
||||
?.directVerified !=
|
||||
true)) {
|
||||
try {
|
||||
await ssss.encryption.crossSigning.selfSign(openSsss: this);
|
||||
} catch (e, s) {
|
||||
Logs().e('[SSSS] Failed to self-sign', e, s);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class _KeyFromPassphraseArgs {
|
||||
final String passphrase;
|
||||
final PassphraseInfo info;
|
||||
|
||||
_KeyFromPassphraseArgs({required this.passphrase, required this.info});
|
||||
}
|
||||
|
||||
Future<Uint8List> _keyFromPassphrase(_KeyFromPassphraseArgs args) async {
|
||||
return await SSSS.keyFromPassphrase(args.passphrase, args.info);
|
||||
}
|
||||
13
lib/encryption/utils/base64_unpadded.dart
Normal file
13
lib/encryption/utils/base64_unpadded.dart
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
import 'dart:convert';
|
||||
import 'dart:typed_data';
|
||||
|
||||
/// decodes base64
|
||||
///
|
||||
/// Dart's native [base64.decode] requires a padded base64 input String.
|
||||
/// This function allows unpadded base64 too.
|
||||
///
|
||||
/// See: https://github.com/dart-lang/sdk/issues/39510
|
||||
Uint8List base64decodeUnpadded(String s) {
|
||||
final needEquals = (4 - (s.length % 4)) % 4;
|
||||
return base64.decode(s + ('=' * needEquals));
|
||||
}
|
||||
606
lib/encryption/utils/bootstrap.dart
Normal file
606
lib/encryption/utils/bootstrap.dart
Normal file
|
|
@ -0,0 +1,606 @@
|
|||
/*
|
||||
* Famedly Matrix SDK
|
||||
* Copyright (C) 2020, 2021 Famedly GmbH
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as
|
||||
* published by the Free Software Foundation, either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import 'dart:convert';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:canonical_json/canonical_json.dart';
|
||||
import 'package:olm/olm.dart' as olm;
|
||||
|
||||
import '../encryption.dart';
|
||||
import '../ssss.dart';
|
||||
import '../key_manager.dart';
|
||||
import '../../matrix.dart';
|
||||
import 'base64_unpadded.dart';
|
||||
|
||||
enum BootstrapState {
|
||||
/// Is loading.
|
||||
loading,
|
||||
|
||||
/// Existing SSSS found, should we wipe it?
|
||||
askWipeSsss,
|
||||
|
||||
/// Ask if an existing SSSS should be userDeviceKeys
|
||||
askUseExistingSsss,
|
||||
|
||||
/// Ask to unlock all the SSSS keys
|
||||
askUnlockSsss,
|
||||
|
||||
/// SSSS is in a bad state, continue with potential dataloss?
|
||||
askBadSsss,
|
||||
|
||||
/// Ask for new SSSS key / passphrase
|
||||
askNewSsss,
|
||||
|
||||
/// Open an existing SSSS key
|
||||
openExistingSsss,
|
||||
|
||||
/// Ask if cross signing should be wiped
|
||||
askWipeCrossSigning,
|
||||
|
||||
/// Ask if cross signing should be set up
|
||||
askSetupCrossSigning,
|
||||
|
||||
/// Ask if online key backup should be wiped
|
||||
askWipeOnlineKeyBackup,
|
||||
|
||||
/// Ask if the online key backup should be set up
|
||||
askSetupOnlineKeyBackup,
|
||||
|
||||
/// An error has been occured.
|
||||
error,
|
||||
|
||||
/// done
|
||||
done,
|
||||
}
|
||||
|
||||
/// Bootstrapping SSSS and cross-signing
|
||||
class Bootstrap {
|
||||
final Encryption encryption;
|
||||
Client get client => encryption.client;
|
||||
void Function()? onUpdate;
|
||||
BootstrapState get state => _state;
|
||||
BootstrapState _state = BootstrapState.loading;
|
||||
Map<String, OpenSSSS>? oldSsssKeys;
|
||||
OpenSSSS? newSsssKey;
|
||||
Map<String, String>? secretMap;
|
||||
|
||||
Bootstrap({required this.encryption, this.onUpdate}) {
|
||||
if (analyzeSecrets().isNotEmpty) {
|
||||
state = BootstrapState.askWipeSsss;
|
||||
} else {
|
||||
state = BootstrapState.askNewSsss;
|
||||
}
|
||||
}
|
||||
|
||||
// cache the secret analyzing so that we don't drop stuff a different client sets during bootstrapping
|
||||
Map<String, Set<String>>? _secretsCache;
|
||||
Map<String, Set<String>> analyzeSecrets() {
|
||||
final secretsCache = _secretsCache;
|
||||
if (secretsCache != null) {
|
||||
// deep-copy so that we can do modifications
|
||||
final newSecrets = <String, Set<String>>{};
|
||||
for (final s in secretsCache.entries) {
|
||||
newSecrets[s.key] = Set<String>.from(s.value);
|
||||
}
|
||||
return newSecrets;
|
||||
}
|
||||
final secrets = <String, Set<String>>{};
|
||||
for (final entry in client.accountData.entries) {
|
||||
final type = entry.key;
|
||||
final event = entry.value;
|
||||
if (!(event.content['encrypted'] is Map)) {
|
||||
continue;
|
||||
}
|
||||
final validKeys = <String>{};
|
||||
final invalidKeys = <String>{};
|
||||
for (final keyEntry in event.content['encrypted'].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();
|
||||
}
|
||||
|
||||
Set<String> badSecrets() {
|
||||
final secrets = analyzeSecrets();
|
||||
secrets.removeWhere((k, v) => v.isNotEmpty);
|
||||
return Set<String>.from(secrets.keys);
|
||||
}
|
||||
|
||||
String mostUsedKey(Map<String, Set<String>> secrets) {
|
||||
final usage = <String, int>{};
|
||||
for (final keys in secrets.values) {
|
||||
for (final key in keys) {
|
||||
usage.update(key, (i) => i + 1, ifAbsent: () => 1);
|
||||
}
|
||||
}
|
||||
final entriesList = usage.entries.toList();
|
||||
entriesList.sort((a, b) => a.value.compareTo(b.value));
|
||||
return entriesList.first.key;
|
||||
}
|
||||
|
||||
Set<String> allNeededKeys() {
|
||||
final secrets = analyzeSecrets();
|
||||
secrets.removeWhere(
|
||||
(k, v) => v.isEmpty); // we don't care about the failed secrets here
|
||||
final keys = <String>{};
|
||||
final defaultKeyId = encryption.ssss.defaultKeyId;
|
||||
final removeKey = (String key) {
|
||||
final sizeBefore = secrets.length;
|
||||
secrets.removeWhere((k, v) => v.contains(key));
|
||||
return sizeBefore - secrets.length;
|
||||
};
|
||||
// first we want to try the default key id
|
||||
if (defaultKeyId != null) {
|
||||
if (removeKey(defaultKeyId) > 0) {
|
||||
keys.add(defaultKeyId);
|
||||
}
|
||||
}
|
||||
// now we re-try as long as we have keys for all secrets
|
||||
while (secrets.isNotEmpty) {
|
||||
final key = mostUsedKey(secrets);
|
||||
removeKey(key);
|
||||
keys.add(key);
|
||||
}
|
||||
return keys;
|
||||
}
|
||||
|
||||
void wipeSsss(bool wipe) {
|
||||
if (state != BootstrapState.askWipeSsss) {
|
||||
throw BootstrapBadStateException('Wrong State');
|
||||
}
|
||||
if (wipe) {
|
||||
state = BootstrapState.askNewSsss;
|
||||
} else if (encryption.ssss.defaultKeyId != null &&
|
||||
encryption.ssss.isKeyValid(encryption.ssss.defaultKeyId!)) {
|
||||
state = BootstrapState.askUseExistingSsss;
|
||||
} else if (badSecrets().isNotEmpty) {
|
||||
state = BootstrapState.askBadSsss;
|
||||
} else {
|
||||
migrateOldSsss();
|
||||
}
|
||||
}
|
||||
|
||||
void useExistingSsss(bool use) {
|
||||
if (state != BootstrapState.askUseExistingSsss) {
|
||||
throw BootstrapBadStateException('Wrong State');
|
||||
}
|
||||
if (use) {
|
||||
try {
|
||||
newSsssKey = encryption.ssss.open(encryption.ssss.defaultKeyId);
|
||||
state = BootstrapState.openExistingSsss;
|
||||
} catch (e, s) {
|
||||
Logs().e('[Bootstrapping] Error open SSSS', e, s);
|
||||
state = BootstrapState.error;
|
||||
return;
|
||||
}
|
||||
} else if (badSecrets().isNotEmpty) {
|
||||
state = BootstrapState.askBadSsss;
|
||||
} else {
|
||||
migrateOldSsss();
|
||||
}
|
||||
}
|
||||
|
||||
void ignoreBadSecrets(bool ignore) {
|
||||
if (state != BootstrapState.askBadSsss) {
|
||||
throw BootstrapBadStateException('Wrong State');
|
||||
}
|
||||
if (ignore) {
|
||||
migrateOldSsss();
|
||||
} else {
|
||||
// that's it, folks. We can't do anything here
|
||||
state = BootstrapState.error;
|
||||
}
|
||||
}
|
||||
|
||||
void migrateOldSsss() {
|
||||
final keys = allNeededKeys();
|
||||
final oldSsssKeys = this.oldSsssKeys = {};
|
||||
try {
|
||||
for (final key in keys) {
|
||||
oldSsssKeys[key] = encryption.ssss.open(key);
|
||||
}
|
||||
} catch (e, s) {
|
||||
Logs().e('[Bootstrapping] Error construction ssss key', e, s);
|
||||
state = BootstrapState.error;
|
||||
return;
|
||||
}
|
||||
state = BootstrapState.askUnlockSsss;
|
||||
}
|
||||
|
||||
void unlockedSsss() {
|
||||
if (state != BootstrapState.askUnlockSsss) {
|
||||
throw BootstrapBadStateException('Wrong State');
|
||||
}
|
||||
state = BootstrapState.askNewSsss;
|
||||
}
|
||||
|
||||
Future<void> newSsss([String? passphrase]) async {
|
||||
if (state != BootstrapState.askNewSsss) {
|
||||
throw BootstrapBadStateException('Wrong State');
|
||||
}
|
||||
state = BootstrapState.loading;
|
||||
try {
|
||||
Logs().v('Create key...');
|
||||
newSsssKey = await encryption.ssss.createKey(passphrase);
|
||||
if (oldSsssKeys != null) {
|
||||
// alright, we have to re-encrypt old secrets with the new key
|
||||
final secrets = analyzeSecrets();
|
||||
final 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 = <String, String>{};
|
||||
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 updatedAccountData = client.onSync.stream.firstWhere((syncUpdate) =>
|
||||
syncUpdate.accountData != null &&
|
||||
syncUpdate.accountData!.any((accountData) =>
|
||||
accountData.type == EventTypes.SecretStorageDefaultKey));
|
||||
await encryption.ssss.setDefaultKeyId(newSsssKey!.keyId);
|
||||
await updatedAccountData;
|
||||
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;
|
||||
return;
|
||||
}
|
||||
// alright, we successfully migrated all secrets, if needed
|
||||
|
||||
checkCrossSigning();
|
||||
}
|
||||
|
||||
Future<void> openExistingSsss() async {
|
||||
final newSsssKey = this.newSsssKey;
|
||||
if (state != BootstrapState.openExistingSsss || newSsssKey == null) {
|
||||
throw BootstrapBadStateException();
|
||||
}
|
||||
if (!newSsssKey.isUnlocked) {
|
||||
throw BootstrapBadStateException('Key not unlocked');
|
||||
}
|
||||
Logs().v('Maybe cache all...');
|
||||
await newSsssKey.maybeCacheAll();
|
||||
checkCrossSigning();
|
||||
}
|
||||
|
||||
void checkCrossSigning() {
|
||||
// so, let's see if we have cross signing set up
|
||||
if (encryption.crossSigning.enabled) {
|
||||
// cross signing present, ask for wipe
|
||||
state = BootstrapState.askWipeCrossSigning;
|
||||
return;
|
||||
}
|
||||
// no cross signing present
|
||||
state = BootstrapState.askSetupCrossSigning;
|
||||
}
|
||||
|
||||
void wipeCrossSigning(bool wipe) {
|
||||
if (state != BootstrapState.askWipeCrossSigning) {
|
||||
throw BootstrapBadStateException();
|
||||
}
|
||||
if (wipe) {
|
||||
state = BootstrapState.askSetupCrossSigning;
|
||||
} else {
|
||||
checkOnlineKeyBackup();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> askSetupCrossSigning(
|
||||
{bool setupMasterKey = false,
|
||||
bool setupSelfSigningKey = false,
|
||||
bool setupUserSigningKey = false}) async {
|
||||
if (state != BootstrapState.askSetupCrossSigning) {
|
||||
throw BootstrapBadStateException();
|
||||
}
|
||||
if (!setupMasterKey && !setupSelfSigningKey && !setupUserSigningKey) {
|
||||
checkOnlineKeyBackup();
|
||||
return;
|
||||
}
|
||||
final userID = client.userID!;
|
||||
try {
|
||||
Uint8List masterSigningKey;
|
||||
final secretsToStore = <String, String>{};
|
||||
MatrixCrossSigningKey? masterKey;
|
||||
MatrixCrossSigningKey? selfSigningKey;
|
||||
MatrixCrossSigningKey? userSigningKey;
|
||||
String? masterPub;
|
||||
if (setupMasterKey) {
|
||||
final master = olm.PkSigning();
|
||||
try {
|
||||
masterSigningKey = master.generate_seed();
|
||||
masterPub = master.init_with_seed(masterSigningKey);
|
||||
final json = <String, dynamic>{
|
||||
'user_id': userID,
|
||||
'usage': ['master'],
|
||||
'keys': <String, dynamic>{
|
||||
'ed25519:$masterPub': masterPub,
|
||||
},
|
||||
};
|
||||
masterKey = MatrixCrossSigningKey.fromJson(json);
|
||||
secretsToStore[EventTypes.CrossSigningMasterKey] =
|
||||
base64.encode(masterSigningKey);
|
||||
} finally {
|
||||
master.free();
|
||||
}
|
||||
} else {
|
||||
Logs().v('Get stored key...');
|
||||
masterSigningKey = base64decodeUnpadded(
|
||||
await newSsssKey?.getStored(EventTypes.CrossSigningMasterKey) ??
|
||||
'');
|
||||
if (masterSigningKey.isEmpty) {
|
||||
// no master signing key :(
|
||||
throw BootstrapBadStateException('No master key');
|
||||
}
|
||||
final master = olm.PkSigning();
|
||||
try {
|
||||
masterPub = master.init_with_seed(masterSigningKey);
|
||||
} finally {
|
||||
master.free();
|
||||
}
|
||||
}
|
||||
final _sign = (Map<String, dynamic> object) {
|
||||
final keyObj = olm.PkSigning();
|
||||
try {
|
||||
keyObj.init_with_seed(masterSigningKey);
|
||||
return keyObj
|
||||
.sign(String.fromCharCodes(canonicalJson.encode(object)));
|
||||
} finally {
|
||||
keyObj.free();
|
||||
}
|
||||
};
|
||||
if (setupSelfSigningKey) {
|
||||
final selfSigning = olm.PkSigning();
|
||||
try {
|
||||
final selfSigningPriv = selfSigning.generate_seed();
|
||||
final selfSigningPub = selfSigning.init_with_seed(selfSigningPriv);
|
||||
final json = <String, dynamic>{
|
||||
'user_id': userID,
|
||||
'usage': ['self_signing'],
|
||||
'keys': <String, dynamic>{
|
||||
'ed25519:$selfSigningPub': selfSigningPub,
|
||||
},
|
||||
};
|
||||
final signature = _sign(json);
|
||||
json['signatures'] = <String, dynamic>{
|
||||
userID: <String, dynamic>{
|
||||
'ed25519:$masterPub': signature,
|
||||
},
|
||||
};
|
||||
selfSigningKey = MatrixCrossSigningKey.fromJson(json);
|
||||
secretsToStore[EventTypes.CrossSigningSelfSigning] =
|
||||
base64.encode(selfSigningPriv);
|
||||
} finally {
|
||||
selfSigning.free();
|
||||
}
|
||||
}
|
||||
if (setupUserSigningKey) {
|
||||
final userSigning = olm.PkSigning();
|
||||
try {
|
||||
final userSigningPriv = userSigning.generate_seed();
|
||||
final userSigningPub = userSigning.init_with_seed(userSigningPriv);
|
||||
final json = <String, dynamic>{
|
||||
'user_id': userID,
|
||||
'usage': ['user_signing'],
|
||||
'keys': <String, dynamic>{
|
||||
'ed25519:$userSigningPub': userSigningPub,
|
||||
},
|
||||
};
|
||||
final signature = _sign(json);
|
||||
json['signatures'] = <String, dynamic>{
|
||||
userID: <String, dynamic>{
|
||||
'ed25519:$masterPub': signature,
|
||||
},
|
||||
};
|
||||
userSigningKey = MatrixCrossSigningKey.fromJson(json);
|
||||
secretsToStore[EventTypes.CrossSigningUserSigning] =
|
||||
base64.encode(userSigningPriv);
|
||||
} finally {
|
||||
userSigning.free();
|
||||
}
|
||||
}
|
||||
// upload the keys!
|
||||
state = BootstrapState.loading;
|
||||
Logs().v('Upload device signing keys.');
|
||||
await client.uiaRequestBackground(
|
||||
(AuthenticationData? auth) => client.uploadCrossSigningKeys(
|
||||
masterKey: masterKey,
|
||||
selfSigningKey: selfSigningKey,
|
||||
userSigningKey: userSigningKey,
|
||||
auth: auth,
|
||||
));
|
||||
Logs().v('Device signing keys have been uploaded.');
|
||||
// aaaand set the SSSS secrets
|
||||
final futures = <Future<void>>[];
|
||||
if (masterKey != null) {
|
||||
futures.add(
|
||||
client.onSync.stream
|
||||
.firstWhere((syncUpdate) =>
|
||||
masterKey?.publicKey != null &&
|
||||
client.userDeviceKeys[client.userID]?.masterKey?.ed25519Key ==
|
||||
masterKey?.publicKey)
|
||||
.then((_) => Logs().v('New Master Key was created')),
|
||||
);
|
||||
}
|
||||
for (final entry in secretsToStore.entries) {
|
||||
futures.add(
|
||||
client.onSync.stream
|
||||
.firstWhere((syncUpdate) =>
|
||||
syncUpdate.accountData != null &&
|
||||
syncUpdate.accountData!
|
||||
.any((accountData) => accountData.type == entry.key))
|
||||
.then((_) =>
|
||||
Logs().v('New Key with type ${entry.key} was created')),
|
||||
);
|
||||
Logs().v('Store new SSSS key ${entry.key}...');
|
||||
await newSsssKey?.store(entry.key, entry.value);
|
||||
}
|
||||
Logs().v(
|
||||
'Wait for MasterKey and ${secretsToStore.entries.length} keys to be created');
|
||||
await Future.wait<void>(futures);
|
||||
final keysToSign = <SignableKey>[];
|
||||
if (masterKey != null) {
|
||||
if (client.userDeviceKeys[client.userID]?.masterKey?.ed25519Key !=
|
||||
masterKey.publicKey) {
|
||||
throw BootstrapBadStateException(
|
||||
'ERROR: New master key does not match up!');
|
||||
}
|
||||
Logs().v('Set own master key to verified...');
|
||||
await client.userDeviceKeys[client.userID]!.masterKey!
|
||||
.setVerified(true, false);
|
||||
keysToSign.add(client.userDeviceKeys[client.userID]!.masterKey!);
|
||||
}
|
||||
if (selfSigningKey != null) {
|
||||
keysToSign.add(
|
||||
client.userDeviceKeys[client.userID]!.deviceKeys[client.deviceID]!);
|
||||
}
|
||||
Logs().v('Sign ourself...');
|
||||
await encryption.crossSigning.sign(keysToSign);
|
||||
} catch (e, s) {
|
||||
Logs().e('[Bootstrapping] Error setting up cross signing', e, s);
|
||||
state = BootstrapState.error;
|
||||
return;
|
||||
}
|
||||
|
||||
checkOnlineKeyBackup();
|
||||
}
|
||||
|
||||
void checkOnlineKeyBackup() {
|
||||
// check if we have online key backup set up
|
||||
if (encryption.keyManager.enabled) {
|
||||
state = BootstrapState.askWipeOnlineKeyBackup;
|
||||
return;
|
||||
}
|
||||
state = BootstrapState.askSetupOnlineKeyBackup;
|
||||
}
|
||||
|
||||
void wipeOnlineKeyBackup(bool wipe) {
|
||||
if (state != BootstrapState.askWipeOnlineKeyBackup) {
|
||||
throw BootstrapBadStateException();
|
||||
}
|
||||
if (wipe) {
|
||||
state = BootstrapState.askSetupOnlineKeyBackup;
|
||||
} else {
|
||||
state = BootstrapState.done;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> askSetupOnlineKeyBackup(bool setup) async {
|
||||
if (state != BootstrapState.askSetupOnlineKeyBackup) {
|
||||
throw BootstrapBadStateException();
|
||||
}
|
||||
if (!setup) {
|
||||
state = BootstrapState.done;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
final keyObj = olm.PkDecryption();
|
||||
String pubKey;
|
||||
Uint8List privKey;
|
||||
try {
|
||||
pubKey = keyObj.generate_key();
|
||||
privKey = keyObj.get_private_key();
|
||||
} finally {
|
||||
keyObj.free();
|
||||
}
|
||||
Logs().v('Create the new backup version...');
|
||||
await client.postRoomKeysVersion(
|
||||
BackupAlgorithm.mMegolmBackupV1Curve25519AesSha2,
|
||||
<String, dynamic>{
|
||||
'public_key': pubKey,
|
||||
},
|
||||
);
|
||||
Logs().v('Store the secret...');
|
||||
await newSsssKey?.store(megolmKey, base64.encode(privKey));
|
||||
Logs().v(
|
||||
'And finally set all megolm keys as needing to be uploaded again...');
|
||||
await client.database?.markInboundGroupSessionsAsNeedingUpload();
|
||||
} catch (e, s) {
|
||||
Logs().e('[Bootstrapping] Error setting up online key backup', e, s);
|
||||
state = BootstrapState.error;
|
||||
encryption.client.onEncryptionError.add(
|
||||
SdkError(exception: e, stackTrace: s),
|
||||
);
|
||||
return;
|
||||
}
|
||||
state = BootstrapState.done;
|
||||
}
|
||||
|
||||
set state(BootstrapState newState) {
|
||||
Logs().v('BootstrapState: $newState');
|
||||
if (state != BootstrapState.error) {
|
||||
_state = newState;
|
||||
}
|
||||
|
||||
onUpdate?.call();
|
||||
}
|
||||
}
|
||||
|
||||
class BootstrapBadStateException implements Exception {
|
||||
String cause;
|
||||
BootstrapBadStateException([this.cause = 'Bad state']);
|
||||
|
||||
@override
|
||||
String toString() => 'BootstrapBadStateException: $cause';
|
||||
}
|
||||
50
lib/encryption/utils/json_signature_check_extension.dart
Normal file
50
lib/encryption/utils/json_signature_check_extension.dart
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
/*
|
||||
* Famedly Matrix SDK
|
||||
* Copyright (C) 2020, 2021 Famedly GmbH
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as
|
||||
* published by the Free Software Foundation, either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import 'package:canonical_json/canonical_json.dart';
|
||||
import 'package:olm/olm.dart' as olm;
|
||||
|
||||
import '../../matrix.dart';
|
||||
|
||||
extension JsonSignatureCheckExtension on Map<String, dynamic> {
|
||||
/// Checks the signature of a signed json object.
|
||||
bool checkJsonSignature(String key, String userId, String deviceId) {
|
||||
final signatures = this['signatures'];
|
||||
if (signatures == null ||
|
||||
!(signatures is Map<String, dynamic>) ||
|
||||
!signatures.containsKey(userId)) return false;
|
||||
remove('unsigned');
|
||||
remove('signatures');
|
||||
if (!signatures[userId].containsKey('ed25519:$deviceId')) return false;
|
||||
final String signature = signatures[userId]['ed25519:$deviceId'];
|
||||
final canonical = canonicalJson.encode(this);
|
||||
final message = String.fromCharCodes(canonical);
|
||||
var isValid = false;
|
||||
final olmutil = olm.Utility();
|
||||
try {
|
||||
olmutil.ed25519_verify(key, message, signature);
|
||||
isValid = true;
|
||||
} catch (e, s) {
|
||||
isValid = false;
|
||||
Logs().w('[LibOlm] Signature check failed', e, s);
|
||||
} finally {
|
||||
olmutil.free();
|
||||
}
|
||||
return isValid;
|
||||
}
|
||||
}
|
||||
1242
lib/encryption/utils/key_verification.dart
Normal file
1242
lib/encryption/utils/key_verification.dart
Normal file
File diff suppressed because it is too large
Load diff
61
lib/encryption/utils/olm_session.dart
Normal file
61
lib/encryption/utils/olm_session.dart
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
/*
|
||||
* Famedly Matrix SDK
|
||||
* Copyright (C) 2020, 2021 Famedly GmbH
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as
|
||||
* published by the Free Software Foundation, either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import 'package:olm/olm.dart' as olm;
|
||||
|
||||
import '../../matrix.dart';
|
||||
|
||||
class OlmSession {
|
||||
String identityKey;
|
||||
String? sessionId;
|
||||
olm.Session? session;
|
||||
DateTime? lastReceived;
|
||||
final String key;
|
||||
String? get pickledSession => session?.pickle(key);
|
||||
|
||||
bool get isValid => session != null;
|
||||
|
||||
OlmSession({
|
||||
required this.key,
|
||||
required this.identityKey,
|
||||
required this.sessionId,
|
||||
required this.session,
|
||||
required this.lastReceived,
|
||||
});
|
||||
|
||||
OlmSession.fromJson(Map<String, dynamic> dbEntry, String key)
|
||||
: key = key,
|
||||
identityKey = dbEntry['identity_key'] ?? '' {
|
||||
session = olm.Session();
|
||||
try {
|
||||
session!.unpickle(key, dbEntry['pickle']);
|
||||
sessionId = dbEntry['session_id'];
|
||||
lastReceived =
|
||||
DateTime.fromMillisecondsSinceEpoch(dbEntry['last_received'] ?? 0);
|
||||
assert(sessionId == session!.session_id());
|
||||
} catch (e, s) {
|
||||
Logs().e('[LibOlm] Could not unpickle olm session', e, s);
|
||||
dispose();
|
||||
}
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
session?.free();
|
||||
session = null;
|
||||
}
|
||||
}
|
||||
72
lib/encryption/utils/outbound_group_session.dart
Normal file
72
lib/encryption/utils/outbound_group_session.dart
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
/*
|
||||
* Famedly Matrix SDK
|
||||
* Copyright (C) 2019, 2020, 2021 Famedly GmbH
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as
|
||||
* published by the Free Software Foundation, either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:olm/olm.dart' as olm;
|
||||
|
||||
import '../../matrix.dart';
|
||||
|
||||
class OutboundGroupSession {
|
||||
/// The devices is a map from user id to device id to if the device is blocked.
|
||||
/// This way we can easily know if a new user is added, leaves, a new devices is added, and,
|
||||
/// very importantly, if we block a device. These are all important for determining if/when
|
||||
/// an outbound session needs to be rotated.
|
||||
Map<String, Map<String, bool>> devices = {};
|
||||
// Default to a date, that would get this session rotated in any case to make handling easier
|
||||
DateTime creationTime = DateTime.fromMillisecondsSinceEpoch(0);
|
||||
olm.OutboundGroupSession? outboundGroupSession;
|
||||
int? get sentMessages => outboundGroupSession?.message_index();
|
||||
bool get isValid => outboundGroupSession != null;
|
||||
final String key;
|
||||
|
||||
OutboundGroupSession(
|
||||
{required this.devices,
|
||||
required this.creationTime,
|
||||
required this.outboundGroupSession,
|
||||
required this.key});
|
||||
|
||||
OutboundGroupSession.fromJson(Map<String, dynamic> dbEntry, String key)
|
||||
: key = key {
|
||||
try {
|
||||
for (final entry in json.decode(dbEntry['device_ids']).entries) {
|
||||
devices[entry.key] = Map<String, bool>.from(entry.value);
|
||||
}
|
||||
} catch (e) {
|
||||
// devices is bad (old data), so just not use this session
|
||||
Logs().i(
|
||||
'[OutboundGroupSession] Session in database is old, not using it. ' +
|
||||
e.toString());
|
||||
return;
|
||||
}
|
||||
outboundGroupSession = olm.OutboundGroupSession();
|
||||
try {
|
||||
outboundGroupSession!.unpickle(key, dbEntry['pickle']);
|
||||
creationTime =
|
||||
DateTime.fromMillisecondsSinceEpoch(dbEntry['creation_time']);
|
||||
} catch (e, s) {
|
||||
dispose();
|
||||
Logs().e('[LibOlm] Unable to unpickle outboundGroupSession', e, s);
|
||||
}
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
outboundGroupSession?.free();
|
||||
outboundGroupSession = null;
|
||||
}
|
||||
}
|
||||
115
lib/encryption/utils/session_key.dart
Normal file
115
lib/encryption/utils/session_key.dart
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
/*
|
||||
* Famedly Matrix SDK
|
||||
* Copyright (C) 2019, 2020, 2021 Famedly GmbH
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as
|
||||
* published by the Free Software Foundation, either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import 'package:matrix/encryption/utils/stored_inbound_group_session.dart';
|
||||
import 'package:matrix_api_lite/src/utils/filter_map_extension.dart';
|
||||
import 'package:olm/olm.dart' as olm;
|
||||
|
||||
import '../../matrix.dart';
|
||||
|
||||
class SessionKey {
|
||||
/// The raw json content of the key
|
||||
Map<String, dynamic> content = <String, dynamic>{};
|
||||
|
||||
/// Map of stringified-index to event id, so that we can detect replay attacks
|
||||
Map<String, String> indexes;
|
||||
|
||||
/// Map of userId to map of deviceId to index, that we know that device receivied, e.g. sending it ourself.
|
||||
/// Used for automatically answering key requests
|
||||
Map<String, Map<String, int>> allowedAtIndex;
|
||||
|
||||
/// Underlying olm [InboundGroupSession] object
|
||||
olm.InboundGroupSession? inboundGroupSession;
|
||||
|
||||
/// Key for libolm pickle / unpickle
|
||||
final String key;
|
||||
|
||||
/// Forwarding keychain
|
||||
List<String> get forwardingCurve25519KeyChain =>
|
||||
(content['forwarding_curve25519_key_chain'] != null
|
||||
? List<String>.from(content['forwarding_curve25519_key_chain'])
|
||||
: null) ??
|
||||
<String>[];
|
||||
|
||||
/// Claimed keys of the original sender
|
||||
late Map<String, String> senderClaimedKeys;
|
||||
|
||||
/// Sender curve25519 key
|
||||
String senderKey;
|
||||
|
||||
/// Is this session valid?
|
||||
bool get isValid => inboundGroupSession != null;
|
||||
|
||||
/// roomId for this session
|
||||
String roomId;
|
||||
|
||||
/// Id of this session
|
||||
String sessionId;
|
||||
|
||||
SessionKey(
|
||||
{required this.content,
|
||||
required this.inboundGroupSession,
|
||||
required this.key,
|
||||
Map<String, String>? indexes,
|
||||
Map<String, Map<String, int>>? allowedAtIndex,
|
||||
required this.roomId,
|
||||
required this.sessionId,
|
||||
required this.senderKey,
|
||||
required this.senderClaimedKeys})
|
||||
: indexes = indexes ?? <String, String>{},
|
||||
allowedAtIndex = allowedAtIndex ?? <String, Map<String, int>>{};
|
||||
|
||||
SessionKey.fromDb(StoredInboundGroupSession dbEntry, String key)
|
||||
: key = key,
|
||||
content = Event.getMapFromPayload(dbEntry.content),
|
||||
indexes = Event.getMapFromPayload(dbEntry.indexes)
|
||||
.catchMap((k, v) => MapEntry<String, String>(k, v)),
|
||||
allowedAtIndex = Event.getMapFromPayload(dbEntry.allowedAtIndex)
|
||||
.catchMap((k, v) => MapEntry(k, Map<String, int>.from(v))),
|
||||
roomId = dbEntry.roomId,
|
||||
sessionId = dbEntry.sessionId,
|
||||
senderKey = dbEntry.senderKey,
|
||||
inboundGroupSession = olm.InboundGroupSession() {
|
||||
final parsedSenderClaimedKeys =
|
||||
Event.getMapFromPayload(dbEntry.senderClaimedKeys)
|
||||
.catchMap((k, v) => MapEntry<String, String>(k, v));
|
||||
// we need to try...catch as the map used to be <String, int> and that will throw an error.
|
||||
senderClaimedKeys = (parsedSenderClaimedKeys.isNotEmpty)
|
||||
? parsedSenderClaimedKeys
|
||||
: (content['sender_claimed_keys'] is Map
|
||||
? content['sender_claimed_keys']
|
||||
.catchMap((k, v) => MapEntry<String, String>(k, v))
|
||||
: (content['sender_claimed_ed25519_key'] is String
|
||||
? <String, String>{
|
||||
'ed25519': content['sender_claimed_ed25519_key']
|
||||
}
|
||||
: <String, String>{}));
|
||||
|
||||
try {
|
||||
inboundGroupSession!.unpickle(key, dbEntry.pickle);
|
||||
} catch (e, s) {
|
||||
dispose();
|
||||
Logs().e('[LibOlm] Unable to unpickle inboundGroupSession', e, s);
|
||||
}
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
inboundGroupSession?.free();
|
||||
inboundGroupSession = null;
|
||||
}
|
||||
}
|
||||
40
lib/encryption/utils/ssss_cache.dart
Normal file
40
lib/encryption/utils/ssss_cache.dart
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
/*
|
||||
* Famedly Matrix SDK
|
||||
* Copyright (C) 2021 Famedly GmbH
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as
|
||||
* published by the Free Software Foundation, either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
class SSSSCache {
|
||||
final String? type;
|
||||
final String? keyId;
|
||||
final String? ciphertext;
|
||||
final String? content;
|
||||
|
||||
const SSSSCache({this.type, this.keyId, this.ciphertext, this.content});
|
||||
|
||||
factory SSSSCache.fromJson(Map<String, dynamic> json) => SSSSCache(
|
||||
type: json['type'],
|
||||
keyId: json['key_id'],
|
||||
ciphertext: json['ciphertext'],
|
||||
content: json['content'],
|
||||
);
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'type': type,
|
||||
'key_id': keyId,
|
||||
'ciphertext': ciphertext,
|
||||
'content': content,
|
||||
};
|
||||
}
|
||||
66
lib/encryption/utils/stored_inbound_group_session.dart
Normal file
66
lib/encryption/utils/stored_inbound_group_session.dart
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
/*
|
||||
* Famedly Matrix SDK
|
||||
* Copyright (C) 2021 Famedly GmbH
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as
|
||||
* published by the Free Software Foundation, either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
class StoredInboundGroupSession {
|
||||
final String roomId;
|
||||
final String sessionId;
|
||||
final String pickle;
|
||||
final String content;
|
||||
final String indexes;
|
||||
final String allowedAtIndex;
|
||||
final bool uploaded;
|
||||
final String senderKey;
|
||||
final String senderClaimedKeys;
|
||||
|
||||
StoredInboundGroupSession({
|
||||
required this.roomId,
|
||||
required this.sessionId,
|
||||
required this.pickle,
|
||||
required this.content,
|
||||
required this.indexes,
|
||||
required this.allowedAtIndex,
|
||||
required this.uploaded,
|
||||
required this.senderKey,
|
||||
required this.senderClaimedKeys,
|
||||
});
|
||||
|
||||
factory StoredInboundGroupSession.fromJson(Map<String, dynamic> json) =>
|
||||
StoredInboundGroupSession(
|
||||
roomId: json['room_id'],
|
||||
sessionId: json['session_id'],
|
||||
pickle: json['pickle'],
|
||||
content: json['content'],
|
||||
indexes: json['indexes'],
|
||||
allowedAtIndex: json['allowed_at_index'],
|
||||
uploaded: json['uploaded'],
|
||||
senderKey: json['sender_key'],
|
||||
senderClaimedKeys: json['sender_claimed_keys'],
|
||||
);
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'room_id': roomId,
|
||||
'session_id': sessionId,
|
||||
'pickle': pickle,
|
||||
'content': content,
|
||||
'indexes': indexes,
|
||||
'allowed_at_index': allowedAtIndex,
|
||||
'uploaded': uploaded,
|
||||
'sender_key': senderKey,
|
||||
'sender_claimed_keys': senderClaimedKeys,
|
||||
};
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue