diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml index d06526571..ee8c1c249 100644 --- a/.github/workflows/dependency-review.yml +++ b/.github/workflows/dependency-review.yml @@ -24,4 +24,4 @@ jobs: - name: 'Checkout Repository' uses: actions/checkout@3df4ab11eba7bda6032a0b82a6bb43b11571feac # v4.0.0 - name: 'Dependency Review' - uses: actions/dependency-review-action@f6fff72a3217f580d5afd49a46826795305b63c7 # v3.0.8 + uses: actions/dependency-review-action@6c5ccdad469c9f8a2996bfecaec55a631a347034 # v3.1.0 diff --git a/packages/at_secondary_server/config/config.yaml b/packages/at_secondary_server/config/config.yaml index fabcabd2e..4c1cac706 100644 --- a/packages/at_secondary_server/config/config.yaml +++ b/packages/at_secondary_server/config/config.yaml @@ -149,3 +149,9 @@ sync: #IMPORTANT NOTE : please set testingMode to true only if you know what you're doing. Set to false when not testing testing: testingMode: false + +# APKAM enrollment configurations +enrollment: + # The maximum time in hours for an enrollment to expire, beyond which any action on enrollment is forbidden. + # Default values is 48 hours. + expiryInHours: 48 \ No newline at end of file diff --git a/packages/at_secondary_server/lib/src/server/at_secondary_config.dart b/packages/at_secondary_server/lib/src/server/at_secondary_config.dart index 65a4f9ec6..5b004bb75 100644 --- a/packages/at_secondary_server/lib/src/server/at_secondary_config.dart +++ b/packages/at_secondary_server/lib/src/server/at_secondary_config.dart @@ -44,17 +44,22 @@ class AtSecondaryConfig { //Notification static const bool _autoNotify = true; + // The maximum number of retries for a notification. static const int _maxNotificationRetries = 30; + // The quarantine duration of an atsign. Notifications will be retried max_retries times, every quarantineDuration seconds approximately. static const int _notificationQuarantineDuration = 10; + // The notifications queue will be processed every jobFrequency seconds. However, the notifications queue will always be processed // *immediately* when a new notification is queued. When that happens, the queue processing will not run again until jobFrequency // seconds have passed since the last queue-processing run completed. static const int _notificationJobFrequency = 11; + // The time interval(in seconds) to notify latest commitID to monitor connections // To disable to the feature, set to -1. static const int _statsNotificationJobTimeInterval = 15; + // defines the time after which a notification expires in units of minutes. Notifications expire after 1440 minutes or 24 hours by default. static const int _notificationExpiresAfterMins = 1440; @@ -116,10 +121,14 @@ class AtSecondaryConfig { ? ConfigUtil.getPubspecConfig()!['version'] : null; + static final int _enrollmentExpiryInHours = 48; + static final Map _envVars = Platform.environment; static String? get secondaryServerVersion => _secondaryServerVersion; + static int get enrollmentExpiryInHours => _enrollmentExpiryInHours; + // TODO: Medium priority: Most (all?) getters in this class return a default value but the signatures currently // allow for nulls. Should fix this as has been done for logLevel // TODO: Low priority: Lots of very similar boilerplate code here. Not necessarily bad in this particular case, but diff --git a/packages/at_secondary_server/lib/src/verb/handler/enroll_verb_handler.dart b/packages/at_secondary_server/lib/src/verb/handler/enroll_verb_handler.dart index f18a017fb..5f4d7bd0b 100644 --- a/packages/at_secondary_server/lib/src/verb/handler/enroll_verb_handler.dart +++ b/packages/at_secondary_server/lib/src/verb/handler/enroll_verb_handler.dart @@ -3,13 +3,16 @@ import 'dart:collection'; import 'dart:convert'; import 'package:at_commons/at_commons.dart'; import 'package:at_secondary/src/connection/inbound/inbound_connection_metadata.dart'; +import 'package:at_secondary/src/server/at_secondary_config.dart'; import 'package:at_secondary/src/server/at_secondary_impl.dart'; import 'package:at_secondary/src/constants/enroll_constants.dart'; import 'package:at_secondary/src/enroll/enroll_datastore_value.dart'; import 'package:at_secondary/src/utils/notification_util.dart'; +import 'package:at_secondary/src/utils/secondary_util.dart'; import 'package:at_secondary/src/verb/handler/otp_verb_handler.dart'; import 'package:at_server_spec/at_server_spec.dart'; import 'package:at_server_spec/at_verb_spec.dart'; +import 'package:meta/meta.dart'; import 'package:uuid/uuid.dart'; import 'abstract_verb_handler.dart'; import 'package:at_persistence_secondary_server/at_persistence_secondary_server.dart'; @@ -26,6 +29,10 @@ class EnrollVerbHandler extends AbstractVerbHandler { @override Verb getVerb() => enrollVerb; + @visibleForTesting + int enrollmentExpiryInMills = + Duration(hours: AtSecondaryConfig.enrollmentExpiryInHours).inMilliseconds; + @override Future processVerb( Response response, @@ -119,7 +126,9 @@ class EnrollVerbHandler extends AbstractVerbHandler { enrollParams.appName!, enrollParams.deviceName!, enrollParams.apkamPublicKey!); - + enrollmentValue.namespaces = enrollNamespaces; + enrollmentValue.requestType = EnrollRequestType.newEnrollment; + AtData enrollData; if (atConnection.getMetaData().authType != null && atConnection.getMetaData().authType == AuthType.cram) { // auto approve request from connection that is CRAM authenticated. @@ -137,15 +146,19 @@ class EnrollVerbHandler extends AbstractVerbHandler { // The keys with AT_PKAM_PUBLIC_KEY does not sync to client. await keyStore.put( AT_PKAM_PUBLIC_KEY, AtData()..data = enrollParams.apkamPublicKey!); + enrollData = AtData()..data = jsonEncode(enrollmentValue.toJson()); } else { enrollmentValue.approval = EnrollApproval(EnrollStatus.pending.name); await _storeNotification(key, enrollParams, currentAtSign); responseJson['status'] = 'pending'; + enrollData = AtData() + ..data = jsonEncode(enrollmentValue.toJson()) + // Set TTL to the pending enrollments. + // The enrollments will expire after configured + // expiry limit, beyond which any action (approve/deny/revoke) on an + // enrollment is forbidden + ..metaData = (AtMetaData()..ttl = enrollmentExpiryInMills); } - - enrollmentValue.namespaces = enrollNamespaces; - enrollmentValue.requestType = EnrollRequestType.newEnrollment; - AtData enrollData = AtData()..data = jsonEncode(enrollmentValue.toJson()); logger.finer('enrollData: $enrollData'); await keyStore.put('$key$currentAtSign', enrollData, skipCommit: true); } @@ -160,39 +173,51 @@ class EnrollVerbHandler extends AbstractVerbHandler { String? operation, Map responseJson) async { final enrollmentIdFromParams = enrollParams.enrollmentId; - var key = + String enrollmentKey = '$enrollmentIdFromParams.$newEnrollmentKeyPattern.$enrollManageNamespace'; - logger.finer('key: $key$currentAtSign'); - var enrollData; - try { - enrollData = await keyStore.get('$key$currentAtSign'); - } on KeyNotFoundException { - throw AtEnrollmentException( - 'enrollment id: $enrollmentIdFromParams not found in keystore'); - } - if (enrollData != null) { - final existingAtData = enrollData.data; - var enrollDataStoreValue = - EnrollDataStoreValue.fromJson(jsonDecode(existingAtData)); + logger.finer( + 'Enrollment key: $enrollmentKey$currentAtSign | Enrollment operation: $operation'); + // Fetch and returns enrollment data from the keystore. + // Throw AtEnrollmentException, IF + // 1. Enrollment key is not present in keystore + // 2. Enrollment key is not active + AtData enrollData = await _fetchEnrollmentDataFromKeyStore( + enrollmentKey, currentAtSign, enrollmentIdFromParams); + var enrollDataStoreValue = + EnrollDataStoreValue.fromJson(jsonDecode(enrollData.data!)); - enrollDataStoreValue.approval!.state = - _getEnrollStatusEnum(operation).name; - responseJson['status'] = _getEnrollStatusEnum(operation).name; - AtData updatedEnrollData = AtData() - ..data = jsonEncode(enrollDataStoreValue.toJson()); - await keyStore.put('$key$currentAtSign', updatedEnrollData, - skipCommit: true); - // when enrollment is approved store the apkamPublicKey of the enrollment - if (operation == 'approve') { - var apkamPublicKeyInKeyStore = - 'public:${enrollDataStoreValue.appName}.${enrollDataStoreValue.deviceName}.pkam.$pkamNamespace.__public_keys$currentAtSign'; - var valueJson = {}; - valueJson[apkamPublicKey] = enrollDataStoreValue.apkamPublicKey; - var atData = AtData()..data = jsonEncode(valueJson); - await keyStore.put(apkamPublicKeyInKeyStore, atData); - await _storeEncryptionKeys( - enrollmentIdFromParams!, enrollParams, currentAtSign); - } + // Verifies whether the enrollment state matches the intended state + // Throws AtEnrollmentException, if the enrollment state is different from + // the intended state + _verifyEnrollmentStateBeforeAction(operation, enrollDataStoreValue); + enrollDataStoreValue.approval!.state = _getEnrollStatusEnum(operation).name; + responseJson['status'] = _getEnrollStatusEnum(operation).name; + + // If an enrollment is approved, we need the enrollment to be active + // to subsequently revoke the enrollment. Hence reset TTL and + // expiredAt on metadata. + /* TODO: Currently TTL is reset on all the enrollments. + However, if the enrollment state is denied or revoked, + unless we wanted to display denied or revoked enrollments in the UI, + we can let the TTL be, so that the enrollment will be deleted subsequently.*/ + await keyStore.put( + '$enrollmentKey$currentAtSign', + AtData() + ..data = jsonEncode(enrollDataStoreValue.toJson()) + ..metaData = (enrollData.metaData + ?..ttl = 0 + ..expiresAt = null), + skipCommit: true); + // when enrollment is approved store the apkamPublicKey of the enrollment + if (operation == 'approve') { + var apkamPublicKeyInKeyStore = + 'public:${enrollDataStoreValue.appName}.${enrollDataStoreValue.deviceName}.pkam.$pkamNamespace.__public_keys$currentAtSign'; + var valueJson = {}; + valueJson[apkamPublicKey] = enrollDataStoreValue.apkamPublicKey; + var atData = AtData()..data = jsonEncode(valueJson); + await keyStore.put(apkamPublicKeyInKeyStore, atData); + await _storeEncryptionKeys( + enrollmentIdFromParams!, enrollParams, currentAtSign); } responseJson['enrollmentId'] = enrollmentIdFromParams; } @@ -311,4 +336,40 @@ class EnrollVerbHandler extends AbstractVerbHandler { 'Error while storing notification key $enrollmentId. Error $e. Trace $trace'); } } + + Future _fetchEnrollmentDataFromKeyStore( + String enrollmentKey, currentAtSign, String? enrollmentId) async { + AtData enrollData; + // KeyStore.get will not return null. If the value is null, keyStore.get + // throws KeyNotFoundException. + // So, enrollData will NOT be null. + try { + enrollData = await keyStore.get('$enrollmentKey$currentAtSign'); + } on KeyNotFoundException { + throw AtEnrollmentException( + 'enrollment id: $enrollmentId not found in keystore'); + } + // If enrollment is not active, throw AtEnrollmentException + if (!SecondaryUtil.isActiveKey(enrollData)) { + throw AtEnrollmentException('The enrollment $enrollmentId is expired'); + } + return enrollData; + } + + /// Verifies whether the enrollment state matches the intended state. + /// Throws AtEnrollmentException: If the enrollment state is different + /// from the intended state. + void _verifyEnrollmentStateBeforeAction( + String? operation, EnrollDataStoreValue enrollDataStoreValue) { + if (operation == 'approve' && + enrollDataStoreValue.approval!.state != EnrollStatus.pending.name) { + throw AtEnrollmentException( + 'Cannot approve a ${enrollDataStoreValue.approval!.state} enrollment. Only pending enrollments can be approved'); + } + if (operation == 'revoke' && + enrollDataStoreValue.approval!.state != EnrollStatus.approved.name) { + throw AtEnrollmentException( + 'Cannot revoke a ${enrollDataStoreValue.approval!.state} enrollment. Only approved enrollments can be revoked'); + } + } } diff --git a/packages/at_secondary_server/pubspec.yaml b/packages/at_secondary_server/pubspec.yaml index 964bda93b..b40be29b3 100644 --- a/packages/at_secondary_server/pubspec.yaml +++ b/packages/at_secondary_server/pubspec.yaml @@ -29,7 +29,7 @@ dependencies: intl: ^0.18.1 json_annotation: ^4.8.0 version: 3.0.2 - meta: 1.9.1 + meta: 1.10.0 mutex: 3.0.1 yaml: 3.1.2 logging: 1.2.0 diff --git a/packages/at_secondary_server/test/enroll_verb_test.dart b/packages/at_secondary_server/test/enroll_verb_test.dart index 04f0efb1b..bad17ebbb 100644 --- a/packages/at_secondary_server/test/enroll_verb_test.dart +++ b/packages/at_secondary_server/test/enroll_verb_test.dart @@ -220,7 +220,6 @@ void main() { var enrollOperationMap = { 'approve': 'approved', 'deny': 'denied', - 'revoke': 'revoked' }; enrollOperationMap.forEach((operation, expectedStatus) { @@ -463,4 +462,248 @@ void main() { }); tearDown(() async => await verbTestsTearDown()); }); + + group('A group of tests related to enrollment request expiry', () { + Response response = Response(); + setUp(() async { + await verbTestsSetUp(); + // Fetch TOTP + String totpCommand = 'otp:get'; + HashMap totpVerbParams = + getVerbParam(VerbSyntax.otp, totpCommand); + OtpVerbHandler otpVerbHandler = OtpVerbHandler(secondaryKeyStore); + inboundConnection.getMetaData().isAuthenticated = true; + await otpVerbHandler.processVerb( + response, totpVerbParams, inboundConnection); + }); + test('A test to verify expired enrollment cannot be approved', () async { + // Enroll a request on an unauthenticated connection which will expire in 1 millisecond + EnrollVerbHandler enrollVerbHandler = + EnrollVerbHandler(secondaryKeyStore); + enrollVerbHandler.enrollmentExpiryInMills = 1; + String enrollmentRequest = + 'enroll:request:{"appName":"wavi","deviceName":"mydevice","namespaces":{"wavi":"r"},"otp":"${response.data}","apkamPublicKey":"dummy_apkam_public_key"}'; + HashMap enrollVerbParams = + getVerbParam(VerbSyntax.enroll, enrollmentRequest); + inboundConnection.getMetaData().isAuthenticated = false; + inboundConnection.getMetaData().sessionID = 'dummy_session_id'; + await enrollVerbHandler.processVerb( + response, enrollVerbParams, inboundConnection); + String enrollmentId = jsonDecode(response.data!)['enrollmentId']; + String status = jsonDecode(response.data!)['status']; + expect(status, 'pending'); + await Future.delayed(Duration(milliseconds: 1)); + //Approve enrollment + String approveEnrollmentCommand = + 'enroll:approve:{"enrollmentId":"$enrollmentId"}'; + enrollVerbParams = + getVerbParam(VerbSyntax.enroll, approveEnrollmentCommand); + inboundConnection.getMetaData().isAuthenticated = true; + inboundConnection.getMetaData().sessionID = 'dummy_session_id'; + expect( + () async => await enrollVerbHandler.processVerb( + response, enrollVerbParams, inboundConnection), + throwsA(predicate((dynamic e) => + e is AtEnrollmentException && + e.message == 'The enrollment $enrollmentId is expired'))); + }); + + test('A test to verify expired enrollment cannot be denied', () async { + // Enroll a request on an unauthenticated connection which will expire in 1 millisecond + EnrollVerbHandler enrollVerbHandler = + EnrollVerbHandler(secondaryKeyStore); + enrollVerbHandler.enrollmentExpiryInMills = 1; + String enrollmentRequest = + 'enroll:request:{"appName":"wavi","deviceName":"mydevice","namespaces":{"wavi":"r"},"otp":"${response.data}","apkamPublicKey":"dummy_apkam_public_key"}'; + HashMap enrollVerbParams = + getVerbParam(VerbSyntax.enroll, enrollmentRequest); + inboundConnection.getMetaData().isAuthenticated = false; + inboundConnection.getMetaData().sessionID = 'dummy_session_id'; + await enrollVerbHandler.processVerb( + response, enrollVerbParams, inboundConnection); + String enrollmentId = jsonDecode(response.data!)['enrollmentId']; + String status = jsonDecode(response.data!)['status']; + expect(status, 'pending'); + //Deny enrollment + await Future.delayed(Duration(milliseconds: 1)); + String approveEnrollmentCommand = + 'enroll:deny:{"enrollmentId":"$enrollmentId"}'; + enrollVerbParams = + getVerbParam(VerbSyntax.enroll, approveEnrollmentCommand); + inboundConnection.getMetaData().isAuthenticated = true; + inboundConnection.getMetaData().sessionID = 'dummy_session_id'; + expect( + () async => await enrollVerbHandler.processVerb( + response, enrollVerbParams, inboundConnection), + throwsA(predicate((dynamic e) => + e is AtEnrollmentException && + e.message == 'The enrollment $enrollmentId is expired'))); + }); + + test('A test to verify TTL on approved enrollment is reset', () async { + // Enroll a request on an unauthenticated connection which will expire in 1 minute + EnrollVerbHandler enrollVerbHandler = + EnrollVerbHandler(secondaryKeyStore); + enrollVerbHandler.enrollmentExpiryInMills = 60000; + String enrollmentRequest = + 'enroll:request:{"appName":"wavi","deviceName":"mydevice","namespaces":{"wavi":"r"},"otp":"${response.data}","apkamPublicKey":"dummy_apkam_public_key"}'; + HashMap enrollVerbParams = + getVerbParam(VerbSyntax.enroll, enrollmentRequest); + inboundConnection.getMetaData().isAuthenticated = false; + inboundConnection.getMetaData().sessionID = 'dummy_session_id'; + await enrollVerbHandler.processVerb( + response, enrollVerbParams, inboundConnection); + String enrollmentId = jsonDecode(response.data!)['enrollmentId']; + String status = jsonDecode(response.data!)['status']; + expect(status, 'pending'); + // Verify TTL is added to the enrollment + AtData? enrollmentData = await secondaryKeyStore.get( + '$enrollmentId.$newEnrollmentKeyPattern.$enrollManageNamespace$alice'); + expect(enrollmentData!.metaData!.expiresAt, isNotNull); + expect(enrollmentData.metaData!.ttl, 60000); + //Approve enrollment + String approveEnrollmentCommand = + 'enroll:approve:{"enrollmentId":"$enrollmentId"}'; + enrollVerbParams = + getVerbParam(VerbSyntax.enroll, approveEnrollmentCommand); + inboundConnection.getMetaData().isAuthenticated = true; + inboundConnection.getMetaData().sessionID = 'dummy_session_id'; + await enrollVerbHandler.processVerb( + response, enrollVerbParams, inboundConnection); + // Verify TTL is reset + enrollmentData = await secondaryKeyStore.get( + '$enrollmentId.$newEnrollmentKeyPattern.$enrollManageNamespace$alice'); + expect(enrollmentData!.metaData!.expiresAt, null); + expect(enrollmentData.metaData!.ttl, 0); + }); + + test( + 'A test to verify TTL is not set for enrollment requested on an authenticated connection', + () async { + EnrollVerbHandler enrollVerbHandler = + EnrollVerbHandler(secondaryKeyStore); + String enrollmentRequest = + 'enroll:request:{"appName":"wavi","deviceName":"mydevice","namespaces":{"wavi":"r"},"otp":"${response.data}","apkamPublicKey":"dummy_apkam_public_key"}'; + HashMap enrollVerbParams = + getVerbParam(VerbSyntax.enroll, enrollmentRequest); + inboundConnection.getMetaData().isAuthenticated = true; + inboundConnection.getMetaData().authType = AuthType.cram; + inboundConnection.getMetaData().sessionID = 'dummy_session_id'; + await enrollVerbHandler.processVerb( + response, enrollVerbParams, inboundConnection); + String enrollmentId = jsonDecode(response.data!)['enrollmentId']; + expect(enrollmentId, isNotNull); + expect(jsonDecode(response.data!)['status'], 'approved'); + // Verify TTL is not set + AtData? enrollmentData = await secondaryKeyStore.get( + '$enrollmentId.$newEnrollmentKeyPattern.$enrollManageNamespace$alice'); + expect(enrollmentData!.metaData!.expiresAt, null); + expect(enrollmentData.metaData!.ttl, null); + }); + tearDown(() async => await verbTestsTearDown()); + }); + + group('A group of tests related to approve enrollment', () { + Response response = Response(); + late String enrollmentId; + late EnrollVerbHandler enrollVerbHandler; + HashMap enrollVerbParams; + setUp(() async { + await verbTestsSetUp(); + // Fetch TOTP + String totpCommand = 'otp:get'; + HashMap totpVerbParams = + getVerbParam(VerbSyntax.otp, totpCommand); + OtpVerbHandler otpVerbHandler = OtpVerbHandler(secondaryKeyStore); + inboundConnection.getMetaData().isAuthenticated = true; + await otpVerbHandler.processVerb( + response, totpVerbParams, inboundConnection); + // Enroll a request on an unauthenticated connection which will expire in 1 minute + enrollVerbHandler = EnrollVerbHandler(secondaryKeyStore); + enrollVerbHandler.enrollmentExpiryInMills = 60000; + String enrollmentRequest = + 'enroll:request:{"appName":"wavi","deviceName":"mydevice","namespaces":{"wavi":"r"},"otp":"${response.data}","apkamPublicKey":"dummy_apkam_public_key"}'; + HashMap enrollVerbParams = + getVerbParam(VerbSyntax.enroll, enrollmentRequest); + inboundConnection.getMetaData().isAuthenticated = false; + inboundConnection.getMetaData().sessionID = 'dummy_session_id'; + await enrollVerbHandler.processVerb( + response, enrollVerbParams, inboundConnection); + enrollmentId = jsonDecode(response.data!)['enrollmentId']; + String status = jsonDecode(response.data!)['status']; + expect(status, 'pending'); + }); + test('A test to verify denied enrollment cannot be approved', () async { + //deny enrollment + String denyEnrollmentCommand = + 'enroll:deny:{"enrollmentId":"$enrollmentId"}'; + enrollVerbParams = getVerbParam(VerbSyntax.enroll, denyEnrollmentCommand); + inboundConnection.getMetaData().isAuthenticated = true; + inboundConnection.getMetaData().sessionID = 'dummy_session_id'; + await enrollVerbHandler.processVerb( + response, enrollVerbParams, inboundConnection); + expect(jsonDecode(response.data!)['enrollmentId'], enrollmentId); + expect(jsonDecode(response.data!)['status'], 'denied'); + //approve enrollment + String approveEnrollmentCommand = + 'enroll:approve:{"enrollmentId":"$enrollmentId"}'; + enrollVerbParams = + getVerbParam(VerbSyntax.enroll, approveEnrollmentCommand); + inboundConnection.getMetaData().isAuthenticated = true; + inboundConnection.getMetaData().sessionID = 'dummy_session_id'; + expect( + () async => await enrollVerbHandler.processVerb( + response, enrollVerbParams, inboundConnection), + throwsA(predicate((dynamic e) => + e is AtEnrollmentException && + e.message == + 'Cannot approve a denied enrollment. Only pending enrollments can be approved'))); + }); + + test('A test to verify revoked enrollment cannot be approved', () async { + //approve enrollment + String approveEnrollmentCommand = + 'enroll:approve:{"enrollmentId":"$enrollmentId"}'; + HashMap approveEnrollVerbParams = + getVerbParam(VerbSyntax.enroll, approveEnrollmentCommand); + inboundConnection.getMetaData().isAuthenticated = true; + inboundConnection.getMetaData().sessionID = 'dummy_session_id'; + await enrollVerbHandler.processVerb( + response, approveEnrollVerbParams, inboundConnection); + expect(jsonDecode(response.data!)['enrollmentId'], enrollmentId); + expect(jsonDecode(response.data!)['status'], 'approved'); + //revoke enrollment + String denyEnrollmentCommand = + 'enroll:revoke:{"enrollmentId":"$enrollmentId"}'; + enrollVerbParams = getVerbParam(VerbSyntax.enroll, denyEnrollmentCommand); + await enrollVerbHandler.processVerb( + response, enrollVerbParams, inboundConnection); + expect(jsonDecode(response.data!)['enrollmentId'], enrollmentId); + expect(jsonDecode(response.data!)['status'], 'revoked'); + // Approved a revoked enrollment throws AtEnrollmentException + expect( + () async => await enrollVerbHandler.processVerb( + response, approveEnrollVerbParams, inboundConnection), + throwsA(predicate((dynamic e) => + e is AtEnrollmentException && + e.message == + 'Cannot approve a revoked enrollment. Only pending enrollments can be approved'))); + }); + + test('A test to verify pending enrollment cannot be revoked', () async { + //revoke enrollment + String denyEnrollmentCommand = + 'enroll:revoke:{"enrollmentId":"$enrollmentId"}'; + enrollVerbParams = getVerbParam(VerbSyntax.enroll, denyEnrollmentCommand); + inboundConnection.getMetaData().isAuthenticated = true; + inboundConnection.getMetaData().sessionID = 'dummy_session_id'; + expect( + () async => await enrollVerbHandler.processVerb( + response, enrollVerbParams, inboundConnection), + throwsA(predicate((dynamic e) => + e is AtEnrollmentException && + e.message == + 'Cannot revoke a pending enrollment. Only approved enrollments can be revoked'))); + }); + }); } diff --git a/tests/at_functional_test/test/enroll_verb_test.dart b/tests/at_functional_test/test/enroll_verb_test.dart index b7eac899e..d77a878c5 100644 --- a/tests/at_functional_test/test/enroll_verb_test.dart +++ b/tests/at_functional_test/test/enroll_verb_test.dart @@ -651,4 +651,116 @@ void main() { }); }); }); + + group('A group of negative tests on enroll verb', () { + late String enrollmentId; + late String enrollmentResponse; + setUp(() async { + // Get TOTP from server + String otp = await _getOTPFromServer(firstAtsign); + await socketConnection1?.close(); + // Close the connection and create a new connection and send an enrollment request on an + // unauthenticated connection. + await _connect(); + String enrollRequest = + 'enroll:request:{"appName":"wavi","deviceName":"pixel","namespaces":{"wavi":"rw"},"otp":"$otp","apkamPublicKey":"${pkamPublicKeyMap[firstAtsign]!}"}'; + await socket_writer(socketConnection1!, enrollRequest); + enrollmentResponse = await read(); + enrollmentResponse = enrollmentResponse.replaceAll('data:', ''); + enrollmentId = jsonDecode(enrollmentResponse)['enrollmentId']; + socketConnection1?.close(); + }); + test( + 'A test to verify error is returned when pending enrollment is revoked', + () async { + // Revoke enrollment on an authenticate connection + await _connect(); + await prepare(socketConnection1!, firstAtsign); + await socket_writer( + socketConnection1!, 'enroll:revoke:{"enrollmentId":"$enrollmentId"}'); + enrollmentResponse = (await read()).replaceAll('error:', ''); + expect(jsonDecode(enrollmentResponse)['errorDescription'], + 'Internal server exception : Cannot revoke a pending enrollment. Only approved enrollments can be revoked'); + }); + + test( + 'A test to verify error is returned when denied enrollment is approved', + () async { + // Deny enrollment on an authenticate connection + await _connect(); + await prepare(socketConnection1!, firstAtsign); + await socket_writer( + socketConnection1!, 'enroll:deny:{"enrollmentId":"$enrollmentId"}'); + enrollmentResponse = (await read()).replaceAll('data:', ''); + expect(jsonDecode(enrollmentResponse)['status'], 'denied'); + expect(jsonDecode(enrollmentResponse)['enrollmentId'], enrollmentId); + // Approve enrollment + await socket_writer( + socketConnection1!, 'enroll:approve:{"enrollmentId":"$enrollmentId"}'); + enrollmentResponse = (await read()).replaceAll('error:', ''); + expect( + jsonDecode(enrollmentResponse)['errorDescription'], + 'Internal server exception : Cannot approve a denied enrollment. ' + 'Only pending enrollments can be approved'); + }); + + test('A test to verify error is returned when denied enrollment is revoked', + () async { + // Deny enrollment on an authenticate connection + await _connect(); + await prepare(socketConnection1!, firstAtsign); + await socket_writer( + socketConnection1!, 'enroll:deny:{"enrollmentId":"$enrollmentId"}'); + enrollmentResponse = (await read()).replaceAll('data:', ''); + expect(jsonDecode(enrollmentResponse)['status'], 'denied'); + expect(jsonDecode(enrollmentResponse)['enrollmentId'], enrollmentId); + // Revoke enrollment + await socket_writer( + socketConnection1!, 'enroll:revoke:{"enrollmentId":"$enrollmentId"}'); + enrollmentResponse = (await read()).replaceAll('error:', ''); + expect( + jsonDecode(enrollmentResponse)['errorDescription'], + 'Internal server exception : Cannot revoke a denied enrollment. ' + 'Only approved enrollments can be revoked'); + }); + + test('A test to verify revoked enrollment cannot be approved', () async { + // Approve enrollment + await _connect(); + await prepare(socketConnection1!, firstAtsign); + await socket_writer( + socketConnection1!, 'enroll:approve:{"enrollmentId":"$enrollmentId"}'); + enrollmentResponse = (await read()).replaceAll('data:', ''); + expect(jsonDecode(enrollmentResponse)['status'], 'approved'); + expect(jsonDecode(enrollmentResponse)['enrollmentId'], enrollmentId); + // Revoke enrollment + await socket_writer( + socketConnection1!, 'enroll:revoke:{"enrollmentId":"$enrollmentId"}'); + enrollmentResponse = (await read()).replaceAll('data:', ''); + expect(jsonDecode(enrollmentResponse)['status'], 'revoked'); + expect(jsonDecode(enrollmentResponse)['enrollmentId'], enrollmentId); + // Approve a revoked enrollment + await socket_writer( + socketConnection1!, 'enroll:approve:{"enrollmentId":"$enrollmentId"}'); + enrollmentResponse = (await read()).replaceAll('error:', ''); + expect( + jsonDecode(enrollmentResponse)['errorDescription'], + 'Internal server exception : Cannot approve a revoked enrollment. ' + 'Only pending enrollments can be approved'); + }); + }); +} + +Future _getOTPFromServer(String atSign) async { + await socket_writer(socketConnection1!, 'from:$atSign'); + var fromResponse = await read(); + fromResponse = fromResponse.replaceAll('data:', ''); + var pkamDigest = generatePKAMDigest(atSign, fromResponse); + await socket_writer(socketConnection1!, 'pkam:$pkamDigest'); + // Calling read to remove the PKAM request from the queue + await read(); + await socket_writer(socketConnection1!, 'otp:get'); + String otp = await read(); + otp = otp.replaceAll('data:', '').trim(); + return otp; } diff --git a/tools/build_virtual_environment/ve_base/Dockerfile b/tools/build_virtual_environment/ve_base/Dockerfile index 6833aebbf..71265ab44 100644 --- a/tools/build_virtual_environment/ve_base/Dockerfile +++ b/tools/build_virtual_environment/ve_base/Dockerfile @@ -1,4 +1,4 @@ -FROM dart:3.1.0@sha256:96d2e5d03b8356c2a7542716ace7dce745971efe1d03888a1d7ecd2e7c1dde36 AS buildimage +FROM dart:3.1.0@sha256:a4aea0628b9feb242c7ff272fd5d46d1b5f8ea2dde32c8e00c28e06f67eaa916 AS buildimage ENV USER_ID=1024 ENV GROUP_ID=1024 WORKDIR /app @@ -17,7 +17,7 @@ RUN \ dart pub update ; \ dart compile exe bin/install_PKAM_Keys.dart -o install_PKAM_Keys -FROM debian:stable-20230904-slim@sha256:94dffd981d305c82c21a6c4da8a579e57586c214243f396568edc057f8eee029 +FROM debian:stable-20230904-slim@sha256:0941f9e9cc96c4106845a381fb6fca98393f5f659f3eba6a64e9f79219165cfc # was debian:stable-20221114-slim USER root