Skip to content

Commit

Permalink
Merge pull request #1731 from atsign-foundation/semi-permanent-pass-c…
Browse files Browse the repository at this point in the history
…odes

feat: Enable OTP verb handler to store Semi permanent passcodes
  • Loading branch information
gkc authored Jan 16, 2024
2 parents 3615667 + 910a6a9 commit 304dc62
Show file tree
Hide file tree
Showing 7 changed files with 323 additions and 5 deletions.
6 changes: 5 additions & 1 deletion packages/at_secondary_server/config/config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -158,4 +158,8 @@ enrollment:
# The maximum number of requests allowed within the time window.
maxRequestsPerTimeFrame: 5
# The duration of the time window in hours.
timeFrameInHours: 1
timeFrameInHours: 1
# The threshold value for the delay interval in seconds.
# If the duration between the last received invalid OTP and the current date-time
# exceeds the delayIntervalThreshold, trigger a reset to the default value.
delayIntervalThreshold: 55
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,8 @@ class AtSecondaryConfig {

static final int _timeFrameInHours = 1;

static final int _enrollmentResponseDelayIntervalInSeconds = 55;

// For easy of testing, duration in hours is long. Hence introduced "timeFrameInMills"
// to have a shorter time frame. This is defaulted to "_timeFrameInHours", can be modified
// via the config verb
Expand Down Expand Up @@ -774,6 +776,18 @@ class AtSecondaryConfig {
_timeFrameInMills = timeWindowInMills;
}

static int get enrollmentResponseDelayIntervalInSeconds {
var result = _getIntEnvVar('enrollmentDelayIntervalThreshold');
if (result != null) {
return result;
}
try {
return getConfigFromYaml(['enrollment', 'delayIntervalThreshold']);
} on ElementNotFoundException {
return _enrollmentResponseDelayIntervalInSeconds;
}
}

//implementation for config:set. This method returns a data stream which subscribers listen to for updates
static Stream<dynamic>? subscribe(ModifiableConfigs configName) {
if (testingMode) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -159,11 +159,24 @@ abstract class AbstractVerbHandler implements VerbHandler {
/// It returns true if the OTP is valid; otherwise, it returns false.
/// If the OTP is not found in the keystore, it also returns false.
///
/// Additionally, this function removes the OTP from the keystore to prevent its reuse.
/// Additionally, this function removes the OTP from the keystore to prevent
/// its reuse.
Future<bool> isOTPValid(String? otp) async {
if (otp == null) {
return false;
}
// Check if user have configured SPP(Semi-Permanent Pass-code).
// If SPP key is available, check if the otp sent is a valid pass code.
// If yes, return true, else check it is a valid OTP.
String sppKey =
'private:spp${AtSecondaryServerImpl.getInstance().currentAtSign}';
if (keyStore.isKeyExists(sppKey)) {
AtData atData = await keyStore.get(sppKey);
if (atData.data?.toLowerCase() == otp.toLowerCase()) {
return true;
}
}
// If SPP is not valid, then check if the provided otp is valid.
String otpKey =
'private:${otp.toLowerCase()}${AtSecondaryServerImpl.getInstance().currentAtSign}';
AtData otpAtData;
Expand All @@ -172,6 +185,12 @@ abstract class AbstractVerbHandler implements VerbHandler {
} on KeyNotFoundException {
return false;
}
return SecondaryUtil.isActiveKey(otpAtData);
bool isOTPValid = SecondaryUtil.isActiveKey(otpAtData);
// Remove the OTP after it is used.
// NOTE: SPP code should NOT be deleted. only OTPs should be
// deleted after use.
await keyStore.remove(otpKey);

return isOTPValid;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,23 @@ import 'package:at_persistence_secondary_server/at_persistence_secondary_server.
class EnrollVerbHandler extends AbstractVerbHandler {
static Enroll enrollVerb = Enroll();

/// Defaulting the initial delay to 1000 milliseconds (1 second).
@visibleForTesting
static int initialDelayInMilliseconds = 1000;

/// A list storing a series of delay intervals for handling invalid OTP series.
/// The series is initially set to [0, [initialDelayInMilliseconds]] and is updated using the Fibonacci sequence.
@visibleForTesting
List<int> delayForInvalidOTPSeries = <int>[0, initialDelayInMilliseconds];

/// The threshold value for the delay interval in milliseconds.
/// When the last delay in '_delayForInvalidOTPSeries' surpasses this threshold,
/// the series is reset to [0, initialDelayInMilliseconds] to prevent excessively long delay intervals.
@visibleForTesting
int enrollmentResponseDelayIntervalInMillis = Duration(
seconds: AtSecondaryConfig.enrollmentResponseDelayIntervalInSeconds)
.inMilliseconds;

EnrollVerbHandler(SecondaryKeyStore keyStore) : super(keyStore);

@override
Expand All @@ -31,6 +48,8 @@ class EnrollVerbHandler extends AbstractVerbHandler {
int enrollmentExpiryInMills =
Duration(hours: AtSecondaryConfig.enrollmentExpiryInHours).inMilliseconds;

int _lastInvalidOtpReceivedInMills = 0;

@override
Future<void> processVerb(
Response response,
Expand Down Expand Up @@ -121,11 +140,25 @@ class EnrollVerbHandler extends AbstractVerbHandler {
if (atConnection.getMetaData().isAuthenticated == false) {
var isValid = await isOTPValid(enrollParams.otp);
if (!isValid) {
_lastInvalidOtpReceivedInMills =
DateTime.now().toUtc().millisecondsSinceEpoch;
await Future.delayed(
Duration(milliseconds: getDelayIntervalInMilliseconds()));
throw AtEnrollmentException(
'invalid otp. Cannot process enroll request');
}
}

// When threshold is met, set "_lastInvalidOtpReceivedInMills" and "delayForInvalidOTPSeries"
// to default values.
if (((DateTime.now().toUtc().millisecondsSinceEpoch) -
_lastInvalidOtpReceivedInMills) >=
enrollmentResponseDelayIntervalInMillis) {
_lastInvalidOtpReceivedInMills = 0;
delayForInvalidOTPSeries.clear();
delayForInvalidOTPSeries.addAll([0, initialDelayInMilliseconds]);
}

var enrollNamespaces = enrollParams.namespaces ?? {};
var newEnrollmentId = Uuid().v4();
var key =
Expand Down Expand Up @@ -395,4 +428,36 @@ class EnrollVerbHandler extends AbstractVerbHandler {
..metaData = enrollMetaData,
skipCommit: true);
}

/// Calculates and returns the delay interval in milliseconds for handling
/// invalid OTP.
///
/// This method updates a series of delays stored in the '_delayForInvalidOTPSeries'
/// list.
/// The delays are calculated based on the Fibonacci sequence. If the last delay in the
/// series surpasses a predefined threshold, the series is reset to default value.
///
/// Returns the calculated delay interval in milliseconds.
@visibleForTesting
int getDelayIntervalInMilliseconds() {
// If the last digit in "delayForInvalidOTPSeries" list reaches the threshold
// (enrollmentResponseDelayIntervalInMillis) then return the same without
// further incrementing the delay.
if (delayForInvalidOTPSeries.last >=
enrollmentResponseDelayIntervalInMillis) {
return delayForInvalidOTPSeries.last;
}
delayForInvalidOTPSeries.add(delayForInvalidOTPSeries.last +
delayForInvalidOTPSeries[delayForInvalidOTPSeries.length - 2]);
delayForInvalidOTPSeries.remove(delayForInvalidOTPSeries.first);

return delayForInvalidOTPSeries.last;
}

/// NOT a part of API. Used for unit tests
@visibleForTesting
int getEnrollmentResponseDelayInMilliseconds() {
return delayForInvalidOTPSeries.last;
}
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import 'dart:collection';
import 'dart:math';
import 'package:at_commons/at_commons.dart';
import 'package:at_secondary/src/connection/inbound/inbound_connection_metadata.dart';
import 'package:at_secondary/src/constants/enroll_constants.dart';
import 'package:at_secondary/src/server/at_secondary_impl.dart';
import 'package:at_server_spec/at_server_spec.dart';
import 'package:meta/meta.dart';
Expand All @@ -18,7 +20,7 @@ class OtpVerbHandler extends AbstractVerbHandler {
OtpVerbHandler(SecondaryKeyStore keyStore) : super(keyStore);

@override
bool accept(String command) => command == 'otp:get';
bool accept(String command) => command.startsWith('otp:');

@override
Verb getVerb() => otpVerb;
Expand Down Expand Up @@ -51,6 +53,20 @@ class OtpVerbHandler extends AbstractVerbHandler {
'${DateTime.now().toUtc().add(Duration(milliseconds: otpExpiryInMills)).millisecondsSinceEpoch}'
..metaData = (AtMetaData()..ttl = otpExpiryInMills));
break;
case 'put':
// Only client connection which has access to __manage access are allowed to store the semi permanent pass codes
if (!(await _isClientAuthorizedToStoreSPP(
atConnection.getMetaData() as InboundConnectionMetadata,
AtSecondaryServerImpl.getInstance().currentAtSign))) {
throw InvalidRequestException(
'Client not allowed to not store semi permanent pass code');
}
String? otp = verbParams['otp'];
await keyStore.put(
'private:spp${AtSecondaryServerImpl.getInstance().currentAtSign}',
AtData()..data = otp);
response.data = 'ok';
break;
default:
throw InvalidSyntaxException('$operation is not a valid operation');
}
Expand Down Expand Up @@ -103,4 +119,27 @@ class OtpVerbHandler extends AbstractVerbHandler {
}
return result;
}

/// Only the connections which have access to the __manage namespace are allowed
/// to store the SPP.
Future<bool> _isClientAuthorizedToStoreSPP(
InboundConnectionMetadata atConnectionMetadata,
String currentAtSign) async {
var enrollmentKey =
'${atConnectionMetadata.enrollmentId}.$newEnrollmentKeyPattern.$enrollManageNamespace$currentAtSign';
var enrollNamespaces =
(await getEnrollDataStoreValue(enrollmentKey)).namespaces;

if (enrollNamespaces.isEmpty) {
logger.finer(
'For the enrollmentId ${atConnectionMetadata.enrollmentId} no namespaces are enrolled. Returning empty list');
return false;
}
// If enrollment namespace contains ".*" return all keys.
if (enrollNamespaces.containsKey(enrollManageNamespace) ||
enrollNamespaces.containsKey(allNamespaces)) {
return true;
}
return false;
}
}
108 changes: 108 additions & 0 deletions packages/at_secondary_server/test/enroll_verb_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -275,6 +275,7 @@ void main() {
expect(jsonDecode(response.data!)['enrollmentId'], enrollmentId);
});
});
tearDown(() async => await verbTestsTearDown());
});
group(
'A group of tests to assert enroll operations cannot performed on unauthenticated connection',
Expand Down Expand Up @@ -750,5 +751,112 @@ void main() {
e.message ==
'Cannot revoke a pending enrollment. Only approved enrollments can be revoked')));
});
tearDown(() async => await verbTestsTearDown());
});

group('A group of test to verify getDelayIntervalInSeconds method', () {
setUp(() async {
await verbTestsSetUp();
});
test(
'A test to verify getDelayIntervalInSeconds return delay in increment order',
() {
EnrollVerbHandler enrollVerbHandler =
EnrollVerbHandler(secondaryKeyStore);

expect(enrollVerbHandler.getDelayIntervalInMilliseconds(), 1000);
expect(enrollVerbHandler.getDelayIntervalInMilliseconds(), 2000);
expect(enrollVerbHandler.getDelayIntervalInMilliseconds(), 3000);
expect(enrollVerbHandler.getDelayIntervalInMilliseconds(), 5000);
expect(enrollVerbHandler.getDelayIntervalInMilliseconds(), 8000);
expect(enrollVerbHandler.getDelayIntervalInMilliseconds(), 13000);
expect(enrollVerbHandler.getDelayIntervalInMilliseconds(), 21000);
expect(enrollVerbHandler.getDelayIntervalInMilliseconds(), 34000);
expect(enrollVerbHandler.getDelayIntervalInMilliseconds(), 55000);
expect(enrollVerbHandler.getDelayIntervalInMilliseconds(), 55000);
expect(enrollVerbHandler.getDelayIntervalInMilliseconds(), 55000);
});

test(
'A test to verify getDelayIntervalInSeconds is reset only after threshold is met',
() async {
EnrollVerbHandler.initialDelayInMilliseconds = 100;
Response response = Response();
EnrollVerbHandler enrollVerbHandler =
EnrollVerbHandler(secondaryKeyStore);
enrollVerbHandler.delayForInvalidOTPSeries = [
0,
EnrollVerbHandler.initialDelayInMilliseconds
];
enrollVerbHandler.enrollmentResponseDelayIntervalInMillis = 500;
inboundConnection.getMetaData().isAuthenticated = false;
inboundConnection.getMetaData().sessionID = 'dummy_session_id';
// First Invalid request
String enrollmentRequest =
'enroll:request:{"appName":"wavi","deviceName":"mydevice","namespaces":{"wavi":"r"},"otp":"123","apkamPublicKey":"dummy_apkam_public_key"}';
HashMap<String, String?> enrollVerbParams =
getVerbParam(VerbSyntax.enroll, enrollmentRequest);
try {
await enrollVerbHandler.processVerb(
response, enrollVerbParams, inboundConnection);
// Do nothing on exception
} on AtEnrollmentException {}
expect(enrollVerbHandler.getEnrollmentResponseDelayInMilliseconds(), 100);
// Second Invalid request and verify the delay response interval is incremented.
enrollmentRequest =
'enroll:request:{"appName":"wavi","deviceName":"mydevice","namespaces":{"wavi":"r"},"otp":"123","apkamPublicKey":"dummy_apkam_public_key"}';
enrollVerbParams = getVerbParam(VerbSyntax.enroll, enrollmentRequest);

try {
await enrollVerbHandler.processVerb(
response, enrollVerbParams, inboundConnection);
} on AtEnrollmentException {}
expect(enrollVerbHandler.getEnrollmentResponseDelayInMilliseconds(), 200);
// Third Invalid request and verify the delay response interval is incremented.
enrollmentRequest =
'enroll:request:{"appName":"wavi","deviceName":"mydevice","namespaces":{"wavi":"r"},"otp":"123","apkamPublicKey":"dummy_apkam_public_key"}';
enrollVerbParams = getVerbParam(VerbSyntax.enroll, enrollmentRequest);
try {
await enrollVerbHandler.processVerb(
response, enrollVerbParams, inboundConnection);
} on AtEnrollmentException {}
expect(enrollVerbHandler.getEnrollmentResponseDelayInMilliseconds(), 300);

// Get OTP and send a valid enrollment request. Verify the delay response is
// not reset because the threshold is not met.
OtpVerbHandler otpVerbHandler = OtpVerbHandler(secondaryKeyStore);
inboundConnection.getMetaData().isAuthenticated = true;
await otpVerbHandler.processVerb(
response, getVerbParam(VerbSyntax.otp, 'otp:get'), inboundConnection);

inboundConnection.getMetaData().isAuthenticated = false;
enrollmentRequest =
'enroll:request:{"appName":"wavi","deviceName":"mydevice","namespaces":{"wavi":"r"},"otp":"${response.data}","apkamPublicKey":"dummy_apkam_public_key"}';
enrollVerbParams = getVerbParam(VerbSyntax.enroll, enrollmentRequest);
await enrollVerbHandler.processVerb(
response, enrollVerbParams, inboundConnection);
Map<String, dynamic> enrollmentResponse = jsonDecode(response.data!);
expect(enrollmentResponse['status'], 'pending');
// When threshold limit is not met, assert the delay interval is not reset.
expect(enrollVerbHandler.getEnrollmentResponseDelayInMilliseconds(), 300);
// Wait for 5 seconds to for threshold to met to reset the delay in response.
await Future.delayed(Duration(milliseconds: 500));
// Get OTP and send a valid Enrollment request
inboundConnection.getMetaData().isAuthenticated = true;
await otpVerbHandler.processVerb(
response, getVerbParam(VerbSyntax.otp, 'otp:get'), inboundConnection);
inboundConnection.getMetaData().isAuthenticated = false;
enrollmentRequest =
'enroll:request:{"appName":"wavi","deviceName":"mydevice","namespaces":{"wavi":"r"},"otp":"${response.data}","apkamPublicKey":"dummy_apkam_public_key"}';
enrollVerbParams = getVerbParam(VerbSyntax.enroll, enrollmentRequest);
await enrollVerbHandler.processVerb(
response, enrollVerbParams, inboundConnection);
enrollmentResponse = jsonDecode(response.data!);
expect(enrollmentResponse['status'], 'pending');
// When threshold limit is met, assert the delay interval is reset.
expect(enrollVerbHandler.getEnrollmentResponseDelayInMilliseconds(),
EnrollVerbHandler.initialDelayInMilliseconds);
});
tearDown(() async => await verbTestsTearDown());
});
}
Loading

0 comments on commit 304dc62

Please sign in to comment.