Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Uptake public key hash changes #1440

Merged
merged 10 commits into from
Dec 18, 2024
Merged
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
import 'package:at_chops/at_chops.dart';
import 'package:at_client/src/client/at_client_spec.dart';
import 'package:at_client/src/decryption_service/decryption.dart';
import 'package:at_client/src/response/default_response_parser.dart';
import 'package:at_client/src/util/encryption_util.dart';
import 'package:at_commons/at_builders.dart';
import 'package:at_commons/at_commons.dart';
import 'package:at_utils/at_logger.dart';
import 'package:at_chops/at_chops.dart';

/// Class responsible for decrypting the value of shared key's that are not owned
/// by currentAtSign
Expand Down Expand Up @@ -50,14 +50,26 @@ class SharedKeyDecryption implements AtKeyDecryption {
intent: Intent.fetchEncryptionPublicKey,
exceptionScenario: ExceptionScenario.localVerbExecutionFailed);
}
if (currentAtSignPublicKey != null &&
(atKey.metadata.pubKeyCS != null &&
atKey.metadata.pubKeyCS !=
EncryptionUtil.md5CheckSum(currentAtSignPublicKey))) {
if (currentAtSignPublicKey.isNullOrEmpty) {
throw AtPublicKeyNotFoundException('Public key cannot be null or empty');
}

final isPubKeyHashMismatch = atKey.metadata.pubKeyHash != null &&
atKey.metadata.pubKeyHash?.hash !=
AtChops.hashWith(HashingAlgoType.fromString(
atKey.metadata.pubKeyHash!.hashingAlgo))
.hash(currentAtSignPublicKey!.codeUnits);

final isPubKeyCSMismatch = atKey.metadata.pubKeyCS != null &&
atKey.metadata.pubKeyCS !=
EncryptionUtil.md5CheckSum(currentAtSignPublicKey!);

if (isPubKeyHashMismatch || isPubKeyCSMismatch) {
throw AtPublicKeyChangeException(
'Public key has changed. Cannot decrypt shared key ${atKey.toString()}',
intent: Intent.fetchEncryptionPublicKey,
exceptionScenario: ExceptionScenario.decryptionFailed);
'Public key has changed. Cannot decrypt shared key ${atKey.toString()}',
intent: Intent.fetchEncryptionPublicKey,
exceptionScenario: ExceptionScenario.decryptionFailed,
);
}

AtEncryptionResult decryptionResultFromAtChops;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import 'package:at_chops/at_chops.dart';
import 'package:at_client/at_client.dart';
import 'package:at_client/src/client/secondary.dart';
import 'package:at_client/src/encryption_service/encryption.dart';
Expand All @@ -9,7 +10,6 @@ import 'package:at_commons/at_builders.dart';
import 'package:at_persistence_secondary_server/at_persistence_secondary_server.dart';
import 'package:at_utils/at_logger.dart';
import 'package:meta/meta.dart';
import 'package:at_chops/at_chops.dart';

/// Contains the common code for [SharedKeyEncryption] and [StreamEncryption]
abstract class AbstractAtKeyEncryption implements AtKeyEncryption {
Expand Down Expand Up @@ -49,8 +49,15 @@ abstract class AbstractAtKeyEncryption implements AtKeyEncryption {

if (storeSharedKeyEncryptedWithData) {
atKey.metadata.sharedKeyEnc = theirEncryptedSymmetricKeyCopy;
// This is a legacy checksum with MD5 algo.
atKey.metadata.pubKeyCS =
EncryptionUtil.md5CheckSum(await _getSharedWithPublicKey(atKey));
// Hashed the encryption public key with sha512. This is to ensure the encryption
// public key of the receiver are same during encryption and decryption process.
String hash = await AtChops.hashWith(HashingAlgoType.sha512)
.hash((await _getSharedWithPublicKey(atKey)).codeUnits);
atKey.metadata.pubKeyHash =
PublicKeyHash(hash, HashingAlgoType.sha512.name);
}
}

Expand Down
11 changes: 11 additions & 0 deletions packages/at_client/lib/src/response/at_notification.dart
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import 'dart:convert';

import 'package:at_client/at_client.dart';

class AtNotification {
Expand Down Expand Up @@ -34,6 +36,15 @@ class AtNotification {
metadata.skeEncAlgo =
json['metadata'][AtConstants.sharedKeyEncryptedEncryptingAlgo];
metadata.sharedKeyEnc = json['metadata'][AtConstants.sharedKeyEncrypted];
// AtContants.sharedWithPublicKeyHash will be sent by the server starting v3.0.52
// Notifications received from Secondary server before 3.0.52 does not contain
// AtConstants.sharedWithPublicKeyHash. Therefore, check for null String.
if (json['metadata'][AtConstants.sharedWithPublicKeyHash] != "null") {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This seems bad

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sure Gary, will revisit the code and modify the IF condition.

Copy link
Member Author

@sitaram-kalluri sitaram-kalluri Dec 13, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In the MonitorVerbHandler.dart file of at_secondary_server, the pubKeyHash object is converted into a JSON-encoded string and sent to the client. When the pubKeyHash value is null, the jsonEncode method returns a string with the value "null", which causes metadata.pubKeyHash to be set to "null" instead of null.

Code snippet from MonitorVerbHandler.dart:

"pubKeyHash": jsonEncode(atNotification.atMetadata?.pubKeyHash?.toJson())

To ensure that metadata.publicKeyHash contains a null object on the client side, modify the code in MonitorVerbHandler.dart as follows:

notification.metadata?.putIfAbsent(
          "pubKeyHash",
          () => (atNotification.atMetadata?.pubKeyHash != null)
              ? jsonEncode(atNotification.atMetadata?.pubKeyHash?.toJson())
              : null);

@gkc : Let me know if this approach looks good or suggest an alternate approach please.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yep looks fine. Please add unit test(s) to cover

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sure, Thanks Gary.

var publicKeyHash =
jsonDecode(json['metadata'][AtConstants.sharedWithPublicKeyHash]);
metadata.pubKeyHash =
PublicKeyHash(publicKeyHash['hash'], publicKeyHash['hashingAlgo']);
}
}

return AtNotification(json['id'], json['key'], json['from'], json['to'],
Expand Down
20 changes: 20 additions & 0 deletions packages/at_client/lib/src/service/sync_service_impl.dart
Original file line number Diff line number Diff line change
Expand Up @@ -741,6 +741,13 @@ class SyncServiceImpl implements SyncService, AtSignChangeListener {
if (metadata.pubKeyCS != null) {
metadataStr += ':pubKeyCS:${metadata.pubKeyCS}';
}
if (metadata.pubKeyHash != null) {
metadataStr +=
':${AtConstants.sharedWithPublicKeyHash}:${metadata.pubKeyHash?.hash}';
metadataStr +=
':${AtConstants.sharedWithPublicKeyHashingAlgo}:${metadata.pubKeyHash?.hashingAlgo}';
}

if (metadata.encoding != null) {
metadataStr += ':encoding:${metadata.encoding}';
}
Expand Down Expand Up @@ -963,6 +970,12 @@ class SyncServiceImpl implements SyncService, AtSignChangeListener {
builder.atKey.metadata.pubKeyCS =
metaData[AtConstants.sharedWithPublicKeyCheckSum];
}
if (metaData[AtConstants.sharedWithPublicKeyHash] != null) {
Map pubKeyHash =
jsonDecode(metaData[AtConstants.sharedWithPublicKeyHash]);
builder.atKey.metadata.pubKeyHash =
PublicKeyHash(pubKeyHash['hash'], pubKeyHash['hashingAlgo']);
}
if (metaData[AtConstants.encoding] != null) {
builder.atKey.metadata.encoding = metaData[AtConstants.encoding];
}
Expand All @@ -984,6 +997,13 @@ class SyncServiceImpl implements SyncService, AtSignChangeListener {
builder.atKey.metadata.skeEncAlgo =
metaData[AtConstants.sharedKeyEncryptedEncryptingAlgo];
}

if (metaData[AtConstants.sharedWithPublicKeyHash] != null &&
metaData[AtConstants.sharedWithPublicKeyHashingAlgo] != null) {
builder.atKey.metadata.pubKeyHash = PublicKeyHash(
metaData[AtConstants.sharedWithPublicKeyHash],
metaData[AtConstants.sharedWithPublicKeyHashingAlgo]);
}
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,13 @@

import 'dart:async';

import 'package:at_client/src/encryption_service/encryption.dart';
import 'package:at_client/src/preference/at_client_preference.dart';
import 'package:at_client/src/service/notification_service.dart';
import 'package:at_client/src/transformer/at_transformer.dart';
import 'package:at_client/src/util/at_client_util.dart';
import 'package:at_commons/at_builders.dart';
import 'package:at_commons/at_commons.dart';
import 'package:at_client/src/encryption_service/encryption.dart';

/// Class is responsible for taking the [NotificationParams] and converting into [NotifyVerbBuilder]
class NotificationRequestTransformer
Expand Down Expand Up @@ -96,6 +96,8 @@ class NotificationRequestTransformer
notificationParams.atKey.metadata.skeEncKeyName;
builder.atKey.metadata.skeEncAlgo =
notificationParams.atKey.metadata.skeEncAlgo;
builder.atKey.metadata.pubKeyHash =
notificationParams.atKey.metadata.pubKeyHash;
}

Future<String> _encryptNotificationValue(AtKey atKey, String value) async {
Expand Down
2 changes: 2 additions & 0 deletions packages/at_client/lib/src/util/at_client_util.dart
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,8 @@ class AtClientUtil {
metadataMap[AtConstants.sharedKeyEncryptedEncryptingAlgo];
metadata.isPublic = isPublic;
metadata.isCached = isCached;
metadata.pubKeyHash = PublicKeyHash.fromJson(
metadataMap[AtConstants.sharedWithPublicKeyHash]);

return metadata;
}
Expand Down
12 changes: 6 additions & 6 deletions packages/at_client/pubspec.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -31,19 +31,19 @@ dependencies:
async: ^2.9.0
at_utf7: ^1.0.0
at_base2e15: ^1.0.0
at_commons: ^5.0.0
at_commons: ^5.0.2
at_utils: ^3.0.19
at_chops: ^2.0.1
at_chops: ^2.2.0
at_lookup: ^3.0.49
at_auth: ^2.0.7
at_auth: ^2.0.10
at_persistence_spec: ^2.0.14
at_persistence_secondary_server: ^3.0.64
at_persistence_secondary_server: ^3.1.0
meta: ^1.8.0
version: ^3.0.2

dev_dependencies:
lints: ^4.0.0
test: ^1.21.4
lints: ^5.0.0
test: ^1.25.8
at_demo_data: ^1.0.1
coverage: ^1.5.0
mocktail: ^1.0.3
170 changes: 170 additions & 0 deletions packages/at_client/test/encryption_decryption_test.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
import 'dart:io';

import 'package:at_chops/at_chops.dart';
import 'package:at_client/at_client.dart';
import 'package:at_client/src/decryption_service/shared_key_decryption.dart';
import 'package:at_client/src/encryption_service/shared_key_encryption.dart';
import 'package:at_commons/at_builders.dart';
import 'package:mocktail/mocktail.dart';
import 'package:test/test.dart';

class MockRemoteSecondary extends Mock implements RemoteSecondary {}

void main() {
String currentAtSign = '@alice';
String namespace = 'unit.test';

MockRemoteSecondary mockRemoteSecondary = MockRemoteSecondary();

late AtChops atChops;
late AtClient atClient;

setUp(() async {
AtClientPreference atClientPreference = AtClientPreference()
..isLocalStoreRequired = true
..hiveStoragePath = 'test/unit_test_storage/hive'
..commitLogPath = 'test/unit_test_storage/commit';

AtEncryptionKeyPair atEncryptionKeyPair =
AtChopsUtil.generateAtEncryptionKeyPair();
AtPkamKeyPair atPkamKeyPair = AtChopsUtil.generateAtPkamKeyPair();
AtChopsKeys atChopsKeys =
AtChopsKeys.create(atEncryptionKeyPair, atPkamKeyPair);
atChopsKeys.selfEncryptionKey =
AtChopsUtil.generateSymmetricKey(EncryptionKeyType.aes256);
atChops = AtChopsImpl(atChopsKeys);

atClient = await AtClientImpl.create(
currentAtSign, namespace, atClientPreference,
remoteSecondary: mockRemoteSecondary, atChops: atChops);

// During decryption, fetches the encryption public key from local keystore.
// So, store the encryption public key into local secondary keystore.
await atClient.getLocalSecondary()?.putValue(
'public:publickey$currentAtSign',
atEncryptionKeyPair.atPublicKey.publicKey);
});

tearDownAll(() {
Directory('test/unit_test_storage').deleteSync(recursive: true);
});

group('A group of tests related to encryption and decryption of shared keys',
() {
test(
'A test to verify encryption and decryption of shared key is successful - with publicKeyHash',
() async {
registerFallbackValue(LLookupVerbBuilder());
AtKey sharedKey =
(AtKey.shared('email', namespace: namespace, sharedBy: currentAtSign)
..sharedWith('@bob'))
.build();
String value = '[email protected]';

when(() => mockRemoteSecondary
.executeVerb(any(that: EncryptedSharedKeyMatcher())))
.thenAnswer((_) => Future.value('data:null'));

// Returns encryption public key of sharedWith atSign.
// For unit test, reusing the current AtSign encryptionPublicKey.
when(() => mockRemoteSecondary
.executeVerb(any(that: EncryptionPublicKeyMatcher())))
.thenAnswer((_) => Future.value(
atChops.atChopsKeys.atEncryptionKeyPair?.atPublicKey.publicKey));

// Encryption
SharedKeyEncryption sharedKeyEncryption = SharedKeyEncryption(atClient);
String encryptedValue =
await sharedKeyEncryption.encrypt(sharedKey, value);
expect(sharedKey.metadata.pubKeyHash?.hash.isNotEmpty, true);
expect(sharedKey.metadata.pubKeyHash?.hashingAlgo, 'sha512');
expect(sharedKey.metadata.sharedKeyEnc?.isNotEmpty, true);
expect(sharedKey.metadata.pubKeyCS?.isNotEmpty, true);

// Explicitly setting pubKeyCS to null, so that pubKeyHash will only be used
// during decryption.
sharedKey.metadata.pubKeyCS = null;
expect(sharedKey.metadata.pubKeyCS, null);

// Decryption
SharedKeyDecryption sharedKeyDecryption = SharedKeyDecryption(atClient);
String decryptedValue =
await sharedKeyDecryption.decrypt(sharedKey, encryptedValue);
expect(decryptedValue, value);
});

test('A test to verify exception is thrown when publicKeyHash mismatch',
() async {
registerFallbackValue(LLookupVerbBuilder());
AtKey sharedKey =
(AtKey.shared('email', namespace: namespace, sharedBy: currentAtSign)
..sharedWith('@bob'))
.build();
String value = '[email protected]';

when(() => mockRemoteSecondary
.executeVerb(any(that: EncryptedSharedKeyMatcher())))
.thenAnswer((_) => Future.value('data:null'));

// Returns encryption public key of sharedWith atSign.
// For unit test, reusing the current AtSign encryptionPublicKey.
when(() => mockRemoteSecondary
.executeVerb(any(that: EncryptionPublicKeyMatcher())))
.thenAnswer((_) => Future.value(
atChops.atChopsKeys.atEncryptionKeyPair?.atPublicKey.publicKey));

// Encryption
SharedKeyEncryption sharedKeyEncryption = SharedKeyEncryption(atClient);
String encryptedValue =
await sharedKeyEncryption.encrypt(sharedKey, value);
expect(sharedKey.metadata.pubKeyHash?.hash.isNotEmpty, true);
expect(sharedKey.metadata.pubKeyHash?.hashingAlgo, 'sha512');
expect(sharedKey.metadata.sharedKeyEnc?.isNotEmpty, true);
expect(sharedKey.metadata.pubKeyCS?.isNotEmpty, true);

// Explicitly setting pubKeyCS to null, so that pubKeyHash will only be used
// during decryption.
sharedKey.metadata.pubKeyCS = null;
expect(sharedKey.metadata.pubKeyCS, null);
// Explicity changing the publicKeyHash value to mimic change in publicKeyHash
// value.
sharedKey.metadata.pubKeyHash =
PublicKeyHash('dummy_hash_value', HashingAlgoType.sha512.name);

// Decryption
SharedKeyDecryption sharedKeyDecryption = SharedKeyDecryption(atClient);
expect(
() async =>
await sharedKeyDecryption.decrypt(sharedKey, encryptedValue),
throwsA(predicate((dynamic e) =>
e is AtPublicKeyChangeException &&
e.message ==
'Public key has changed. Cannot decrypt shared key ${sharedKey.toString()}')));
});
});
}

class EncryptedSharedKeyMatcher extends Matcher {
@override
Description describe(Description description) {
return description;
}

@override
bool matches(item, Map matchState) {
return item.atKey.key.startsWith(AtConstants.atEncryptionSharedKey);
}
}

class EncryptionPublicKeyMatcher extends Matcher {
@override
Description describe(Description description) {
// TODO: implement describe
throw UnimplementedError();
}

@override
bool matches(item, Map matchState) {
return item.atKey.key.startsWith('publickey');
}
}
2 changes: 1 addition & 1 deletion tests/at_end2end_test/pubspec.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,6 @@ dependencies:
path: ../../packages/at_client

dev_dependencies:
test: ^1.24.3
test: ^1.25.8
lints: ^2.0.0
coverage: ^1.5.0
4 changes: 2 additions & 2 deletions tests/at_functional_test/pubspec.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,6 @@ dependencies:
at_lookup: ^3.0.49

dev_dependencies:
test: ^1.24.3
lints: ^2.0.0
test: ^1.25.8
lints: ^5.0.0
coverage: ^1.5.0
Loading
Loading