From 8633f39339769547a89eb9be4900596828a48a18 Mon Sep 17 00:00:00 2001 From: gkc Date: Wed, 6 Mar 2024 08:37:25 +0000 Subject: [PATCH 01/19] chore: AtOnboardingPreference: remove unnecessary overrides of AtCientPreference fields --- .../lib/src/util/at_onboarding_preference.dart | 9 --------- 1 file changed, 9 deletions(-) diff --git a/packages/at_onboarding_cli/lib/src/util/at_onboarding_preference.dart b/packages/at_onboarding_cli/lib/src/util/at_onboarding_preference.dart index b2f3ee1e..e79a8261 100644 --- a/packages/at_onboarding_cli/lib/src/util/at_onboarding_preference.dart +++ b/packages/at_onboarding_cli/lib/src/util/at_onboarding_preference.dart @@ -1,6 +1,5 @@ import 'dart:core'; -import 'package:at_chops/at_chops.dart'; import 'package:at_client/at_client.dart'; import 'package:at_onboarding_cli/src/util/registrar_api_constants.dart'; @@ -12,14 +11,6 @@ class AtOnboardingPreference extends AtClientPreference { @Deprecated('qr_code based cram authentication not supported anymore') String? qrCodePath; - /// signing algorithm to use for pkam authentication - @override - SigningAlgoType signingAlgoType = SigningAlgoType.rsa2048; - - /// hashing algorithm to use for pkam authentication - @override - HashingAlgoType hashingAlgoType = HashingAlgoType.sha256; - PkamAuthMode authMode = PkamAuthMode.keysFile; /// if [authMode] is sim, specify publicKeyId to be read from sim From 8c32f11f5f5803f9cb6cc9558ddcf7ebb243685d Mon Sep 17 00:00:00 2001 From: gkc Date: Tue, 19 Mar 2024 10:51:01 +0000 Subject: [PATCH 02/19] feat: new apkam-aware activate / onboarding CLI: interim commit --- .../at_onboarding_cli/bin/activate_cli.dart | 23 +- .../lib/src/cli/auth_cli.dart | 205 +++++++++++++++++ .../lib/src/cli/cli_args.dart | 213 ++++++++++++++++++ .../lib/src/cli/cli_params_validation.dart | 8 + .../lib/src/util/print_full_parser_usage.dart | 34 +++ packages/at_onboarding_cli/pubspec.yaml | 21 +- 6 files changed, 473 insertions(+), 31 deletions(-) create mode 100644 packages/at_onboarding_cli/lib/src/cli/auth_cli.dart create mode 100644 packages/at_onboarding_cli/lib/src/cli/cli_args.dart create mode 100644 packages/at_onboarding_cli/lib/src/cli/cli_params_validation.dart create mode 100644 packages/at_onboarding_cli/lib/src/util/print_full_parser_usage.dart diff --git a/packages/at_onboarding_cli/bin/activate_cli.dart b/packages/at_onboarding_cli/bin/activate_cli.dart index b02402cd..b0bf24a1 100644 --- a/packages/at_onboarding_cli/bin/activate_cli.dart +++ b/packages/at_onboarding_cli/bin/activate_cli.dart @@ -1,26 +1,7 @@ import 'dart:io'; -import 'package:at_client/at_client.dart'; -import 'package:at_onboarding_cli/src/activate_cli/activate_cli.dart' - as activate_cli; +import 'package:at_onboarding_cli/src/cli/auth_cli.dart' as auth_cli; Future main(List args) async { - try { - await activate_cli.main(args); - } on IllegalArgumentException catch (e) { - stderr.writeln('[Exception] Incorrect arguments provided \nCause:$e'); - exit(1); - } on Exception catch (e) { - stderr.writeln('[Exception] Aborting process with exit code:2 \nCause:$e'); - exit(2); - } on Error catch (e) { - stderr.writeln('[Error] Aborting process with exit code:3\nCause:$e'); - exit(3); - } - // The onboarding_service_impl creates an AtClient instance which will start - // the following services: SyncService, AtClientCommitLogCompaction, - // Monitor connection in NotificationService. - // We do not have an stop method in at_client that stop(s) the services, hence - // to force quit, calling exit method. - exit(0); + exit(await auth_cli.main(args)); } diff --git a/packages/at_onboarding_cli/lib/src/cli/auth_cli.dart b/packages/at_onboarding_cli/lib/src/cli/auth_cli.dart new file mode 100644 index 00000000..742ab4c3 --- /dev/null +++ b/packages/at_onboarding_cli/lib/src/cli/auth_cli.dart @@ -0,0 +1,205 @@ +import 'package:at_auth/at_auth.dart'; +import 'package:at_chops/at_chops.dart'; +import 'package:at_client/at_client.dart'; +import 'package:at_lookup/at_lookup.dart'; +import 'package:at_onboarding_cli/at_onboarding_cli.dart'; +import 'package:args/args.dart'; +import 'package:at_onboarding_cli/src/util/at_onboarding_exceptions.dart'; +import 'package:at_onboarding_cli/src/util/print_full_parser_usage.dart'; +import 'dart:io'; +import 'package:at_utils/at_utils.dart'; +import 'package:meta/meta.dart'; + +import 'cli_args.dart'; +import 'cli_params_validation.dart'; + +final AtSignLogger logger = AtSignLogger(' CLI '); + +Future main(List arguments) async { + ArgParser parser = AuthCliArgs().parser; + + try { + return await _main(parser, arguments); + } on ArgumentError catch (e) { + stderr.writeln('Invalid argument: ${e.message}'); + parser.printAllCommandsUsage(sink: stderr); + return 1; + } catch (e) { + stderr.writeln('Error: $e'); + parser.printAllCommandsUsage(sink: stderr); + return 1; + } +} + +Future _main(ArgParser parser, List arguments) async { + if (arguments.isEmpty) { + parser.printAllCommandsUsage(sink: stderr); + return 0; + } + + final first = arguments.first; + if (first.startsWith('-') && first != '-h' && first != '--help') { + // no command found ... legacy ... insert 'onboard' as the command + arguments = ['onboard', ...arguments]; + } + final AuthCliCommand cliCommand; + try { + cliCommand = AuthCliCommand.values.byName(arguments.first); + } catch (e) { + throw ArgumentError('Unknown command: ${arguments.first}'); + } + + final ArgResults topLevelResults = parser.parse(arguments); + + if (topLevelResults.wasParsed('help')) { + parser.printAllCommandsUsage(sink: stderr); + return 0; + } + + AtSignLogger.defaultLoggingHandler = AtSignLogger.stdErrLoggingHandler; + AtSignLogger.root_level = 'warning'; + + if (topLevelResults.wasParsed('verbose')) { + AtSignLogger.root_level = 'info'; + } + if (topLevelResults.wasParsed('debug')) { + AtSignLogger.root_level = 'finest'; + } + + if (topLevelResults.command == null) { + throw ArgumentError('No command was parsed'); + } + + ArgResults commandArgResults = topLevelResults.command!; + if (commandArgResults.name != cliCommand.name) { + throw ArgumentError('detected command ${cliCommand.name}' + ' but parsed command ${commandArgResults.name} '); + } + + // Execute the command + + logger.info('Chosen command: $cliCommand' + ' with options : ${commandArgResults.arguments}' + ' and positional args : ${commandArgResults.rest}'); + + ArgParser commandParser = parser.commands[cliCommand.name]!; + try { + switch (cliCommand) { + case AuthCliCommand.onboard: + await onboard(commandArgResults); + + case AuthCliCommand.spp: + await setSpp(commandArgResults); + + case AuthCliCommand.enroll: + throw ('$cliCommand not yet implemented'); + + case AuthCliCommand.listEnrollRequests: + throw ('$cliCommand not yet implemented'); + + case AuthCliCommand.approve: + throw ('$cliCommand not yet implemented'); + + case AuthCliCommand.deny: + throw ('$cliCommand not yet implemented'); + + case AuthCliCommand.listEnrollments: + throw ('$cliCommand not yet implemented'); + + case AuthCliCommand.revoke: + throw ('$cliCommand not yet implemented'); + } + } on ArgumentError catch (e) { + stderr.writeln('Argument error for command ${cliCommand.name}: ${e.message}'); + commandParser.printAllCommandsUsage(commandName: 'Usage: ${cliCommand.name}', sink: stderr); + return 1; + } catch (e, st) { + stderr.writeln('Error for command ${cliCommand.name}: $e'); + stderr.writeln(st); + commandParser.printAllCommandsUsage(commandName: 'Usage: ${cliCommand.name}', sink: stderr); + return 1; + } + + return 0; +} + +/// onboard params: atSign, [, cram, atDirectory, atRegistrar] +/// When a cram arg is not supplied, we first use the registrar API +/// to send an OTP to the user and then use that OTP to obtain the cram +/// secret from the registrar. +@visibleForTesting +Future onboard(ArgResults argResults, + {AtOnboardingService? atOnboardingService}) async { + logger.info('Root server is ${argResults['rootServer']}'); + logger.info('Registrar url provided is ${argResults['registrarUrl']}'); + + atOnboardingService ??= createOnboardingService( + atSign: argResults['atsign'], + atDirectoryFqdn: argResults['rootServer'], + atRegistrarFqdn: argResults['registrarUrl'], + cramSecret: argResults['cramkey'], + ); + + stderr.writeln( + '[Information] Activating your atSign. This may take up to 2 minutes.'); + try { + await atOnboardingService.onboard(); + } on InvalidDataException catch (e) { + stderr.writeln( + '[Error] Activation failed. Invalid data provided by user. Please try again\nCause: ${e.message}'); + } on InvalidRequestException catch (e) { + stderr.writeln( + '[Error] Activation failed. Invalid data provided by user. Please try again\nCause: ${e.message}'); + } on AtActivateException catch (e) { + stderr.writeln('[Error] ${e.message}'); + } on Exception catch (e) { + stderr.writeln( + '[Error] Activation failed. It looks like something went wrong on our side.\n' + 'Please try again or contact support@atsign.com\nCause: $e'); + } finally { + await atOnboardingService.close(); + } +} + +@visibleForTesting +Future setSpp( + ArgResults argResults, { + AtOnboardingService? svc, +}) async { + String atSign = argResults['atsign']; + String spp = argResults['spp']; + + atSign = AtUtils.fixAtSign(atSign); + + if (invalidSpp(spp)) { + throw ArgumentError(invalidSppMsg); + } + + svc ??= createOnboardingService( + atSign: argResults['atsign'], + atDirectoryFqdn: argResults['rootServer'], + ); + + // authenticate + await svc.authenticate(); + + AtClient atClient = svc.atClient!; + AtLookUp atLookup = atClient.getRemoteSecondary()!.atLookUp; + AtChops atChops = atClient.atChops!; + AtAuth atAuth = svc.atAuth!; + + // send command 'otp:put:$spp' + String? response = await atLookup.executeCommand('otp:put:$spp\n'); + logger.shout('Server response: $response'); +} + +@visibleForTesting +AtOnboardingService createOnboardingService({required String atSign, String atDirectoryFqdn = AuthCliArgs.defaultAtDirectoryFqdn, String atRegistrarFqdn = AuthCliArgs.defaultAtRegistrarFqdn, String? cramSecret}) { + AtOnboardingPreference atOnboardingPreference = AtOnboardingPreference() + ..rootDomain = atDirectoryFqdn + ..registrarUrl = atRegistrarFqdn + ..cramSecret = cramSecret + ..useAtChops = true; + + return AtOnboardingServiceImpl(atSign, atOnboardingPreference); +} \ No newline at end of file diff --git a/packages/at_onboarding_cli/lib/src/cli/cli_args.dart b/packages/at_onboarding_cli/lib/src/cli/cli_args.dart new file mode 100644 index 00000000..464ac391 --- /dev/null +++ b/packages/at_onboarding_cli/lib/src/cli/cli_args.dart @@ -0,0 +1,213 @@ +import 'package:args/args.dart'; +import 'package:meta/meta.dart'; + +enum AuthCliCommand { + onboard, + spp, + enroll, + listEnrollRequests, + approve, + deny, + listEnrollments, + revoke +} + +class AuthCliArgs { + static const defaultAtDirectoryFqdn = 'root.atsign.org'; + static const defaultAtRegistrarFqdn = 'my.atsign.com'; + late final ArgParser _aap; + + final String atDirectoryFqdn; + final String atRegistrarFqdn; + + ArgParser get parser { + return _aap; + } + + AuthCliArgs( + {this.atDirectoryFqdn = defaultAtDirectoryFqdn, + this.atRegistrarFqdn = defaultAtRegistrarFqdn}) { + _aap = createMainParser(); + } + + /// Creates an ArgParser with commands for + /// - onboard + /// - spp + /// - enroll + /// - listEnrollRequests + /// - approve + /// - deny + /// - listEnrollments + /// - revoke + @visibleForTesting + ArgParser createMainParser() { + final p = ArgParser(); + + for (final c in AuthCliCommand.values) { + p.addCommand(c.name, createParserForCommand(c)); + } + + p.addFlag( + 'help', + abbr: 'h', + help: 'Usage instructions', + negatable: false, + ); + p.addFlag( + 'verbose', + abbr: 'v', + help: 'INFO-level logging', + negatable: false, + ); + p.addFlag( + 'debug', + help: 'FINEST-level logging', + negatable: false, + ); + + return p; + } + + @visibleForTesting + ArgParser createParserForCommand(AuthCliCommand c) { + switch (c) { + case AuthCliCommand.onboard: + return createOnboardCommandParser(); + + case AuthCliCommand.spp: + return createSppCommandParser(); + + case AuthCliCommand.enroll: + return createEnrollCommandParser(); + + case AuthCliCommand.listEnrollRequests: + return createListEnrollRequestsCommandParser(); + + case AuthCliCommand.approve: + return createApproveCommandParser(); + + case AuthCliCommand.deny: + return createDenyCommandParser(); + + case AuthCliCommand.listEnrollments: + return createListEnrollmentsCommandParser(); + + case AuthCliCommand.revoke: + return createRevokeCommandParser(); + } + } + + /// auth onboard : require atSign, [, cram, atDirectory, atRegistrar] + /// When the cram arg is not supplied, we first use the registrar API + /// to send an OTP to the user and then use that OTP to obtain the cram + /// secret from the registrar. + @visibleForTesting + ArgParser createOnboardCommandParser() { + ArgParser p = ArgParser(); + p.addOption( + 'atsign', + abbr: 'a', + help: 'atSign to activate', + mandatory: true, + ); + p.addOption( + 'cramkey', + abbr: 'c', + help: 'CRAM key', + mandatory: false, + ); + p.addOption( + 'rootServer', + abbr: 'r', + help: 'root server\'s domain name', + defaultsTo: atDirectoryFqdn, + mandatory: false, + ); + p.addOption( + 'registrarUrl', + abbr: 'g', + help: 'url to the registrar api', + mandatory: false, + defaultsTo: atRegistrarFqdn, + ); + p.addFlag( + 'help', + abbr: 'h', + help: 'Usage instructions', + negatable: false, + ); + + return p; + } + + /// auth spp : require atSign, spp [, atKeys path] [, atDirectory] + @visibleForTesting + ArgParser createSppCommandParser() { + final p = ArgParser(); + p.addOption( + 'atsign', + abbr: 'a', + help: 'The atSign for which we are setting the spp (semi-permanent-pin)', + mandatory: true, + ); + p.addOption( + 'spp', + abbr: 's', + help: 'The semi-permanent enrollment pin to set for this atSign', + mandatory: true, + ); + p.addOption( + 'rootServer', + abbr: 'r', + help: 'root server\'s domain name', + defaultsTo: atDirectoryFqdn, + mandatory: false, + ); + return p; + } + + /// auth enroll : require atSign, app name, device name, otp [, atKeys path] + /// If atKeys file doesn't exist, then this is a new enrollment + /// If it does exist, then the enrollment request has been made and we need + /// to try to auth, and act appropriately on the atServer response + @visibleForTesting + ArgParser createEnrollCommandParser() { + final p = ArgParser(); + return p; + } + + /// auth list-enroll-requests + @visibleForTesting + ArgParser createListEnrollRequestsCommandParser() { + final p = ArgParser(); + return p; + } + + /// auth approve + @visibleForTesting + ArgParser createApproveCommandParser() { + final p = ArgParser(); + return p; + } + + /// auth deny + @visibleForTesting + ArgParser createDenyCommandParser() { + final p = ArgParser(); + return p; + } + + /// auth list-enrollments + @visibleForTesting + ArgParser createListEnrollmentsCommandParser() { + final p = ArgParser(); + return p; + } + + /// auth revoke + @visibleForTesting + ArgParser createRevokeCommandParser() { + final p = ArgParser(); + return p; + } +} diff --git a/packages/at_onboarding_cli/lib/src/cli/cli_params_validation.dart b/packages/at_onboarding_cli/lib/src/cli/cli_params_validation.dart new file mode 100644 index 00000000..8331e3ce --- /dev/null +++ b/packages/at_onboarding_cli/lib/src/cli/cli_params_validation.dart @@ -0,0 +1,8 @@ +const String sppRegex = r'[A-Za-z0-9]{6,16}'; +const String sppFormatHelp = 'alphanumeric and 6 to 16 characters long'; +const String invalidSppMsg = 'SPP must be $sppFormatHelp'; + +bool invalidSpp(String test) { + return RegExp(sppRegex).allMatches(test).first.group(0) != test; +} + diff --git a/packages/at_onboarding_cli/lib/src/util/print_full_parser_usage.dart b/packages/at_onboarding_cli/lib/src/util/print_full_parser_usage.dart new file mode 100644 index 00000000..47a06cc3 --- /dev/null +++ b/packages/at_onboarding_cli/lib/src/util/print_full_parser_usage.dart @@ -0,0 +1,34 @@ +import 'dart:io'; + +import 'package:args/args.dart'; + +extension PrintAllArgParserUsage on ArgParser { + static final String singleIndentation = ' '; + + printAllCommandsUsage( + {String commandName = 'Usage:', IOSink? sink, int indent = 0}) { + sink ??= stderr; + + // header message + _writelnWithIndentation(sink, indent, commandName); + + // this parser usage + List usageLines = usage.split('\n'); + for (final l in usageLines) { + _writelnWithIndentation(sink, indent + 1, l); + } + + // sub-parsers usage + for (final n in commands.keys) { + commands[n]!.printAllCommandsUsage( + commandName: n, sink: sink, indent: (indent + 1)); + } + } + + _writelnWithIndentation(IOSink sink, int indent, String s) { + for (int i = 0; i < indent; i++) { + sink.write(singleIndentation); + } + sink.writeln(s); + } +} diff --git a/packages/at_onboarding_cli/pubspec.yaml b/packages/at_onboarding_cli/pubspec.yaml index 62f8521a..3de5fa53 100644 --- a/packages/at_onboarding_cli/pubspec.yaml +++ b/packages/at_onboarding_cli/pubspec.yaml @@ -6,25 +6,26 @@ homepage: https://atsign.com documentation: https://docs.atsign.com/ environment: - sdk: '>=2.15.1 <4.0.0' + sdk: '>=3.3.0 <4.0.0' executables: at_register: register_cli at_activate: activate_cli dependencies: - args: ^2.4.1 - crypton: ^2.0.3 - encrypt: ^5.0.1 - http: ^1.0.0 - image: ^4.0.17 - path: ^1.8.1 + args: ^2.4.2 + crypton: ^2.2.1 + encrypt: ^5.0.3 + http: ^1.2.1 + image: ^4.1.7 + meta: ^1.12.0 + path: ^1.9.0 zxing2: ^0.2.0 at_auth: ^1.0.5 at_chops: ^2.0.0 - at_client: ^3.0.74 - at_commons: ^4.0.0 - at_lookup: ^3.0.45 + at_client: ^3.0.75 + at_commons: ^4.0.3 + at_lookup: ^3.0.46 at_server_status: ^1.0.4 at_utils: ^3.0.16 From 9d9be7087dddfba11287bb74282b28bf5c594794 Mon Sep 17 00:00:00 2001 From: gkc Date: Mon, 22 Apr 2024 15:15:56 +0100 Subject: [PATCH 03/19] feat: add Duration? retryInterval param to AtOnboardingService.enroll; deprecate the int? pkamRetryIntervalMins parameter --- .../lib/src/onboard/at_onboarding_service.dart | 11 ++++++++--- .../src/onboard/at_onboarding_service_impl.dart | 16 +++++++++++----- 2 files changed, 19 insertions(+), 8 deletions(-) diff --git a/packages/at_onboarding_cli/lib/src/onboard/at_onboarding_service.dart b/packages/at_onboarding_cli/lib/src/onboard/at_onboarding_service.dart index 7ba200b1..b94d686a 100644 --- a/packages/at_onboarding_cli/lib/src/onboard/at_onboarding_service.dart +++ b/packages/at_onboarding_cli/lib/src/onboard/at_onboarding_service.dart @@ -21,9 +21,14 @@ abstract class AtOnboardingService { /// namespaces - key-value pair of namespace-access of the requesting client e.g {"wavi":"rw","contacts":"r"} /// pkamRetryIntervalMins - optional param which specifies interval in mins for pkam retry for this enrollment. /// The passed value will override the value in [AtOnboardingPreference] - Future enroll(String appName, String deviceName, - String otp, Map namespaces, - {int? pkamRetryIntervalMins}); + Future enroll( + String appName, + String deviceName, + String otp, + Map namespaces, { + @Deprecated('Use retryInterval') int? pkamRetryIntervalMins, + Duration? retryInterval, + }); ///returns an authenticated instance of AtClient @Deprecated('use getter') diff --git a/packages/at_onboarding_cli/lib/src/onboard/at_onboarding_service_impl.dart b/packages/at_onboarding_cli/lib/src/onboard/at_onboarding_service_impl.dart index 747e2e63..fd452d82 100644 --- a/packages/at_onboarding_cli/lib/src/onboard/at_onboarding_service_impl.dart +++ b/packages/at_onboarding_cli/lib/src/onboard/at_onboarding_service_impl.dart @@ -131,15 +131,21 @@ class AtOnboardingServiceImpl implements AtOnboardingService { } @override - Future enroll(String appName, String deviceName, - String otp, Map namespaces, - {int? pkamRetryIntervalMins}) async { + Future enroll( + String appName, + String deviceName, + String otp, + Map namespaces, { + @Deprecated('Use retryInterval') int? pkamRetryIntervalMins, + Duration? retryInterval, + }) async { if (appName == null || deviceName == null) { throw AtEnrollmentException( 'appName and deviceName are mandatory for enrollment'); } - pkamRetryIntervalMins ??= atOnboardingPreference.apkamAuthRetryDurationMins; - final Duration retryInterval = Duration(minutes: pkamRetryIntervalMins); + retryInterval ??= Duration( + minutes: pkamRetryIntervalMins ?? + atOnboardingPreference.apkamAuthRetryDurationMins); AtLookupImpl atLookUpImpl = AtLookupImpl(_atSign, atOnboardingPreference.rootDomain, atOnboardingPreference.rootPort); From 895d1c24d803c38862d0a9fcd3310d7d46820599 Mon Sep 17 00:00:00 2001 From: gkc Date: Mon, 22 Apr 2024 15:16:23 +0100 Subject: [PATCH 04/19] feat: new apkam-aware cli for onboarding / enrollment / enrollment management. Interim commit: mostly structural changes for testability, readability etc --- .../lib/src/cli/auth_cli.dart | 220 +++++++++++++----- .../lib/src/cli/cli_args.dart | 147 +++++++++--- 2 files changed, 281 insertions(+), 86 deletions(-) diff --git a/packages/at_onboarding_cli/lib/src/cli/auth_cli.dart b/packages/at_onboarding_cli/lib/src/cli/auth_cli.dart index 742ab4c3..57a7a7ab 100644 --- a/packages/at_onboarding_cli/lib/src/cli/auth_cli.dart +++ b/packages/at_onboarding_cli/lib/src/cli/auth_cli.dart @@ -1,5 +1,3 @@ -import 'package:at_auth/at_auth.dart'; -import 'package:at_chops/at_chops.dart'; import 'package:at_client/at_client.dart'; import 'package:at_lookup/at_lookup.dart'; import 'package:at_onboarding_cli/at_onboarding_cli.dart'; @@ -86,120 +84,226 @@ Future _main(ArgParser parser, List arguments) async { try { switch (cliCommand) { case AuthCliCommand.onboard: + // First time an app is connecting to an atServer. + // Authenticate with cram secret + // then set PKAM keys + // then authenticate with PKAM to verify + // and then delete the cram secret + // Write keys to the usual output file @atSign_keys.atKeys await onboard(commandArgResults); case AuthCliCommand.spp: + // set a semi-permanent passcode for this atSign. This is a passcode + // which enrolling apps can provide which will signal that this is a + // real enrollment request and will be accepted by the atServer. + // Note that enrollment requests always require approval from an + // already-authenticated authorized atClient - the passcode on + // enrollment requests is used solely to defend against ddos attacks + // where users are bombarded with spurious enrollment requests. await setSpp(commandArgResults); - case AuthCliCommand.enroll: - throw ('$cliCommand not yet implemented'); + case AuthCliCommand.interactive: + // Interactive session for various enrollment management activities: + // - listing, approving, denying and revoking enrollments + // - setting spp, generating otp, etc + await interactive(commandArgResults); + + case AuthCliCommand.listen: + // Interactive session which listens for new enrollment requests + // and allows the user to approve or deny them. + await listen(commandArgResults); case AuthCliCommand.listEnrollRequests: - throw ('$cliCommand not yet implemented'); + await listEnrollRequests(commandArgResults); case AuthCliCommand.approve: - throw ('$cliCommand not yet implemented'); + await approve(commandArgResults); case AuthCliCommand.deny: - throw ('$cliCommand not yet implemented'); - - case AuthCliCommand.listEnrollments: - throw ('$cliCommand not yet implemented'); + await deny(commandArgResults); case AuthCliCommand.revoke: - throw ('$cliCommand not yet implemented'); + await revoke(commandArgResults); + + case AuthCliCommand.enroll: + // App which doesn't have auth keys and is not the first app. + // Send an enrollment request which has to be approved by an existing + // app which has permissions to approve enrollment requests. + // Write keys to @atSign_keys.atKeys IFF it doesn't already exist; if + // it does exist, then write to @atSign_appName_deviceName_keys.atKeys + await enroll(commandArgResults); } } on ArgumentError catch (e) { - stderr.writeln('Argument error for command ${cliCommand.name}: ${e.message}'); - commandParser.printAllCommandsUsage(commandName: 'Usage: ${cliCommand.name}', sink: stderr); + stderr + .writeln('Argument error for command ${cliCommand.name}: ${e.message}'); + commandParser.printAllCommandsUsage( + commandName: 'Usage: ${cliCommand.name}', sink: stderr); return 1; } catch (e, st) { stderr.writeln('Error for command ${cliCommand.name}: $e'); stderr.writeln(st); - commandParser.printAllCommandsUsage(commandName: 'Usage: ${cliCommand.name}', sink: stderr); + commandParser.printAllCommandsUsage( + commandName: 'Usage: ${cliCommand.name}', sink: stderr); return 1; } return 0; } -/// onboard params: atSign, [, cram, atDirectory, atRegistrar] -/// When a cram arg is not supplied, we first use the registrar API +/// When a cramSecret arg is not supplied, we first use the registrar API /// to send an OTP to the user and then use that OTP to obtain the cram /// secret from the registrar. @visibleForTesting -Future onboard(ArgResults argResults, - {AtOnboardingService? atOnboardingService}) async { - logger.info('Root server is ${argResults['rootServer']}'); - logger.info('Registrar url provided is ${argResults['registrarUrl']}'); - - atOnboardingService ??= createOnboardingService( - atSign: argResults['atsign'], - atDirectoryFqdn: argResults['rootServer'], - atRegistrarFqdn: argResults['registrarUrl'], - cramSecret: argResults['cramkey'], - ); +Future onboard(ArgResults argResults, {AtOnboardingService? svc}) async { + logger + .info('Root server is ${argResults[AuthCliArgs.argNameAtDirectoryFqdn]}'); + logger.info( + 'Registrar url provided is ${argResults[AuthCliArgs.argNameRegistrarFqdn]}'); + svc ??= createOnboardingService(argResults); stderr.writeln( - '[Information] Activating your atSign. This may take up to 2 minutes.'); + '[Information] Onboarding your atSign. This may take up to 2 minutes.'); try { - await atOnboardingService.onboard(); + await svc.onboard(); + exit(0); } on InvalidDataException catch (e) { stderr.writeln( - '[Error] Activation failed. Invalid data provided by user. Please try again\nCause: ${e.message}'); + '[Error] Onboarding failed. Invalid data provided by user. Please try again\nCause: ${e.message}'); + exit(1); } on InvalidRequestException catch (e) { stderr.writeln( - '[Error] Activation failed. Invalid data provided by user. Please try again\nCause: ${e.message}'); + '[Error] Onboarding failed. Invalid data provided by user. Please try again\nCause: ${e.message}'); + exit(1); } on AtActivateException catch (e) { stderr.writeln('[Error] ${e.message}'); + exit(1); } on Exception catch (e) { stderr.writeln( - '[Error] Activation failed. It looks like something went wrong on our side.\n' + '[Error] Onboarding failed. It looks like something went wrong on our side.\n' 'Please try again or contact support@atsign.com\nCause: $e'); - } finally { - await atOnboardingService.close(); + exit(1); } } +/// auth enroll : require atSign, app name, device name, otp [, atKeys path] +/// If atKeys file doesn't exist, then this is a new enrollment +/// If it does exist, then the enrollment request has been made and we need +/// to try to auth, and act appropriately on the atServer response @visibleForTesting -Future setSpp( - ArgResults argResults, { - AtOnboardingService? svc, -}) async { - String atSign = argResults['atsign']; - String spp = argResults['spp']; - - atSign = AtUtils.fixAtSign(atSign); +Future enroll(ArgResults argResults, {AtOnboardingService? svc}) async { + logger + .info('Root server is ${argResults[AuthCliArgs.argNameAtDirectoryFqdn]}'); + logger.info( + 'Registrar url provided is ${argResults[AuthCliArgs.argNameRegistrarFqdn]}'); + + svc ??= createOnboardingService(argResults); + Map namespaces = {}; + String nsArg = argResults[AuthCliArgs.argNameNamespaceAccessList]; + // TODO nsArg + try { + await svc.enroll( + argResults[AuthCliArgs.argNameAppName], + argResults[AuthCliArgs.argNameDeviceName], + argResults[AuthCliArgs.argNamePasscode], + namespaces, + retryInterval: Duration(seconds: 10), + ); + } on InvalidDataException catch (e) { + stderr.writeln( + '[Error] Enrollment failed. Invalid data provided by user. Please try again\nCause: ${e.message}'); + } on InvalidRequestException catch (e) { + stderr.writeln( + '[Error] Enrollment failed. Invalid data provided by user. Please try again\nCause: ${e.message}'); + } on AtActivateException catch (e) { + stderr.writeln('[Error] ${e.message}'); + } on Exception catch (e) { + stderr.writeln( + '[Error] Enrollment failed. It looks like something went wrong on our side.\n' + 'Please try again or contact support@atsign.com\nCause: $e'); + } +} +@visibleForTesting +Future setSpp(ArgResults argResults, {AtClient? atClient}) async { + String spp = argResults[AuthCliArgs.argNameSpp]; if (invalidSpp(spp)) { throw ArgumentError(invalidSppMsg); } - svc ??= createOnboardingService( - atSign: argResults['atsign'], - atDirectoryFqdn: argResults['rootServer'], - ); - - // authenticate - await svc.authenticate(); + if (atClient == null) { + AtOnboardingService svc = createOnboardingService(argResults); + await svc.authenticate(); + atClient = svc.atClient!; + } - AtClient atClient = svc.atClient!; AtLookUp atLookup = atClient.getRemoteSecondary()!.atLookUp; - AtChops atChops = atClient.atChops!; - AtAuth atAuth = svc.atAuth!; // send command 'otp:put:$spp' String? response = await atLookup.executeCommand('otp:put:$spp\n'); logger.shout('Server response: $response'); } +/// Only usable if there are atKeys already available. +/// All commands available same as the CLI as a whole, except for +/// 'onboard' and 'enroll' +Future interactive(ArgResults argResults) async {} + +/// Only usable if there are atKeys already available. +/// Listens for notifications about new enrollment requests then prompts +/// the user to approve or deny. +Future listen(ArgResults argResults) async {} + +Future listEnrollRequests(ArgResults argResults, + {AtClient? atClient}) async { + if (atClient == null) { + AtOnboardingService svc = createOnboardingService(argResults); + await svc.authenticate(); + atClient = svc.atClient!; + } + + // 'enroll:list:{"enrollmentStatusFilter":["approved"]}' + // 'enroll:list:{"enrollmentStatusFilter":["revoked"]}' + // 'enroll:list:{"enrollmentStatusFilter":["denied"]}' + // 'enroll:list:{"enrollmentStatusFilter":["pending"]}' +} + +Future approve(ArgResults argResults, {AtClient? atClient}) async { + if (atClient == null) { + AtOnboardingService svc = createOnboardingService(argResults); + await svc.authenticate(); + atClient = svc.atClient!; + } + + // 'enroll:approve:{"enrollmentId":"$secondEnrollId"}' +} + +Future deny(ArgResults argResults, {AtClient? atClient}) async { + if (atClient == null) { + AtOnboardingService svc = createOnboardingService(argResults); + await svc.authenticate(); + atClient = svc.atClient!; + } + + // 'enroll:deny:{"enrollmentId":"$secondEnrollId"}' +} + +Future revoke(ArgResults argResults, {AtClient? atClient}) async { + if (atClient == null) { + AtOnboardingService svc = createOnboardingService(argResults); + await svc.authenticate(); + atClient = svc.atClient!; + } + + // 'enroll:revoke:{"enrollmentid":"$enrollmentId"}' +} + @visibleForTesting -AtOnboardingService createOnboardingService({required String atSign, String atDirectoryFqdn = AuthCliArgs.defaultAtDirectoryFqdn, String atRegistrarFqdn = AuthCliArgs.defaultAtRegistrarFqdn, String? cramSecret}) { +AtOnboardingService createOnboardingService(ArgResults ar) { + String atSign = AtUtils.fixAtSign(ar[AuthCliArgs.argNameAtSign]); AtOnboardingPreference atOnboardingPreference = AtOnboardingPreference() - ..rootDomain = atDirectoryFqdn - ..registrarUrl = atRegistrarFqdn - ..cramSecret = cramSecret - ..useAtChops = true; + ..rootDomain = ar[AuthCliArgs.argNameAtDirectoryFqdn] + ..registrarUrl = ar[AuthCliArgs.argNameRegistrarFqdn] + ..cramSecret = ar[AuthCliArgs.argNameCramSecret]; return AtOnboardingServiceImpl(atSign, atOnboardingPreference); -} \ No newline at end of file +} diff --git a/packages/at_onboarding_cli/lib/src/cli/cli_args.dart b/packages/at_onboarding_cli/lib/src/cli/cli_args.dart index 464ac391..4d88d4d6 100644 --- a/packages/at_onboarding_cli/lib/src/cli/cli_args.dart +++ b/packages/at_onboarding_cli/lib/src/cli/cli_args.dart @@ -4,12 +4,13 @@ import 'package:meta/meta.dart'; enum AuthCliCommand { onboard, spp, - enroll, + interactive, + listen, listEnrollRequests, approve, deny, - listEnrollments, - revoke + revoke, + enroll, } class AuthCliArgs { @@ -20,6 +21,20 @@ class AuthCliArgs { final String atDirectoryFqdn; final String atRegistrarFqdn; + static const argNameHelp = 'help'; + static const argNameVerbose = 'verbose'; + static const argNameDebug = 'debug'; + static const argNameAtSign = 'atsign'; + static const argNameCramSecret = 'cramkey'; + static const argNameAtDirectoryFqdn = 'rootServer'; + static const argNameRegistrarFqdn = 'registrarUrl'; + static const argNameSpp = 'spp'; + static const argNameAppName = 'app'; + static const argNameDeviceName = 'device'; + static const argNamePasscode = 'passcode'; + static const argNameNamespaceAccessList = 'namespaces'; + static const argNameEnrollmentId = 'enrollmentId'; + ArgParser get parser { return _aap; } @@ -48,19 +63,19 @@ class AuthCliArgs { } p.addFlag( - 'help', + argNameHelp, abbr: 'h', help: 'Usage instructions', negatable: false, ); p.addFlag( - 'verbose', + argNameVerbose, abbr: 'v', help: 'INFO-level logging', negatable: false, ); p.addFlag( - 'debug', + argNameDebug, help: 'FINEST-level logging', negatable: false, ); @@ -74,12 +89,18 @@ class AuthCliArgs { case AuthCliCommand.onboard: return createOnboardCommandParser(); - case AuthCliCommand.spp: - return createSppCommandParser(); - case AuthCliCommand.enroll: return createEnrollCommandParser(); + case AuthCliCommand.interactive: + return createInteractiveCommandParser(); + + case AuthCliCommand.listen: + return createListenCommandParser(); + + case AuthCliCommand.spp: + return createSppCommandParser(); + case AuthCliCommand.listEnrollRequests: return createListEnrollRequestsCommandParser(); @@ -89,9 +110,6 @@ class AuthCliArgs { case AuthCliCommand.deny: return createDenyCommandParser(); - case AuthCliCommand.listEnrollments: - return createListEnrollmentsCommandParser(); - case AuthCliCommand.revoke: return createRevokeCommandParser(); } @@ -105,33 +123,33 @@ class AuthCliArgs { ArgParser createOnboardCommandParser() { ArgParser p = ArgParser(); p.addOption( - 'atsign', + argNameAtSign, abbr: 'a', help: 'atSign to activate', mandatory: true, ); p.addOption( - 'cramkey', + argNameCramSecret, abbr: 'c', help: 'CRAM key', mandatory: false, ); p.addOption( - 'rootServer', + argNameAtDirectoryFqdn, abbr: 'r', - help: 'root server\'s domain name', + help: 'atDirectory (root) server\'s domain name', defaultsTo: atDirectoryFqdn, mandatory: false, ); p.addOption( - 'registrarUrl', + argNameRegistrarFqdn, abbr: 'g', help: 'url to the registrar api', mandatory: false, defaultsTo: atRegistrarFqdn, ); p.addFlag( - 'help', + argNameHelp, abbr: 'h', help: 'Usage instructions', negatable: false, @@ -140,24 +158,62 @@ class AuthCliArgs { return p; } + ArgParser createInteractiveCommandParser() { + final p = ArgParser(); + + p.addOption( + argNameAtSign, + abbr: 'a', + help: 'The atSign', + mandatory: true, + ); + p.addOption( + argNameAtDirectoryFqdn, + abbr: 'r', + help: 'root server\'s domain name', + defaultsTo: atDirectoryFqdn, + mandatory: false, + ); + return p; + } + + ArgParser createListenCommandParser() { + final p = ArgParser(); + + p.addOption( + argNameAtSign, + abbr: 'a', + help: 'The atSign', + mandatory: true, + ); + p.addOption( + argNameAtDirectoryFqdn, + abbr: 'r', + help: 'root server\'s domain name', + defaultsTo: atDirectoryFqdn, + mandatory: false, + ); + return p; + } + /// auth spp : require atSign, spp [, atKeys path] [, atDirectory] @visibleForTesting ArgParser createSppCommandParser() { final p = ArgParser(); p.addOption( - 'atsign', + argNameAtSign, abbr: 'a', help: 'The atSign for which we are setting the spp (semi-permanent-pin)', mandatory: true, ); p.addOption( - 'spp', + argNameSpp, abbr: 's', help: 'The semi-permanent enrollment pin to set for this atSign', mandatory: true, ); p.addOption( - 'rootServer', + argNameAtDirectoryFqdn, abbr: 'r', help: 'root server\'s domain name', defaultsTo: atDirectoryFqdn, @@ -173,6 +229,48 @@ class AuthCliArgs { @visibleForTesting ArgParser createEnrollCommandParser() { final p = ArgParser(); + p.addOption( + argNameAtSign, + abbr: 'a', + help: 'The atSign for which we are setting the spp (semi-permanent-pin)', + mandatory: true, + ); + p.addOption( + argNamePasscode, + abbr: 'p', + help: 'The passcode to present with this enrollment request.', + mandatory: true, + ); + p.addOption( + argNameAppName, + help: 'The name of the app being enrolled', + mandatory: true, + ); + p.addOption( + argNameDeviceName, + help: 'A name for the device on which this app is running', + mandatory: true, + ); + p.addOption( + argNameAtDirectoryFqdn, + abbr: 'r', + help: 'atDirectory (root) server\'s domain name', + defaultsTo: atDirectoryFqdn, + mandatory: false, + ); + p.addOption( + argNameRegistrarFqdn, + abbr: 'g', + help: 'url to the registrar api', + mandatory: false, + defaultsTo: atRegistrarFqdn, + ); + p.addFlag( + argNameHelp, + abbr: 'h', + help: 'Usage instructions', + negatable: false, + ); return p; } @@ -197,13 +295,6 @@ class AuthCliArgs { return p; } - /// auth list-enrollments - @visibleForTesting - ArgParser createListEnrollmentsCommandParser() { - final p = ArgParser(); - return p; - } - /// auth revoke @visibleForTesting ArgParser createRevokeCommandParser() { From 6998e6af6a411c1fe1e3bdd15749cd2ac0f32bf1 Mon Sep 17 00:00:00 2001 From: gkc Date: Thu, 25 Apr 2024 14:46:35 +0100 Subject: [PATCH 05/19] fix: Fixed async-await bug in at_onboarding_service_impl. Added more lint rules to analysis_options.yaml including the unawaited futures rule so we don't have this happen anywhere else. --- .../at_onboarding_cli/analysis_options.yaml | 11 +++--- .../onboard/at_onboarding_service_impl.dart | 36 ++++++++++++------- .../lib/src/register_cli/register.dart | 2 +- 3 files changed, 30 insertions(+), 19 deletions(-) diff --git a/packages/at_onboarding_cli/analysis_options.yaml b/packages/at_onboarding_cli/analysis_options.yaml index dee8927a..24543b8b 100644 --- a/packages/at_onboarding_cli/analysis_options.yaml +++ b/packages/at_onboarding_cli/analysis_options.yaml @@ -13,11 +13,12 @@ include: package:lints/recommended.yaml -# Uncomment the following section to specify additional rules. - -# linter: -# rules: -# - camel_case_types +linter: + rules: + camel_case_types : true + unnecessary_string_interpolations : true + await_only_futures : true + unawaited_futures: true # analyzer: # exclude: diff --git a/packages/at_onboarding_cli/lib/src/onboard/at_onboarding_service_impl.dart b/packages/at_onboarding_cli/lib/src/onboard/at_onboarding_service_impl.dart index fd452d82..1d49eec6 100644 --- a/packages/at_onboarding_cli/lib/src/onboard/at_onboarding_service_impl.dart +++ b/packages/at_onboarding_cli/lib/src/onboard/at_onboarding_service_impl.dart @@ -41,13 +41,13 @@ class AtOnboardingServiceImpl implements AtOnboardingService { /// a [DefaultAtServiceFactory] AtServiceFactory? atServiceFactory; - at_auth.AtEnrollmentBase? _atEnrollmentBase; + at_auth.AtEnrollmentBase? _atEnrollment; AtOnboardingServiceImpl(atsign, this.atOnboardingPreference, {this.atServiceFactory, String? enrollmentId}) { // performs atSign format checks on the atSign _atSign = AtUtils.fixAtSign(atsign); - _atEnrollmentBase ??= at_auth.atAuthBase.atEnrollment(_atSign); + _atEnrollment ??= at_auth.atAuthBase.atEnrollment(_atSign); // set default LocalStorage paths for this instance atOnboardingPreference.commitLogPath ??= HomeDirectoryUtil.getCommitLogPath(_atSign, enrollmentId: enrollmentId); @@ -166,11 +166,11 @@ class AtOnboardingServiceImpl implements AtOnboardingService { atLookUpImpl.atChops = AtChopsImpl(atChopsKeys); // Pkam auth will be attempted asynchronously until enrollment is approved/denied - _attemptPkamAuthAsync( + await _attemptPkamAuthAsync( atLookUpImpl, enrollmentResponse.enrollmentId, retryInterval); // Upon successful pkam auth, callback _listenToPkamSuccessStream will be invoked - _listenToPkamSuccessStream( + await _listenToPkamSuccessStream( atLookUpImpl, enrollmentResponse.atAuthKeys!.apkamSymmetricKey!, enrollmentResponse.atAuthKeys!.defaultEncryptionPublicKey!, @@ -180,12 +180,13 @@ class AtOnboardingServiceImpl implements AtOnboardingService { return enrollmentResponse; } - void _listenToPkamSuccessStream( + Future _listenToPkamSuccessStream ( AtLookupImpl atLookUpImpl, String apkamSymmetricKey, String defaultEncryptionPublicKey, String apkamPublicKey, - String apkamPrivateKey) { + String apkamPrivateKey) async { + Completer c = Completer(); _onPkamSuccess.listen((enrollmentIdFromServer) async { logger.finer('_listenToPkamSuccessStream invoked'); var decryptedEncryptionPrivateKey = EncryptionUtil.decryptValue( @@ -206,7 +207,9 @@ class AtOnboardingServiceImpl implements AtOnboardingService { ..apkamPrivateKey = apkamPrivateKey; logger.finer('Generating keys file for $enrollmentIdFromServer'); await _generateAtKeysFile(enrollmentIdFromServer, atAuthKeys); + c.complete(); }); + return c.future; } Future _getEncryptionPrivateKeyFromServer( @@ -258,15 +261,15 @@ class AtOnboardingServiceImpl implements AtOnboardingService { String enrollmentIdFromServer, Duration retryInterval) async { // Pkam auth will be retried until server approves/denies/expires the enrollment while (true) { - logger.finer('Attempting pkam for $enrollmentIdFromServer'); + logger.shout('Attempting to authenticate'); bool pkamAuthResult = await _attemptPkamAuth( atLookUpImpl, enrollmentIdFromServer, retryInterval); if (pkamAuthResult) { - logger.finer('Pkam auth successful for $enrollmentIdFromServer'); + logger.shout('Authentication succeeded - enrollment request was approved'); _pkamSuccessController.add(enrollmentIdFromServer); break; } - logger.finer('Retrying pkam after mins: $retryInterval'); + logger.shout('Will retry pkam in ${retryInterval.inSeconds} seconds'); await Future.delayed(retryInterval); // Delay and retry } } @@ -274,21 +277,28 @@ class AtOnboardingServiceImpl implements AtOnboardingService { Future _attemptPkamAuth(AtLookUp atLookUp, String enrollmentIdFromServer, Duration retryInterval) async { try { + logger.finer('_attemptPkamAuth: Calling atLookUp.pkamAuthenticate'); var pkamResult = await atLookUp.pkamAuthenticate(enrollmentId: enrollmentIdFromServer); + logger.finer('_attemptPkamAuth: atLookUp.pkamAuthenticate returned $pkamResult'); if (pkamResult) { return true; } } on UnAuthenticatedException catch (e) { if (e.message.contains('error:AT0401') || e.message.contains('error:AT0026')) { - logger.finer('Pkam auth failed: ${e.message}'); + logger.info('Pkam auth failed: ${e.message}'); return false; } else if (e.message.contains('error:AT0025')) { - logger.finer( - 'enrollmentId $enrollmentIdFromServer denied.Exiting pkam retry logic'); + logger.shout( + 'enrollmentId $enrollmentIdFromServer denied. Exiting pkam retry logic'); throw AtEnrollmentException('enrollment denied'); } + } catch (e) { + logger.shout('Unexpected exception: $e'); + rethrow; + } finally { + logger.finer('_attemptPkamAuth: complete'); } return false; } @@ -306,7 +316,7 @@ class AtOnboardingServiceImpl implements AtOnboardingService { namespaces: namespaces, otp: otp); logger.finer('calling at_enrollment_impl submit enrollment'); - return await _atEnrollmentBase! + return await _atEnrollment! .submit(newClientEnrollmentRequest, atLookUpImpl); } diff --git a/packages/at_onboarding_cli/lib/src/register_cli/register.dart b/packages/at_onboarding_cli/lib/src/register_cli/register.dart index eedc8eb5..40effc88 100644 --- a/packages/at_onboarding_cli/lib/src/register_cli/register.dart +++ b/packages/at_onboarding_cli/lib/src/register_cli/register.dart @@ -64,7 +64,7 @@ class Register { .add(ValidateOtp()) .start(); - activate_cli.main(['-a', params['atsign']!, '-c', params['cramkey']!]); + await activate_cli.main(['-a', params['atsign']!, '-c', params['cramkey']!]); } } From 624ae47700244fc99df660b868264a2d0f47dcb7 Mon Sep 17 00:00:00 2001 From: gkc Date: Thu, 25 Apr 2024 14:47:50 +0100 Subject: [PATCH 06/19] feat: new at_activate cli working fully; needs a couple of changes to go to prod swarms before can be used for prod --- .../apkam_examples/enroll_app_listen.dart | 6 +- .../lib/src/cli/auth_cli.dart | 442 ++++++++++++++---- ...tion.dart => auth_cli_arg_validation.dart} | 0 .../lib/src/cli/auth_cli_args.dart | 363 ++++++++++++++ .../lib/src/cli/cli_args.dart | 304 ------------ .../lib/src/util/print_full_parser_usage.dart | 40 +- packages/at_onboarding_cli/pubspec.yaml | 1 + .../test/enrollment_test.dart | 8 +- 8 files changed, 747 insertions(+), 417 deletions(-) rename packages/at_onboarding_cli/lib/src/cli/{cli_params_validation.dart => auth_cli_arg_validation.dart} (100%) create mode 100644 packages/at_onboarding_cli/lib/src/cli/auth_cli_args.dart delete mode 100644 packages/at_onboarding_cli/lib/src/cli/cli_args.dart diff --git a/packages/at_onboarding_cli/example/apkam_examples/enroll_app_listen.dart b/packages/at_onboarding_cli/example/apkam_examples/enroll_app_listen.dart index c263d09f..8d5ff9c9 100644 --- a/packages/at_onboarding_cli/example/apkam_examples/enroll_app_listen.dart +++ b/packages/at_onboarding_cli/example/apkam_examples/enroll_app_listen.dart @@ -56,10 +56,10 @@ Future _notificationCallback(AtNotification notification, var enrollParamsJson = {}; enrollParamsJson['enrollmentId'] = enrollmentId; if (approveResponse == 'yes') { - final encryptedApkamSymmetricKey = - jsonDecode(notification.value!)['encryptedApkamSymmetricKey']; + final encryptedAPKAMSymmetricKey = + jsonDecode(notification.value!)['encryptedAPKAMSymmetricKey']; final apkamSymmetricKey = EncryptionUtil.decryptKey( - encryptedApkamSymmetricKey, atAuthKeys.defaultEncryptionPrivateKey!); + encryptedAPKAMSymmetricKey, atAuthKeys.defaultEncryptionPrivateKey!); print('decrypted apkam symmetric key: $apkamSymmetricKey'); var encryptedDefaultPrivateEncKey = EncryptionUtil.encryptValue( atAuthKeys.defaultEncryptionPrivateKey!, apkamSymmetricKey); diff --git a/packages/at_onboarding_cli/lib/src/cli/auth_cli.dart b/packages/at_onboarding_cli/lib/src/cli/auth_cli.dart index 57a7a7ab..2d68f7d3 100644 --- a/packages/at_onboarding_cli/lib/src/cli/auth_cli.dart +++ b/packages/at_onboarding_cli/lib/src/cli/auth_cli.dart @@ -1,3 +1,7 @@ +import 'dart:convert'; + +import 'package:at_auth/at_auth.dart'; +import 'package:at_cli_commons/at_cli_commons.dart'; import 'package:at_client/at_client.dart'; import 'package:at_lookup/at_lookup.dart'; import 'package:at_onboarding_cli/at_onboarding_cli.dart'; @@ -8,31 +12,36 @@ import 'dart:io'; import 'package:at_utils/at_utils.dart'; import 'package:meta/meta.dart'; -import 'cli_args.dart'; -import 'cli_params_validation.dart'; +import 'auth_cli_args.dart'; +import 'auth_cli_arg_validation.dart'; final AtSignLogger logger = AtSignLogger(' CLI '); -Future main(List arguments) async { - ArgParser parser = AuthCliArgs().parser; +final aca = AuthCliArgs(); +Future main(List arguments) async { + AtSignLogger.defaultLoggingHandler = AtSignLogger.stdErrLoggingHandler; try { - return await _main(parser, arguments); + return await _main(arguments); } on ArgumentError catch (e) { stderr.writeln('Invalid argument: ${e.message}'); - parser.printAllCommandsUsage(sink: stderr); + aca.parser.printAllCommandsUsage(); return 1; } catch (e) { stderr.writeln('Error: $e'); - parser.printAllCommandsUsage(sink: stderr); + aca.parser.printAllCommandsUsage(); return 1; } } -Future _main(ArgParser parser, List arguments) async { +Future _main(List arguments) async { if (arguments.isEmpty) { - parser.printAllCommandsUsage(sink: stderr); - return 0; + stderr.writeln('You must supply a command.'); + aca.parser.printAllCommandsUsage(showSubCommandParams: false); + stderr.writeln('\n' + 'Use --help or -h flag to show full usage of all commands' + '\n'); + return 1; } final first = arguments.first; @@ -40,28 +49,22 @@ Future _main(ArgParser parser, List arguments) async { // no command found ... legacy ... insert 'onboard' as the command arguments = ['onboard', ...arguments]; } - final AuthCliCommand cliCommand; - try { - cliCommand = AuthCliCommand.values.byName(arguments.first); - } catch (e) { - throw ArgumentError('Unknown command: ${arguments.first}'); - } - final ArgResults topLevelResults = parser.parse(arguments); + final ArgResults topLevelResults = aca.parser.parse(arguments); - if (topLevelResults.wasParsed('help')) { - parser.printAllCommandsUsage(sink: stderr); + if (topLevelResults.wasParsed(AuthCliArgs.argNameHelp)) { + aca.sharedArgsParser + .printAllCommandsUsage(header: 'Arguments common to all commands: '); + aca.parser.printAllCommandsUsage(showSubCommandParams: true); + stderr.writeln(); return 0; } - AtSignLogger.defaultLoggingHandler = AtSignLogger.stdErrLoggingHandler; - AtSignLogger.root_level = 'warning'; - - if (topLevelResults.wasParsed('verbose')) { - AtSignLogger.root_level = 'info'; - } - if (topLevelResults.wasParsed('debug')) { - AtSignLogger.root_level = 'finest'; + final AuthCliCommand cliCommand; + try { + cliCommand = AuthCliCommand.values.byName(arguments.first); + } catch (e) { + throw ArgumentError('Unknown command: ${arguments.first}'); } if (topLevelResults.command == null) { @@ -74,15 +77,34 @@ Future _main(ArgParser parser, List arguments) async { ' but parsed command ${commandArgResults.name} '); } - // Execute the command + // Parse the command options + ArgParser commandParser = aca.parser.commands[cliCommand.name]!; - logger.info('Chosen command: $cliCommand' - ' with options : ${commandArgResults.arguments}' - ' and positional args : ${commandArgResults.rest}'); + if (commandArgResults.wasParsed(AuthCliArgs.argNameHelp)) { + commandParser.printAllCommandsUsage( + header: 'Usage: ${cliCommand.name}', showSubCommandParams: true); + aca.sharedArgsParser.printAllCommandsUsage(); + stderr.writeln('\n${cliCommand.usage}\n'); + return 0; + } - ArgParser commandParser = parser.commands[cliCommand.name]!; + // Parse the log levels and act accordingly + AtSignLogger.root_level = 'warning'; + + if (commandArgResults.wasParsed(AuthCliArgs.argNameVerbose)) { + AtSignLogger.root_level = 'info'; + } + if (commandArgResults.wasParsed(AuthCliArgs.argNameDebug)) { + AtSignLogger.root_level = 'finest'; + } + + // Execute the command try { switch (cliCommand) { + case AuthCliCommand.help: + aca.parser.printAllCommandsUsage(showSubCommandParams: true); + break; + case AuthCliCommand.onboard: // First time an app is connecting to an atServer. // Authenticate with cram secret @@ -100,30 +122,47 @@ Future _main(ArgParser parser, List arguments) async { // already-authenticated authorized atClient - the passcode on // enrollment requests is used solely to defend against ddos attacks // where users are bombarded with spurious enrollment requests. - await setSpp(commandArgResults); + await setSpp( + commandArgResults, await createAtClient(commandArgResults)); + + case AuthCliCommand.otp: + // generate a one-time-passcode for this atSign. This is a passcode + // which enrolling apps can provide which will signal that this is a + // real enrollment request and will be accepted by the atServer. + // Note that enrollment requests always require approval from an + // already-authenticated authorized atClient - the passcode on + // enrollment requests is used solely to defend against ddos attacks + // where users are bombarded with spurious enrollment requests. + await generateOtp( + commandArgResults, await createAtClient(commandArgResults)); case AuthCliCommand.interactive: // Interactive session for various enrollment management activities: // - listing, approving, denying and revoking enrollments // - setting spp, generating otp, etc - await interactive(commandArgResults); + await interactive( + commandArgResults, await createAtClient(commandArgResults)); - case AuthCliCommand.listen: - // Interactive session which listens for new enrollment requests - // and allows the user to approve or deny them. - await listen(commandArgResults); + case AuthCliCommand.list: + await list(commandArgResults, await createAtClient(commandArgResults)); - case AuthCliCommand.listEnrollRequests: - await listEnrollRequests(commandArgResults); + case AuthCliCommand.fetch: + await fetch(commandArgResults, await createAtClient(commandArgResults)); case AuthCliCommand.approve: - await approve(commandArgResults); + await approve( + commandArgResults, await createAtClient(commandArgResults)); case AuthCliCommand.deny: - await deny(commandArgResults); + await deny(commandArgResults, await createAtClient(commandArgResults)); + + case AuthCliCommand.denyAllPending: + await denyAllPending( + commandArgResults, await createAtClient(commandArgResults)); case AuthCliCommand.revoke: - await revoke(commandArgResults); + await revoke( + commandArgResults, await createAtClient(commandArgResults)); case AuthCliCommand.enroll: // App which doesn't have auth keys and is not the first app. @@ -137,34 +176,58 @@ Future _main(ArgParser parser, List arguments) async { stderr .writeln('Argument error for command ${cliCommand.name}: ${e.message}'); commandParser.printAllCommandsUsage( - commandName: 'Usage: ${cliCommand.name}', sink: stderr); + header: 'Usage: ${cliCommand.name}', + ); + aca.sharedArgsParser.printAllCommandsUsage(); return 1; } catch (e, st) { stderr.writeln('Error for command ${cliCommand.name}: $e'); stderr.writeln(st); commandParser.printAllCommandsUsage( - commandName: 'Usage: ${cliCommand.name}', sink: stderr); + header: 'Usage: ${cliCommand.name}', + ); + aca.sharedArgsParser.printAllCommandsUsage(); return 1; } return 0; } +Future createAtClient(ArgResults ar) async { + String nameSpace = 'at_auth_cli'; + String atSign = AtUtils.fixAtSign(ar[AuthCliArgs.argNameAtSign]); + CLIBase cliBase = CLIBase( + atSign: atSign, + atKeysFilePath: ar[AuthCliArgs.argNameAtKeys], + nameSpace: nameSpace, + rootDomain: ar[AuthCliArgs.argNameAtDirectoryFqdn], + homeDir: getHomeDirectory(), + storageDir: '${getHomeDirectory()}/.atsign/$nameSpace/$atSign/storage' + .replaceAll('/', Platform.pathSeparator), + verbose: ar[AuthCliArgs.argNameVerbose] || ar[AuthCliArgs.argNameDebug], + syncDisabled: true); + + await cliBase.init(); + + return cliBase.atClient; +} + /// When a cramSecret arg is not supplied, we first use the registrar API /// to send an OTP to the user and then use that OTP to obtain the cram /// secret from the registrar. @visibleForTesting Future onboard(ArgResults argResults, {AtOnboardingService? svc}) async { + svc ??= createOnboardingService(argResults); logger .info('Root server is ${argResults[AuthCliArgs.argNameAtDirectoryFqdn]}'); logger.info( 'Registrar url provided is ${argResults[AuthCliArgs.argNameRegistrarFqdn]}'); - svc ??= createOnboardingService(argResults); stderr.writeln( '[Information] Onboarding your atSign. This may take up to 2 minutes.'); try { await svc.onboard(); + logger.finest('svc.onboard() has returned - will exit(0)'); exit(0); } on InvalidDataException catch (e) { stderr.writeln( @@ -191,15 +254,25 @@ Future onboard(ArgResults argResults, {AtOnboardingService? svc}) async { /// to try to auth, and act appropriately on the atServer response @visibleForTesting Future enroll(ArgResults argResults, {AtOnboardingService? svc}) async { + svc ??= createOnboardingService(argResults); logger .info('Root server is ${argResults[AuthCliArgs.argNameAtDirectoryFqdn]}'); logger.info( 'Registrar url provided is ${argResults[AuthCliArgs.argNameRegistrarFqdn]}'); - svc ??= createOnboardingService(argResults); + if (!argResults.wasParsed(AuthCliArgs.argNameAtKeys)) { + throw ArgumentError('The --${AuthCliArgs.argNameAtKeys} option is' + ' mandatory for the "enroll" command'); + } Map namespaces = {}; String nsArg = argResults[AuthCliArgs.argNameNamespaceAccessList]; - // TODO nsArg + List nsList = nsArg.split(','); + for (String item in nsList) { + List l = item.split(':'); + String namespace = l[0].replaceAll('"', '').trim(); + String permission = l[1].replaceAll('"', '').trim(); + namespaces[namespace] = permission; + } try { await svc.enroll( argResults[AuthCliArgs.argNameAppName], @@ -216,85 +289,267 @@ Future enroll(ArgResults argResults, {AtOnboardingService? svc}) async { '[Error] Enrollment failed. Invalid data provided by user. Please try again\nCause: ${e.message}'); } on AtActivateException catch (e) { stderr.writeln('[Error] ${e.message}'); + } on AtEnrollmentException catch (e) { + stderr.writeln('[Fatal] ${e.message}'); + } on AtAuthenticationException catch (e) { + stderr.writeln('[Error] ${e.message}'); } on Exception catch (e) { - stderr.writeln( - '[Error] Enrollment failed. It looks like something went wrong on our side.\n' - 'Please try again or contact support@atsign.com\nCause: $e'); + stderr.writeln('[Error] $e'); + stderr.writeln('[Error] Enrollment failed.\n' + ' Cause: $e\n' + ' Please try again or contact support@atsign.com'); } + logger.finest('svc.enroll() has returned'); } @visibleForTesting -Future setSpp(ArgResults argResults, {AtClient? atClient}) async { +Future setSpp(ArgResults argResults, AtClient atClient) async { String spp = argResults[AuthCliArgs.argNameSpp]; if (invalidSpp(spp)) { throw ArgumentError(invalidSppMsg); } - if (atClient == null) { - AtOnboardingService svc = createOnboardingService(argResults); - await svc.authenticate(); - atClient = svc.atClient!; - } - AtLookUp atLookup = atClient.getRemoteSecondary()!.atLookUp; // send command 'otp:put:$spp' - String? response = await atLookup.executeCommand('otp:put:$spp\n'); + String? response = + await atLookup.executeCommand('otp:put:$spp\n', auth: true); + logger.shout('Server response: $response'); +} + +@visibleForTesting +Future generateOtp(ArgResults argResults, AtClient atClient) async { + AtLookUp atLookup = atClient.getRemoteSecondary()!.atLookUp; + + // send command 'otp:get[:ttl:$ttl]' + String? response = await atLookup.executeCommand('otp:get\n', auth: true); logger.shout('Server response: $response'); } /// Only usable if there are atKeys already available. /// All commands available same as the CLI as a whole, except for /// 'onboard' and 'enroll' -Future interactive(ArgResults argResults) async {} +Future interactive(ArgResults argResults, AtClient atClient) async { + // TODO Factor out code which is shared between here and main() + while (true) { + stderr.write(r'$ '); + List arguments = stdin.readLineSync()!.split(RegExp(r'\s')); + + final AuthCliCommand cliCommand; + try { + cliCommand = AuthCliCommand.values.byName(arguments.first); + } catch (e) { + stderr.writeln('Unknown command: ${arguments.first}'); + continue; + } -/// Only usable if there are atKeys already available. -/// Listens for notifications about new enrollment requests then prompts -/// the user to approve or deny. -Future listen(ArgResults argResults) async {} - -Future listEnrollRequests(ArgResults argResults, - {AtClient? atClient}) async { - if (atClient == null) { - AtOnboardingService svc = createOnboardingService(argResults); - await svc.authenticate(); - atClient = svc.atClient!; + final ArgResults topLevelResults = aca.parser.parse(arguments); + + if (topLevelResults.wasParsed(AuthCliArgs.argNameHelp)) { + aca.sharedArgsParser + .printAllCommandsUsage(header: 'Arguments common to all commands: '); + aca.parser.printAllCommandsUsage(showSubCommandParams: true); + stderr.writeln(); + continue; + } + + if (topLevelResults.command == null) { + stderr.writeln('No command was parsed'); + continue; + } + + ArgResults commandArgResults = topLevelResults.command!; + if (commandArgResults.name != cliCommand.name) { + stderr.writeln('detected command ${cliCommand.name}' + ' but parsed command ${commandArgResults.name} '); + continue; + } + + // Parse the command options + ArgParser commandParser = aca.parser.commands[cliCommand.name]!; + + if (commandArgResults.wasParsed(AuthCliArgs.argNameHelp)) { + commandParser.printAllCommandsUsage( + header: 'Usage: ${cliCommand.name}', showSubCommandParams: true); + stderr.writeln('\n${cliCommand.usage}\n'); + continue; + } + + // Execute the command + try { + switch (cliCommand) { + case AuthCliCommand.help: + aca.parser.printAllCommandsUsage(showSubCommandParams: true); + + case AuthCliCommand.onboard: + case AuthCliCommand.interactive: + case AuthCliCommand.enroll: + stderr.writeln('The "${cliCommand.name}" command' + ' may not be used in interactive session'); + + case AuthCliCommand.spp: + await setSpp(commandArgResults, atClient); + + case AuthCliCommand.otp: + await generateOtp(commandArgResults, atClient); + + case AuthCliCommand.list: + await list(commandArgResults, atClient); + + case AuthCliCommand.fetch: + await fetch(commandArgResults, atClient); + + case AuthCliCommand.approve: + await approve(commandArgResults, atClient); + + case AuthCliCommand.deny: + await deny(commandArgResults, atClient); + + case AuthCliCommand.denyAllPending: + await denyAllPending(commandArgResults, atClient); + + case AuthCliCommand.revoke: + await revoke(commandArgResults, atClient); + } + } on ArgumentError catch (e) { + stderr.writeln( + 'Argument error for command ${cliCommand.name}: ${e.message}'); + commandParser.printAllCommandsUsage(header: 'Usage: ${cliCommand.name}'); + } } +} - // 'enroll:list:{"enrollmentStatusFilter":["approved"]}' - // 'enroll:list:{"enrollmentStatusFilter":["revoked"]}' - // 'enroll:list:{"enrollmentStatusFilter":["denied"]}' - // 'enroll:list:{"enrollmentStatusFilter":["pending"]}' +Future _list(String? statusFilter, AtLookUp atLookup) async { + String command = 'enroll:list'; + if (statusFilter != null) { + command += ':{"enrollmentStatusFilter":["$statusFilter"]}'; + } + String rawResponse = (await atLookup.executeCommand( + '$command\n', + auth: true, + ))!; + + if (rawResponse.startsWith('data:')) { + rawResponse = rawResponse.substring(rawResponse.indexOf('data:') + 5); + return jsonDecode(rawResponse); + } else { + logger.shout('Exiting: Unexpected server response: $rawResponse'); + exit(1); + } } -Future approve(ArgResults argResults, {AtClient? atClient}) async { - if (atClient == null) { - AtOnboardingService svc = createOnboardingService(argResults); - await svc.authenticate(); - atClient = svc.atClient!; +Future list(ArgResults ar, AtClient atClient) async { + AtLookUp atLookup = atClient.getRemoteSecondary()!.atLookUp; + + String? statusFilter = ar[AuthCliArgs.argNameEnrollmentStatus]; + + Map json = await _list(statusFilter, atLookup); + for (final eId in json.keys) { + stderr.writeln('Enrollment ID: $eId'); + final e = json[eId] as Map; + stderr.writeln(' Status: ${e['status']}'); + stderr.writeln(' App Name: ${e['appName']}'); + stderr.writeln(' Device Name: ${e['deviceName']}'); + stderr.writeln(' Namespaces: ${e['namespace']}'); } +} - // 'enroll:approve:{"enrollmentId":"$secondEnrollId"}' +Future _fetch(String eId, AtLookUp atLookup) async { + String rawResponse = (await atLookup.executeCommand( + 'enroll:list:' + '{"enrollmentId":"$eId"}' + '\n', + auth: true))!; + + if (rawResponse.startsWith('data:')) { + rawResponse = rawResponse.substring(rawResponse.indexOf('data:') + 5); + // response is a Map of enrollmentId:enrollmentJson + final Map json = jsonDecode(rawResponse); + if (json.keys.isEmpty) { + return null; + } else { + if (json.keys.length > 1) { + logger.shout('Error: Fetched more than one enrollment request' + ' - will return the first one'); + logger.shout(json); + } + return json[json.keys.first]; + } + } else { + logger.shout('Exiting: Unexpected server response: $rawResponse'); + exit(1); + } } -Future deny(ArgResults argResults, {AtClient? atClient}) async { - if (atClient == null) { - AtOnboardingService svc = createOnboardingService(argResults); - await svc.authenticate(); - atClient = svc.atClient!; +Future fetch(ArgResults argResults, AtClient atClient) async { + String eId = argResults[AuthCliArgs.argNameEnrollmentId]; + AtLookUp atLookup = atClient.getRemoteSecondary()!.atLookUp; + + Map? er = await _fetch(eId, atLookup); + if (er == null) { + logger.shout('Enrollment ID $eId not found'); + return; + } else { + stderr.writeln('Fetched enrollment OK: $er'); } +} - // 'enroll:deny:{"enrollmentId":"$secondEnrollId"}' +Future approve(ArgResults argResults, AtClient atClient) async { + String eId = argResults[AuthCliArgs.argNameEnrollmentId]; + AtLookUp atLookup = atClient.getRemoteSecondary()!.atLookUp; + + // First fetch the enrollment request + Map? er = await _fetch(eId, atLookup); + if (er == null) { + logger.shout('Enrollment ID $eId not found'); + return; + } + + stderr.writeln('Fetched enrollment OK: $er'); + + // Then make a 'decision' object using data from the enrollment request + EnrollmentRequestDecision decision = EnrollmentRequestDecision.approved( + ApprovedRequestDecisionBuilder( + enrollmentId: eId, + encryptedAPKAMSymmetricKey: er['encryptedAPKAMSymmetricKey'])); + + // Finally call approve method via an AtEnrollment object + final response = await atAuthBase + .atEnrollment(atClient.getCurrentAtSign()!) + .approve(decision, atLookup); + // 'enroll:approve:{"enrollmentId":"$enrollmentId"}' + logger.shout('Server response: $response'); +} + +Future deny(ArgResults argResults, AtClient atClient) async { + String eId = argResults[AuthCliArgs.argNameEnrollmentId]; + AtLookUp atLookup = atClient.getRemoteSecondary()!.atLookUp; + // 'enroll:deny:{"enrollmentId":"$enrollmentId"}' + String? response = await atLookup + .executeCommand('enroll:deny:{"enrollmentId":"$eId"}\n', auth: true); + logger.shout('Server response: $response'); } -Future revoke(ArgResults argResults, {AtClient? atClient}) async { - if (atClient == null) { - AtOnboardingService svc = createOnboardingService(argResults); - await svc.authenticate(); - atClient = svc.atClient!; +Future denyAllPending(ArgResults argResults, AtClient atClient) async { + AtLookUp atLookup = atClient.getRemoteSecondary()!.atLookUp; + Map json = await _list(EnrollmentStatus.pending.name, atLookup); + for (final String eIdKey in json.keys) { + String eId = eIdKey.substring(0, eIdKey.indexOf('.')); + stderr.writeln('Denying enrollment request: $eId ($eIdKey)'); + // 'enroll:deny:{"enrollmentId":"$enrollmentId"}' + String? response = await atLookup + .executeCommand('enroll:deny:{"enrollmentId":"$eId"}\n', auth: true); + stderr.writeln(' => Server response: $response'); } +} +Future revoke(ArgResults argResults, AtClient atClient) async { + String eId = argResults[AuthCliArgs.argNameEnrollmentId]; + AtLookUp atLookup = atClient.getRemoteSecondary()!.atLookUp; // 'enroll:revoke:{"enrollmentid":"$enrollmentId"}' + String? response = await atLookup + .executeCommand('enroll:revoke:{"enrollmentId":"$eId"}\n', auth: true); + logger.shout('Server response: $response'); } @visibleForTesting @@ -303,7 +558,8 @@ AtOnboardingService createOnboardingService(ArgResults ar) { AtOnboardingPreference atOnboardingPreference = AtOnboardingPreference() ..rootDomain = ar[AuthCliArgs.argNameAtDirectoryFqdn] ..registrarUrl = ar[AuthCliArgs.argNameRegistrarFqdn] - ..cramSecret = ar[AuthCliArgs.argNameCramSecret]; + ..cramSecret = ar[AuthCliArgs.argNameCramSecret] + ..atKeysFilePath = ar[AuthCliArgs.argNameAtKeys]; return AtOnboardingServiceImpl(atSign, atOnboardingPreference); } diff --git a/packages/at_onboarding_cli/lib/src/cli/cli_params_validation.dart b/packages/at_onboarding_cli/lib/src/cli/auth_cli_arg_validation.dart similarity index 100% rename from packages/at_onboarding_cli/lib/src/cli/cli_params_validation.dart rename to packages/at_onboarding_cli/lib/src/cli/auth_cli_arg_validation.dart diff --git a/packages/at_onboarding_cli/lib/src/cli/auth_cli_args.dart b/packages/at_onboarding_cli/lib/src/cli/auth_cli_args.dart new file mode 100644 index 00000000..3ca8de87 --- /dev/null +++ b/packages/at_onboarding_cli/lib/src/cli/auth_cli_args.dart @@ -0,0 +1,363 @@ +import 'dart:io'; + +import 'package:args/args.dart'; +import 'package:at_commons/at_commons.dart'; +import 'package:meta/meta.dart'; + +enum AuthCliCommand { + help(usage: 'Show help'), + onboard(usage: '"onboard" is used when first authenticating to an atServer.' + ' It generates "atKeys" (stored to filesystem or keychain) which' + ' may be used for authentication thereafter.' + '\n\n' + 'When another program' + ' needs to be able to authenticate, it may use the atKeys file if it is' + ' available - however when the program is on a different device, or on ' + ' the same device but in a different, sandboxed app, the recommended' + ' approach is to use the "enroll" command.'), + otp(usage: 'Generate a one-time passcode which may be used by a single' + ' enrollment request.' + '\n\n' + 'Note that the passcode is used only to allow the' + ' atServer to identify that an enrollment request is not' + ' spurious / malicious / spam - i.e. enrollment requests which have a valid' + ' passcode will be accepted.'), + spp(usage: 'Set a semi-permanent passcode which may be used by multiple' + ' enrollment requests. This is particularly useful when programs will ' + ' need to be run on many different devices.' + '\n\n' + 'Note that the passcode is used only to allow the' + ' atServer to identify that an enrollment request is not' + ' spurious / malicious / spam - i.e. enrollment requests which have a valid' + ' passcode will be accepted.'), + interactive(usage: 'Run in interactive mode'), + list(usage: 'List enrollment requests'), + fetch(usage: 'Fetch a specific enrollment request'), + approve(usage: 'Approve a pending enrollment request'), + deny(usage: 'Deny a pending enrollment request'), + denyAllPending(usage: 'Deny all pending enrollment requests'), + revoke(usage: 'Revoke approval of a previously-approved enrollment'), + enroll(usage: 'Enroll is used when a program needs to authenticate and' + ' "atKeys" are not available, and "onboard" has already been run' + ' by another program.' + '\n\n' + 'Enrollment requests require a valid passcode in order' + ' for them to be accepted by the atServer; accepted requests will then' + ' be delivered to some other program(s) which have' + ' permission to approve or deny the requests. Typically that will be' + ' the program which first onboarded; however it can also be an enrolled' + ' program which has "rw" access to the "__manage" namespace.'); + + const AuthCliCommand({this.usage = ''}); + final String usage; +} + +main() { + print(AuthCliCommand.values); +} + +class AuthCliArgs { + static const defaultAtDirectoryFqdn = 'root.atsign.org'; + static const defaultAtRegistrarFqdn = 'my.atsign.com'; + late final ArgParser _aap; + late final ArgParser _sap; + + final String atDirectoryFqdn; + final String atRegistrarFqdn; + + static const argNameHelp = 'help'; + static const argNameVerbose = 'verbose'; + static const argNameDebug = 'debug'; + static const argNameAtSign = 'atsign'; + static const argNameCramSecret = 'cramkey'; + static const argNameAtKeys = 'keys'; + static const argNameAtDirectoryFqdn = 'rootServer'; + static const argNameRegistrarFqdn = 'registrarUrl'; + static const argNameSpp = 'spp'; + static const argNameAppName = 'app'; + static const argNameDeviceName = 'device'; + static const argNamePasscode = 'passcode'; + static const argAbbrPasscode = 's'; + static const argNameNamespaceAccessList = 'namespaces'; + static const argNameEnrollmentId = 'enrollmentId'; + static const argNameEnrollmentStatus = 'enrollmentStatus'; + + ArgParser get parser { + return _aap; + } + + ArgParser get sharedArgsParser { + return _sap; + } + + AuthCliArgs( + {this.atDirectoryFqdn = defaultAtDirectoryFqdn, + this.atRegistrarFqdn = defaultAtRegistrarFqdn}) { + _aap = createMainParser(); + _sap = createSharedArgParser(hide: false); + } + + /// Creates an ArgParser with commands for each of AuthCliCommand + @visibleForTesting + ArgParser createMainParser() { + final p = ArgParser( + usageLineLength: stdout.hasTerminal ? stdout.terminalColumns : null); + + p.addFlag( + argNameHelp, + abbr: 'h', + negatable: false, + hide: true, + ); + + for (final c in AuthCliCommand.values) { + p.addCommand(c.name, createParserForCommand(c)); + } + + return p; + } + + @visibleForTesting + ArgParser createParserForCommand(AuthCliCommand c) { + switch (c) { + case AuthCliCommand.help: + return createHelpCommandParser(); + + case AuthCliCommand.onboard: + return createOnboardCommandParser(); + + case AuthCliCommand.enroll: + return createEnrollCommandParser(); + + case AuthCliCommand.interactive: + return createInteractiveCommandParser(); + + case AuthCliCommand.spp: + return createSppCommandParser(); + + case AuthCliCommand.otp: + return createOtpCommandParser(); + + case AuthCliCommand.list: + return createListCommandParser(); + + case AuthCliCommand.fetch: + return createFetchCommandParser(); + + case AuthCliCommand.approve: + return createApproveCommandParser(); + + case AuthCliCommand.deny: + return createDenyCommandParser(); + + case AuthCliCommand.denyAllPending: + return createDenyAllPendingCommandParser(); + + case AuthCliCommand.revoke: + return createRevokeCommandParser(); + } + } + + /// Make an ArgParser with the args which are common to every command + @visibleForTesting + ArgParser createSharedArgParser({required bool hide, bool forOnboard=false}) { + ArgParser p = ArgParser( + usageLineLength: stdout.hasTerminal ? stdout.terminalColumns : null); + p.addOption( + argNameAtSign, + abbr: 'a', + help: 'The atSign', + mandatory: true, + hide: hide, + ); + p.addOption( + argNameAtDirectoryFqdn, + abbr: 'r', + help: 'atDirectory (aka root) server\'s domain name', + defaultsTo: atDirectoryFqdn, + mandatory: false, + hide: hide, + ); + p.addOption( + argNameAtKeys, + abbr: 'k', + help: 'Path to atKeys file to create (onboard / enroll) or use (approve / deny / etc)', + mandatory: false, + hide: hide, + ); + p.addFlag( + argNameHelp, + abbr: 'h', + negatable: false, + hide: hide, + ); + p.addFlag( + argNameVerbose, + abbr: 'v', + help: 'INFO-level logging', + negatable: false, + hide: hide, + ); + p.addFlag( + argNameDebug, + help: 'FINEST-level logging', + negatable: false, + hide: true, + ); + p.addOption( + argNameRegistrarFqdn, + abbr: 'g', + help: 'url to the registrar api', + mandatory: false, + defaultsTo: atRegistrarFqdn, + hide: !forOnboard, + ); + p.addOption( + argNameCramSecret, + abbr: 'c', + help: 'CRAM key', + mandatory: false, + hide: !forOnboard, + ); + + return p; + } + + @visibleForTesting + ArgParser createHelpCommandParser() { + ArgParser p = createSharedArgParser(hide: true, forOnboard: false); + + return p; + } + + @visibleForTesting + ArgParser createOnboardCommandParser() { + ArgParser p = createSharedArgParser(hide: true, forOnboard: true); + + return p; + } + + ArgParser createInteractiveCommandParser() { + ArgParser p = createSharedArgParser(hide: true); + return p; + } + + /// auth spp : require atSign, spp [, atKeys path] [, atDirectory] + @visibleForTesting + ArgParser createSppCommandParser() { + ArgParser p = createSharedArgParser(hide: true); + p.addOption( + argNameSpp, + abbr: argAbbrPasscode, + help: 'The semi-permanent enrollment passcode to set for this atSign', + mandatory: true, + ); + return p; + } + + /// auth otp : require atSign [, atKeys path] [, atDirectory] + @visibleForTesting + ArgParser createOtpCommandParser() { + ArgParser p = createSharedArgParser(hide: true); + return p; + } + + /// auth enroll : require atSign, app name, device name, namespaces, otp [, atKeys path] + /// If atKeys file doesn't exist, then this is a new enrollment + /// If it does exist, then the enrollment request has been made and we need + /// to try to auth, and act appropriately on the atServer response + @visibleForTesting + ArgParser createEnrollCommandParser() { + ArgParser p = createSharedArgParser(hide: true); + p.addOption( + argNamePasscode, + abbr: argAbbrPasscode, + help: 'The passcode to present with this enrollment request.', + mandatory: true, + ); + p.addOption( + argNameAppName, + abbr: 'p', + help: 'The name of the app being enrolled', + mandatory: true, + ); + p.addOption( + argNameDeviceName, + abbr: 'd', + help: 'A name for the device on which this app is running', + mandatory: true, + ); + p.addOption( + argNameNamespaceAccessList, + abbr: 'n', + help: + 'The namespace access list as comma-separated list of name:value pairs' + ' e.g. "buzz:rw,contacts:rw,__manage:rw"', + mandatory: true, + ); + return p; + } + + /// auth list-enroll-requests + @visibleForTesting + ArgParser createListCommandParser() { + ArgParser p = createSharedArgParser(hide: true); + p.addOption( + argNameEnrollmentStatus, + abbr: 's', + help: + 'A specific status to filter by; if not supplied, all enrollments will be listed', + allowed: EnrollmentStatus.values.map((c) => c.name).toList(), + mandatory: false, + ); + return p; + } + + /// auth list-enroll-requests + @visibleForTesting + ArgParser createFetchCommandParser() { + ArgParser p = createSharedArgParser(hide: true); + _addEnrollmentIdOption(p); + return p; + } + + void _addEnrollmentIdOption(ArgParser p) { + p.addOption( + argNameEnrollmentId, + abbr: 'i', + help: 'The ID of the enrollment request', + mandatory: true, + ); + } + + /// auth approve + @visibleForTesting + ArgParser createApproveCommandParser() { + ArgParser p = createSharedArgParser(hide: true); + _addEnrollmentIdOption(p); + return p; + } + + /// auth deny + @visibleForTesting + ArgParser createDenyCommandParser() { + ArgParser p = createSharedArgParser(hide: true); + _addEnrollmentIdOption(p); + return p; + } + + /// auth deny all pending + @visibleForTesting + ArgParser createDenyAllPendingCommandParser() { + ArgParser p = createSharedArgParser(hide: true); + return p; + } + + /// auth revoke + @visibleForTesting + ArgParser createRevokeCommandParser() { + ArgParser p = createSharedArgParser(hide: true); + _addEnrollmentIdOption(p); + return p; + } +} diff --git a/packages/at_onboarding_cli/lib/src/cli/cli_args.dart b/packages/at_onboarding_cli/lib/src/cli/cli_args.dart deleted file mode 100644 index 4d88d4d6..00000000 --- a/packages/at_onboarding_cli/lib/src/cli/cli_args.dart +++ /dev/null @@ -1,304 +0,0 @@ -import 'package:args/args.dart'; -import 'package:meta/meta.dart'; - -enum AuthCliCommand { - onboard, - spp, - interactive, - listen, - listEnrollRequests, - approve, - deny, - revoke, - enroll, -} - -class AuthCliArgs { - static const defaultAtDirectoryFqdn = 'root.atsign.org'; - static const defaultAtRegistrarFqdn = 'my.atsign.com'; - late final ArgParser _aap; - - final String atDirectoryFqdn; - final String atRegistrarFqdn; - - static const argNameHelp = 'help'; - static const argNameVerbose = 'verbose'; - static const argNameDebug = 'debug'; - static const argNameAtSign = 'atsign'; - static const argNameCramSecret = 'cramkey'; - static const argNameAtDirectoryFqdn = 'rootServer'; - static const argNameRegistrarFqdn = 'registrarUrl'; - static const argNameSpp = 'spp'; - static const argNameAppName = 'app'; - static const argNameDeviceName = 'device'; - static const argNamePasscode = 'passcode'; - static const argNameNamespaceAccessList = 'namespaces'; - static const argNameEnrollmentId = 'enrollmentId'; - - ArgParser get parser { - return _aap; - } - - AuthCliArgs( - {this.atDirectoryFqdn = defaultAtDirectoryFqdn, - this.atRegistrarFqdn = defaultAtRegistrarFqdn}) { - _aap = createMainParser(); - } - - /// Creates an ArgParser with commands for - /// - onboard - /// - spp - /// - enroll - /// - listEnrollRequests - /// - approve - /// - deny - /// - listEnrollments - /// - revoke - @visibleForTesting - ArgParser createMainParser() { - final p = ArgParser(); - - for (final c in AuthCliCommand.values) { - p.addCommand(c.name, createParserForCommand(c)); - } - - p.addFlag( - argNameHelp, - abbr: 'h', - help: 'Usage instructions', - negatable: false, - ); - p.addFlag( - argNameVerbose, - abbr: 'v', - help: 'INFO-level logging', - negatable: false, - ); - p.addFlag( - argNameDebug, - help: 'FINEST-level logging', - negatable: false, - ); - - return p; - } - - @visibleForTesting - ArgParser createParserForCommand(AuthCliCommand c) { - switch (c) { - case AuthCliCommand.onboard: - return createOnboardCommandParser(); - - case AuthCliCommand.enroll: - return createEnrollCommandParser(); - - case AuthCliCommand.interactive: - return createInteractiveCommandParser(); - - case AuthCliCommand.listen: - return createListenCommandParser(); - - case AuthCliCommand.spp: - return createSppCommandParser(); - - case AuthCliCommand.listEnrollRequests: - return createListEnrollRequestsCommandParser(); - - case AuthCliCommand.approve: - return createApproveCommandParser(); - - case AuthCliCommand.deny: - return createDenyCommandParser(); - - case AuthCliCommand.revoke: - return createRevokeCommandParser(); - } - } - - /// auth onboard : require atSign, [, cram, atDirectory, atRegistrar] - /// When the cram arg is not supplied, we first use the registrar API - /// to send an OTP to the user and then use that OTP to obtain the cram - /// secret from the registrar. - @visibleForTesting - ArgParser createOnboardCommandParser() { - ArgParser p = ArgParser(); - p.addOption( - argNameAtSign, - abbr: 'a', - help: 'atSign to activate', - mandatory: true, - ); - p.addOption( - argNameCramSecret, - abbr: 'c', - help: 'CRAM key', - mandatory: false, - ); - p.addOption( - argNameAtDirectoryFqdn, - abbr: 'r', - help: 'atDirectory (root) server\'s domain name', - defaultsTo: atDirectoryFqdn, - mandatory: false, - ); - p.addOption( - argNameRegistrarFqdn, - abbr: 'g', - help: 'url to the registrar api', - mandatory: false, - defaultsTo: atRegistrarFqdn, - ); - p.addFlag( - argNameHelp, - abbr: 'h', - help: 'Usage instructions', - negatable: false, - ); - - return p; - } - - ArgParser createInteractiveCommandParser() { - final p = ArgParser(); - - p.addOption( - argNameAtSign, - abbr: 'a', - help: 'The atSign', - mandatory: true, - ); - p.addOption( - argNameAtDirectoryFqdn, - abbr: 'r', - help: 'root server\'s domain name', - defaultsTo: atDirectoryFqdn, - mandatory: false, - ); - return p; - } - - ArgParser createListenCommandParser() { - final p = ArgParser(); - - p.addOption( - argNameAtSign, - abbr: 'a', - help: 'The atSign', - mandatory: true, - ); - p.addOption( - argNameAtDirectoryFqdn, - abbr: 'r', - help: 'root server\'s domain name', - defaultsTo: atDirectoryFqdn, - mandatory: false, - ); - return p; - } - - /// auth spp : require atSign, spp [, atKeys path] [, atDirectory] - @visibleForTesting - ArgParser createSppCommandParser() { - final p = ArgParser(); - p.addOption( - argNameAtSign, - abbr: 'a', - help: 'The atSign for which we are setting the spp (semi-permanent-pin)', - mandatory: true, - ); - p.addOption( - argNameSpp, - abbr: 's', - help: 'The semi-permanent enrollment pin to set for this atSign', - mandatory: true, - ); - p.addOption( - argNameAtDirectoryFqdn, - abbr: 'r', - help: 'root server\'s domain name', - defaultsTo: atDirectoryFqdn, - mandatory: false, - ); - return p; - } - - /// auth enroll : require atSign, app name, device name, otp [, atKeys path] - /// If atKeys file doesn't exist, then this is a new enrollment - /// If it does exist, then the enrollment request has been made and we need - /// to try to auth, and act appropriately on the atServer response - @visibleForTesting - ArgParser createEnrollCommandParser() { - final p = ArgParser(); - p.addOption( - argNameAtSign, - abbr: 'a', - help: 'The atSign for which we are setting the spp (semi-permanent-pin)', - mandatory: true, - ); - p.addOption( - argNamePasscode, - abbr: 'p', - help: 'The passcode to present with this enrollment request.', - mandatory: true, - ); - p.addOption( - argNameAppName, - help: 'The name of the app being enrolled', - mandatory: true, - ); - p.addOption( - argNameDeviceName, - help: 'A name for the device on which this app is running', - mandatory: true, - ); - p.addOption( - argNameAtDirectoryFqdn, - abbr: 'r', - help: 'atDirectory (root) server\'s domain name', - defaultsTo: atDirectoryFqdn, - mandatory: false, - ); - p.addOption( - argNameRegistrarFqdn, - abbr: 'g', - help: 'url to the registrar api', - mandatory: false, - defaultsTo: atRegistrarFqdn, - ); - p.addFlag( - argNameHelp, - abbr: 'h', - help: 'Usage instructions', - negatable: false, - ); - return p; - } - - /// auth list-enroll-requests - @visibleForTesting - ArgParser createListEnrollRequestsCommandParser() { - final p = ArgParser(); - return p; - } - - /// auth approve - @visibleForTesting - ArgParser createApproveCommandParser() { - final p = ArgParser(); - return p; - } - - /// auth deny - @visibleForTesting - ArgParser createDenyCommandParser() { - final p = ArgParser(); - return p; - } - - /// auth revoke - @visibleForTesting - ArgParser createRevokeCommandParser() { - final p = ArgParser(); - return p; - } -} diff --git a/packages/at_onboarding_cli/lib/src/util/print_full_parser_usage.dart b/packages/at_onboarding_cli/lib/src/util/print_full_parser_usage.dart index 47a06cc3..16cfb794 100644 --- a/packages/at_onboarding_cli/lib/src/util/print_full_parser_usage.dart +++ b/packages/at_onboarding_cli/lib/src/util/print_full_parser_usage.dart @@ -5,23 +5,41 @@ import 'package:args/args.dart'; extension PrintAllArgParserUsage on ArgParser { static final String singleIndentation = ' '; - printAllCommandsUsage( - {String commandName = 'Usage:', IOSink? sink, int indent = 0}) { + printAllCommandsUsage({ + String? header, + IOSink? sink, + int indent = 0, + bool showParams = true, + bool showSubCommandParams = false, + }) { sink ??= stderr; // header message - _writelnWithIndentation(sink, indent, commandName); + if (header != null) { + _writelnWithIndentation(sink, indent, header); + } - // this parser usage - List usageLines = usage.split('\n'); - for (final l in usageLines) { - _writelnWithIndentation(sink, indent + 1, l); + if (showParams) { + // this parser usage + List usageLines = usage.split('\n'); + for (final l in usageLines) { + _writelnWithIndentation(sink, indent + 1, l); + } } - // sub-parsers usage - for (final n in commands.keys) { - commands[n]!.printAllCommandsUsage( - commandName: n, sink: sink, indent: (indent + 1)); + if (commands.isNotEmpty) { + _writelnWithIndentation( + sink, indent, 'Commands: (use " -h" for help)'); + // sub-parsers usage + for (final n in commands.keys) { + commands[n]!.printAllCommandsUsage( + header: n, + sink: sink, + indent: (indent + 1), + showParams: showSubCommandParams, + showSubCommandParams: showSubCommandParams, + ); + } } } diff --git a/packages/at_onboarding_cli/pubspec.yaml b/packages/at_onboarding_cli/pubspec.yaml index 3dc98343..7d88a2f4 100644 --- a/packages/at_onboarding_cli/pubspec.yaml +++ b/packages/at_onboarding_cli/pubspec.yaml @@ -28,6 +28,7 @@ dependencies: at_lookup: ^3.0.46 at_server_status: ^1.0.4 at_utils: ^3.0.16 + at_cli_commons: ^1.0.5 dev_dependencies: lints: ^2.1.0 diff --git a/tests/at_onboarding_cli_functional_tests/test/enrollment_test.dart b/tests/at_onboarding_cli_functional_tests/test/enrollment_test.dart index 1efab89f..df17df55 100644 --- a/tests/at_onboarding_cli_functional_tests/test/enrollment_test.dart +++ b/tests/at_onboarding_cli_functional_tests/test/enrollment_test.dart @@ -27,7 +27,6 @@ void main() { String atSign = '@naresh🛠'; //1. Onboard first client AtOnboardingPreference preference_1 = getPreferenceForAuth(atSign); - preference_1..enableEnrollmentDuringOnboard = true; AtOnboardingService? onboardingService_1 = AtOnboardingServiceImpl(atSign, preference_1); bool status = await onboardingService_1.onboard(); @@ -121,7 +120,6 @@ void main() { String atSign = '@ashish🛠'; //1. Onboard first client AtOnboardingPreference preference_1 = getPreferenceForAuth(atSign); - preference_1.enableEnrollmentDuringOnboard = false; AtOnboardingService? onboardingService_1 = AtOnboardingServiceImpl(atSign, preference_1); bool status = await onboardingService_1.onboard(); @@ -148,8 +146,7 @@ void main() { ..cramSecret = at_demos.cramKeyMap[atSign] ..namespace = 'wavi' // unique identifier that can be used to identify data from your app - ..rootDomain = 'vip.ve.atsign.zone' - ..enableEnrollmentDuringOnboard = true; + ..rootDomain = 'vip.ve.atsign.zone'; AtOnboardingService? onboardingService_1 = AtOnboardingServiceImpl(atSign, preference_1); @@ -174,7 +171,6 @@ void main() { String atSign = '@purnima🛠'; //1. Onboard first client AtOnboardingPreference preference_1 = getPreferenceForAuth(atSign); - preference_1.enableEnrollmentDuringOnboard = true; AtOnboardingService? onboardingService_1 = AtOnboardingServiceImpl(atSign, preference_1); bool status = await onboardingService_1.onboard(); @@ -197,7 +193,7 @@ void main() { expect(totp.length, 6); expect( totp.contains('0') || totp.contains('o') || totp.contains('O'), false); - // check whether otp contains atleast one number and one alphabet + // check whether otp contains at least one number and one alphabet expect(RegExp(r'^(?=.*[a-zA-Z])(?=.*\d).+$').hasMatch(totp), true); //4. enroll second client AtOnboardingPreference enrollPreference_2 = getPreferenceForEnroll(atSign); From 8608953e1277b0498031ddb08ed1cb8821226ca6 Mon Sep 17 00:00:00 2001 From: gkc Date: Sat, 27 Apr 2024 09:44:39 +0100 Subject: [PATCH 07/19] use new enroll:fetch:$id to fetch a specific enrollment --- .../at_onboarding_cli/lib/src/cli/auth_cli.dart | 16 +++------------- 1 file changed, 3 insertions(+), 13 deletions(-) diff --git a/packages/at_onboarding_cli/lib/src/cli/auth_cli.dart b/packages/at_onboarding_cli/lib/src/cli/auth_cli.dart index 2d68f7d3..4db7f1fc 100644 --- a/packages/at_onboarding_cli/lib/src/cli/auth_cli.dart +++ b/packages/at_onboarding_cli/lib/src/cli/auth_cli.dart @@ -456,25 +456,15 @@ Future list(ArgResults ar, AtClient atClient) async { Future _fetch(String eId, AtLookUp atLookup) async { String rawResponse = (await atLookup.executeCommand( - 'enroll:list:' + 'enroll:fetch:' '{"enrollmentId":"$eId"}' '\n', auth: true))!; if (rawResponse.startsWith('data:')) { rawResponse = rawResponse.substring(rawResponse.indexOf('data:') + 5); - // response is a Map of enrollmentId:enrollmentJson - final Map json = jsonDecode(rawResponse); - if (json.keys.isEmpty) { - return null; - } else { - if (json.keys.length > 1) { - logger.shout('Error: Fetched more than one enrollment request' - ' - will return the first one'); - logger.shout(json); - } - return json[json.keys.first]; - } + // response is a Map + return jsonDecode(rawResponse); } else { logger.shout('Exiting: Unexpected server response: $rawResponse'); exit(1); From 459bce52593a9dda6c78b697d733d69bc476e167 Mon Sep 17 00:00:00 2001 From: gkc Date: Wed, 1 May 2024 13:30:14 +0100 Subject: [PATCH 08/19] docs: update CHANGELOG --- packages/at_onboarding_cli/pubspec.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/at_onboarding_cli/pubspec.yaml b/packages/at_onboarding_cli/pubspec.yaml index 7d88a2f4..b463e3de 100644 --- a/packages/at_onboarding_cli/pubspec.yaml +++ b/packages/at_onboarding_cli/pubspec.yaml @@ -24,8 +24,8 @@ dependencies: at_auth: ^2.0.2 at_chops: ^2.0.0 at_client: ^3.0.75 - at_commons: ^4.0.5 - at_lookup: ^3.0.46 + at_commons: ^4.0.8 + at_lookup: ^3.0.47 at_server_status: ^1.0.4 at_utils: ^3.0.16 at_cli_commons: ^1.0.5 From a0349bcc5d01e6c9ac459dd079807a60b538a81d Mon Sep 17 00:00:00 2001 From: gkc Date: Mon, 6 May 2024 13:52:54 +0100 Subject: [PATCH 09/19] feat: at_onboarding_cli: Added regex support for list / approve / deny / revoke in auth_cli --- .../lib/src/cli/auth_cli.dart | 212 +++++++++++++----- .../lib/src/cli/auth_cli_args.dart | 43 ++-- packages/at_onboarding_cli/pubspec.yaml | 4 +- 3 files changed, 187 insertions(+), 72 deletions(-) diff --git a/packages/at_onboarding_cli/lib/src/cli/auth_cli.dart b/packages/at_onboarding_cli/lib/src/cli/auth_cli.dart index 4db7f1fc..b3235e86 100644 --- a/packages/at_onboarding_cli/lib/src/cli/auth_cli.dart +++ b/packages/at_onboarding_cli/lib/src/cli/auth_cli.dart @@ -156,10 +156,6 @@ Future _main(List arguments) async { case AuthCliCommand.deny: await deny(commandArgResults, await createAtClient(commandArgResults)); - case AuthCliCommand.denyAllPending: - await denyAllPending( - commandArgResults, await createAtClient(commandArgResults)); - case AuthCliCommand.revoke: await revoke( commandArgResults, await createAtClient(commandArgResults)); @@ -405,9 +401,6 @@ Future interactive(ArgResults argResults, AtClient atClient) async { case AuthCliCommand.deny: await deny(commandArgResults, atClient); - case AuthCliCommand.denyAllPending: - await denyAllPending(commandArgResults, atClient); - case AuthCliCommand.revoke: await revoke(commandArgResults, atClient); } @@ -419,7 +412,12 @@ Future interactive(ArgResults argResults, AtClient atClient) async { } } -Future _list(String? statusFilter, AtLookUp atLookup) async { +Future _list( + String? statusFilter, + AtLookUp atLookup, { + String? arx, + String? drx, +}) async { String command = 'enroll:list'; if (statusFilter != null) { command += ':{"enrollmentStatusFilter":["$statusFilter"]}'; @@ -429,9 +427,36 @@ Future _list(String? statusFilter, AtLookUp atLookup) async { auth: true, ))!; + RegExp? ar; + RegExp? dr; + if (arx != null) { + ar = RegExp(arx); + } + if (drx != null) { + dr = RegExp(drx); + } if (rawResponse.startsWith('data:')) { rawResponse = rawResponse.substring(rawResponse.indexOf('data:') + 5); - return jsonDecode(rawResponse); + Map unfiltered = jsonDecode(rawResponse); + Map filtered = {}; + for (final String ek in unfiltered.keys) { + final e = unfiltered[ek]; + String appName = e['appName'] as String; + if (ar != null) { + if (! ar.hasMatch(appName)) { + continue; + } + } + String deviceName = e['deviceName'] as String; + if (dr != null) { + if (! dr.hasMatch(deviceName)) { + continue; + } + } + filtered[ek.substring(0, ek.indexOf('.'))] = e; + } + logger.shout("Found ${filtered.length} matching enrollment records"); + return filtered; } else { logger.shout('Exiting: Unexpected server response: $rawResponse'); exit(1); @@ -442,15 +467,26 @@ Future list(ArgResults ar, AtClient atClient) async { AtLookUp atLookup = atClient.getRemoteSecondary()!.atLookUp; String? statusFilter = ar[AuthCliArgs.argNameEnrollmentStatus]; - - Map json = await _list(statusFilter, atLookup); + String? arx = ar[AuthCliArgs.argNameAppNameRegex]; + String? drx = ar[AuthCliArgs.argNameDeviceNameRegex]; + + Map json = await _list(statusFilter, atLookup, arx: arx, drx: drx); + stdout.write('Enrollment ID'.padRight(38)); + stdout.write('Status'.padRight(10)); + stdout.write('AppName'.padRight(20)); + stdout.write('DeviceName'.padRight(38)); + stdout.writeln('Namespaces'); for (final eId in json.keys) { - stderr.writeln('Enrollment ID: $eId'); final e = json[eId] as Map; - stderr.writeln(' Status: ${e['status']}'); - stderr.writeln(' App Name: ${e['appName']}'); - stderr.writeln(' Device Name: ${e['deviceName']}'); - stderr.writeln(' Namespaces: ${e['namespace']}'); + final String status = e['status']; + final String appName = e['appName']; + final String deviceName = e['deviceName']; + final namespaces = e['namespace']; + stdout.writeln('${eId.padRight(38)}' + '${status.padRight(10)}' + '${appName.padRight(20)}' + '${deviceName.padRight(38)}' + '$namespaces'); } } @@ -484,62 +520,126 @@ Future fetch(ArgResults argResults, AtClient atClient) async { } } -Future approve(ArgResults argResults, AtClient atClient) async { - String eId = argResults[AuthCliArgs.argNameEnrollmentId]; - AtLookUp atLookup = atClient.getRemoteSecondary()!.atLookUp; +Future _fetchOrListAndFilter( + AtLookUp atLookup, { + String? eId, + String? statusFilter, + String? arx, + String? drx, +}) async { + if (eId == null && arx == null && drx == null) { + logger.shout('At least one of' + ' ${AuthCliArgs.argNameEnrollmentId},' + ' ${AuthCliArgs.argNameAppNameRegex}' + ' or ${AuthCliArgs.argNameDeviceNameRegex}' + ' must be provided'); + return {}; + } - // First fetch the enrollment request - Map? er = await _fetch(eId, atLookup); - if (er == null) { - logger.shout('Enrollment ID $eId not found'); - return; + Map enrollmentMap = {}; + if (eId != null) { + // First fetch the enrollment request + Map? er = await _fetch(eId, atLookup); + if (er == null) { + logger.shout('Enrollment ID $eId not found'); + } + enrollmentMap[eId] = er; + } else { + enrollmentMap = await _list( + statusFilter, + atLookup, + arx: arx, + drx: drx, + ); } + return enrollmentMap; +} + +Future approve(ArgResults ar, AtClient atClient) async { + AtLookUp atLookup = atClient.getRemoteSecondary()!.atLookUp; - stderr.writeln('Fetched enrollment OK: $er'); + Map toApprove = await _fetchOrListAndFilter( + atLookup, + statusFilter: EnrollmentStatus.pending.name, // must be status pending + eId: ar[AuthCliArgs.argNameEnrollmentId], + arx: ar[AuthCliArgs.argNameAppNameRegex], + drx: ar[AuthCliArgs.argNameDeviceNameRegex], + ); - // Then make a 'decision' object using data from the enrollment request - EnrollmentRequestDecision decision = EnrollmentRequestDecision.approved( - ApprovedRequestDecisionBuilder( - enrollmentId: eId, - encryptedAPKAMSymmetricKey: er['encryptedAPKAMSymmetricKey'])); + if (toApprove.isEmpty) { + logger.shout('No matching enrollment(s) found'); + return; + } - // Finally call approve method via an AtEnrollment object - final response = await atAuthBase - .atEnrollment(atClient.getCurrentAtSign()!) - .approve(decision, atLookup); - // 'enroll:approve:{"enrollmentId":"$enrollmentId"}' - logger.shout('Server response: $response'); + // Iterate through the requests, approve each one + for (String eId in toApprove.keys) { + Map er = toApprove[eId]; + logger.shout('Approving enrollmentId $eId'); + // Then make a 'decision' object using data from the enrollment request + EnrollmentRequestDecision decision = EnrollmentRequestDecision.approved( + ApprovedRequestDecisionBuilder( + enrollmentId: eId, + encryptedAPKAMSymmetricKey: er['encryptedAPKAMSymmetricKey'])); + + // Finally call approve method via an AtEnrollment object + final response = await atAuthBase + .atEnrollment(atClient.getCurrentAtSign()!) + .approve(decision, atLookup); + // 'enroll:approve:{"enrollmentId":"$enrollmentId"}' + logger.shout('Server response: $response'); + } } -Future deny(ArgResults argResults, AtClient atClient) async { - String eId = argResults[AuthCliArgs.argNameEnrollmentId]; +Future deny(ArgResults ar, AtClient atClient) async { AtLookUp atLookup = atClient.getRemoteSecondary()!.atLookUp; - // 'enroll:deny:{"enrollmentId":"$enrollmentId"}' - String? response = await atLookup - .executeCommand('enroll:deny:{"enrollmentId":"$eId"}\n', auth: true); - logger.shout('Server response: $response'); -} -Future denyAllPending(ArgResults argResults, AtClient atClient) async { - AtLookUp atLookup = atClient.getRemoteSecondary()!.atLookUp; - Map json = await _list(EnrollmentStatus.pending.name, atLookup); - for (final String eIdKey in json.keys) { - String eId = eIdKey.substring(0, eIdKey.indexOf('.')); - stderr.writeln('Denying enrollment request: $eId ($eIdKey)'); + Map toDeny = await _fetchOrListAndFilter( + atLookup, + statusFilter: EnrollmentStatus.pending.name, // must be status pending + eId: ar[AuthCliArgs.argNameEnrollmentId], + arx: ar[AuthCliArgs.argNameAppNameRegex], + drx: ar[AuthCliArgs.argNameDeviceNameRegex], + ); + + if (toDeny.isEmpty) { + logger.shout('No matching enrollment(s) found'); + return; + } + + // Iterate through the requests, deny each one + for (String eId in toDeny.keys) { + logger.shout('Denying enrollmentId $eId'); // 'enroll:deny:{"enrollmentId":"$enrollmentId"}' String? response = await atLookup .executeCommand('enroll:deny:{"enrollmentId":"$eId"}\n', auth: true); - stderr.writeln(' => Server response: $response'); + logger.shout('Server response: $response'); } } -Future revoke(ArgResults argResults, AtClient atClient) async { - String eId = argResults[AuthCliArgs.argNameEnrollmentId]; +Future revoke(ArgResults ar, AtClient atClient) async { AtLookUp atLookup = atClient.getRemoteSecondary()!.atLookUp; - // 'enroll:revoke:{"enrollmentid":"$enrollmentId"}' - String? response = await atLookup - .executeCommand('enroll:revoke:{"enrollmentId":"$eId"}\n', auth: true); - logger.shout('Server response: $response'); + + Map toRevoke = await _fetchOrListAndFilter( + atLookup, + statusFilter: EnrollmentStatus.approved.name, // must be status approved + eId: ar[AuthCliArgs.argNameEnrollmentId], + arx: ar[AuthCliArgs.argNameAppNameRegex], + drx: ar[AuthCliArgs.argNameDeviceNameRegex], + ); + + if (toRevoke.isEmpty) { + logger.shout('No matching enrollment(s) found'); + return; + } + + // Iterate through the requests, revoke each one + for (String eId in toRevoke.keys) { + logger.shout('Revoking enrollmentId $eId'); + // 'enroll:revoke:{"enrollmentid":"$enrollmentId"}' + String? response = await atLookup + .executeCommand('enroll:revoke:{"enrollmentId":"$eId"}\n', auth: true); + logger.shout('Server response: $response'); + } } @visibleForTesting diff --git a/packages/at_onboarding_cli/lib/src/cli/auth_cli_args.dart b/packages/at_onboarding_cli/lib/src/cli/auth_cli_args.dart index 3ca8de87..4d695598 100644 --- a/packages/at_onboarding_cli/lib/src/cli/auth_cli_args.dart +++ b/packages/at_onboarding_cli/lib/src/cli/auth_cli_args.dart @@ -35,7 +35,6 @@ enum AuthCliCommand { fetch(usage: 'Fetch a specific enrollment request'), approve(usage: 'Approve a pending enrollment request'), deny(usage: 'Deny a pending enrollment request'), - denyAllPending(usage: 'Deny all pending enrollment requests'), revoke(usage: 'Revoke approval of a previously-approved enrollment'), enroll(usage: 'Enroll is used when a program needs to authenticate and' ' "atKeys" are not available, and "onboard" has already been run' @@ -81,6 +80,8 @@ class AuthCliArgs { static const argNameNamespaceAccessList = 'namespaces'; static const argNameEnrollmentId = 'enrollmentId'; static const argNameEnrollmentStatus = 'enrollmentStatus'; + static const argNameAppNameRegex = 'arx'; + static const argNameDeviceNameRegex = 'drx'; ArgParser get parser { return _aap; @@ -150,9 +151,6 @@ class AuthCliArgs { case AuthCliCommand.deny: return createDenyCommandParser(); - case AuthCliCommand.denyAllPending: - return createDenyAllPendingCommandParser(); - case AuthCliCommand.revoke: return createRevokeCommandParser(); } @@ -310,6 +308,8 @@ class AuthCliArgs { allowed: EnrollmentStatus.values.map((c) => c.name).toList(), mandatory: false, ); + _addAppNameRegexOption(p); + _addDeviceNameRegexOption(p); return p; } @@ -317,16 +317,32 @@ class AuthCliArgs { @visibleForTesting ArgParser createFetchCommandParser() { ArgParser p = createSharedArgParser(hide: true); - _addEnrollmentIdOption(p); + _addEnrollmentIdOption(p, mandatory: true); return p; } - void _addEnrollmentIdOption(ArgParser p) { + void _addAppNameRegexOption(ArgParser p) { + p.addOption( + argNameAppNameRegex, + help: 'Filter responses via regular expression on app name', + mandatory: false, + ); + } + + void _addDeviceNameRegexOption(ArgParser p) { + p.addOption( + argNameDeviceNameRegex, + help: 'Filter responses via regular expression on device name', + mandatory: false, + ); + } + + void _addEnrollmentIdOption(ArgParser p, {bool mandatory = false}) { p.addOption( argNameEnrollmentId, abbr: 'i', help: 'The ID of the enrollment request', - mandatory: true, + mandatory: mandatory, ); } @@ -335,6 +351,8 @@ class AuthCliArgs { ArgParser createApproveCommandParser() { ArgParser p = createSharedArgParser(hide: true); _addEnrollmentIdOption(p); + _addAppNameRegexOption(p); + _addDeviceNameRegexOption(p); return p; } @@ -343,13 +361,8 @@ class AuthCliArgs { ArgParser createDenyCommandParser() { ArgParser p = createSharedArgParser(hide: true); _addEnrollmentIdOption(p); - return p; - } - - /// auth deny all pending - @visibleForTesting - ArgParser createDenyAllPendingCommandParser() { - ArgParser p = createSharedArgParser(hide: true); + _addAppNameRegexOption(p); + _addDeviceNameRegexOption(p); return p; } @@ -358,6 +371,8 @@ class AuthCliArgs { ArgParser createRevokeCommandParser() { ArgParser p = createSharedArgParser(hide: true); _addEnrollmentIdOption(p); + _addAppNameRegexOption(p); + _addDeviceNameRegexOption(p); return p; } } diff --git a/packages/at_onboarding_cli/pubspec.yaml b/packages/at_onboarding_cli/pubspec.yaml index b463e3de..e04c4f87 100644 --- a/packages/at_onboarding_cli/pubspec.yaml +++ b/packages/at_onboarding_cli/pubspec.yaml @@ -13,12 +13,12 @@ executables: at_activate: activate_cli dependencies: - args: ^2.4.2 + args: ^2.5.0 crypton: ^2.2.1 encrypt: ^5.0.3 http: ^1.2.1 image: ^4.1.7 - meta: ^1.12.0 + meta: ^1.14.0 path: ^1.9.0 zxing2: ^0.2.0 at_auth: ^2.0.2 From 6ebe04bfff865ed3a997c621724fc67ea3fe8291 Mon Sep 17 00:00:00 2001 From: gkc Date: Mon, 6 May 2024 13:57:43 +0100 Subject: [PATCH 10/19] build: at_onboarding_cli: pubspec and CHANGELOG for 1.5.0 --- packages/at_onboarding_cli/CHANGELOG.md | 7 +++++++ packages/at_onboarding_cli/pubspec.yaml | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/packages/at_onboarding_cli/CHANGELOG.md b/packages/at_onboarding_cli/CHANGELOG.md index da7b1a03..b18e7e02 100644 --- a/packages/at_onboarding_cli/CHANGELOG.md +++ b/packages/at_onboarding_cli/CHANGELOG.md @@ -1,3 +1,10 @@ +## 1.5.0 +- feat: 'activate' CLI is now APKAM-aware, and supports + - onboarding (as before) + - submitting enrollment requests + - listing / approving / denying / revoking enrollment requests + - generating one-time passcodes + - setting semi-permanent passcode ## 1.4.4 - feat: uptake changes for at_auth 2.0.0 - build[deps]: upgrade at_auth to 2.0.2 | at_lookup to 3.0.46 | at_client to 3.0.75 \ diff --git a/packages/at_onboarding_cli/pubspec.yaml b/packages/at_onboarding_cli/pubspec.yaml index e04c4f87..e5f41217 100644 --- a/packages/at_onboarding_cli/pubspec.yaml +++ b/packages/at_onboarding_cli/pubspec.yaml @@ -1,6 +1,6 @@ name: at_onboarding_cli description: Dart tool to authenticate, onboard and perform complex operations on atSign seccondaries from command-line-interface. -version: 1.4.4 +version: 1.5.0 repository: https://github.com/atsign-foundation/at_libraries homepage: https://atsign.com documentation: https://docs.atsign.com/ From 9466d641cd3f9b6dbd006af5f6dd3aef71b6b93d Mon Sep 17 00:00:00 2001 From: gkc Date: Mon, 6 May 2024 14:01:44 +0100 Subject: [PATCH 11/19] docs: at_onboarding_cli: tweaked pub description --- packages/at_onboarding_cli/pubspec.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/at_onboarding_cli/pubspec.yaml b/packages/at_onboarding_cli/pubspec.yaml index e5f41217..dbf3293e 100644 --- a/packages/at_onboarding_cli/pubspec.yaml +++ b/packages/at_onboarding_cli/pubspec.yaml @@ -1,5 +1,5 @@ name: at_onboarding_cli -description: Dart tool to authenticate, onboard and perform complex operations on atSign seccondaries from command-line-interface. +description: Dart tools for initial client onboarding, subsequent client enrollment, and enrollment management. version: 1.5.0 repository: https://github.com/atsign-foundation/at_libraries homepage: https://atsign.com From 6341a37022a4bfb4620ee1cdb88b5637ce6304a5 Mon Sep 17 00:00:00 2001 From: gkc Date: Mon, 6 May 2024 14:12:13 +0100 Subject: [PATCH 12/19] feat: at_onboarding_cli: auth_cli throw ArgumentError when appropriate --- .../lib/src/cli/auth_cli.dart | 19 +++++++++---------- 1 file changed, 9 insertions(+), 10 deletions(-) diff --git a/packages/at_onboarding_cli/lib/src/cli/auth_cli.dart b/packages/at_onboarding_cli/lib/src/cli/auth_cli.dart index b3235e86..4fa7aa03 100644 --- a/packages/at_onboarding_cli/lib/src/cli/auth_cli.dart +++ b/packages/at_onboarding_cli/lib/src/cli/auth_cli.dart @@ -521,19 +521,18 @@ Future fetch(ArgResults argResults, AtClient atClient) async { } Future _fetchOrListAndFilter( - AtLookUp atLookup, { + AtLookUp atLookup, + String statusFilter, { String? eId, - String? statusFilter, String? arx, String? drx, }) async { if (eId == null && arx == null && drx == null) { - logger.shout('At least one of' - ' ${AuthCliArgs.argNameEnrollmentId},' - ' ${AuthCliArgs.argNameAppNameRegex}' - ' or ${AuthCliArgs.argNameDeviceNameRegex}' + throw ArgumentError('At least one of' + ' --${AuthCliArgs.argNameEnrollmentId},' + ' --${AuthCliArgs.argNameAppNameRegex}' + ' or --${AuthCliArgs.argNameDeviceNameRegex}' ' must be provided'); - return {}; } Map enrollmentMap = {}; @@ -560,7 +559,7 @@ Future approve(ArgResults ar, AtClient atClient) async { Map toApprove = await _fetchOrListAndFilter( atLookup, - statusFilter: EnrollmentStatus.pending.name, // must be status pending + EnrollmentStatus.pending.name, // must be status pending eId: ar[AuthCliArgs.argNameEnrollmentId], arx: ar[AuthCliArgs.argNameAppNameRegex], drx: ar[AuthCliArgs.argNameDeviceNameRegex], @@ -595,7 +594,7 @@ Future deny(ArgResults ar, AtClient atClient) async { Map toDeny = await _fetchOrListAndFilter( atLookup, - statusFilter: EnrollmentStatus.pending.name, // must be status pending + EnrollmentStatus.pending.name, // must be status pending eId: ar[AuthCliArgs.argNameEnrollmentId], arx: ar[AuthCliArgs.argNameAppNameRegex], drx: ar[AuthCliArgs.argNameDeviceNameRegex], @@ -621,7 +620,7 @@ Future revoke(ArgResults ar, AtClient atClient) async { Map toRevoke = await _fetchOrListAndFilter( atLookup, - statusFilter: EnrollmentStatus.approved.name, // must be status approved + EnrollmentStatus.approved.name, // must be status approved eId: ar[AuthCliArgs.argNameEnrollmentId], arx: ar[AuthCliArgs.argNameAppNameRegex], drx: ar[AuthCliArgs.argNameDeviceNameRegex], From e0ecbc9bad8f32228d9392190c441325fd3c374d Mon Sep 17 00:00:00 2001 From: gkc Date: Tue, 7 May 2024 11:38:02 +0100 Subject: [PATCH 13/19] fix: auth_cli: write generated OTP to stdout --- packages/at_onboarding_cli/lib/src/cli/auth_cli.dart | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/at_onboarding_cli/lib/src/cli/auth_cli.dart b/packages/at_onboarding_cli/lib/src/cli/auth_cli.dart index 4fa7aa03..73592eab 100644 --- a/packages/at_onboarding_cli/lib/src/cli/auth_cli.dart +++ b/packages/at_onboarding_cli/lib/src/cli/auth_cli.dart @@ -310,6 +310,7 @@ Future setSpp(ArgResults argResults, AtClient atClient) async { // send command 'otp:put:$spp' String? response = await atLookup.executeCommand('otp:put:$spp\n', auth: true); + logger.shout('Server response: $response'); } @@ -319,7 +320,11 @@ Future generateOtp(ArgResults argResults, AtClient atClient) async { // send command 'otp:get[:ttl:$ttl]' String? response = await atLookup.executeCommand('otp:get\n', auth: true); - logger.shout('Server response: $response'); + if (response != null && response.startsWith('data:')) { + stdout.writeln(response.substring('data:'.length)); + } else { + logger.shout('Failed to generate OTP: server response was $response'); + } } /// Only usable if there are atKeys already available. From 0b7b023819e3c5e77dff7274f3b35c0eb27a8ac7 Mon Sep 17 00:00:00 2001 From: gkc Date: Tue, 7 May 2024 12:37:48 +0100 Subject: [PATCH 14/19] build: at_onboarding_cli: upgrade at_cli_commons dependency --- .../lib/src/cli/auth_cli.dart | 24 ++++++++++--------- packages/at_onboarding_cli/pubspec.yaml | 7 ++++++ 2 files changed, 20 insertions(+), 11 deletions(-) diff --git a/packages/at_onboarding_cli/lib/src/cli/auth_cli.dart b/packages/at_onboarding_cli/lib/src/cli/auth_cli.dart index 73592eab..758443aa 100644 --- a/packages/at_onboarding_cli/lib/src/cli/auth_cli.dart +++ b/packages/at_onboarding_cli/lib/src/cli/auth_cli.dart @@ -193,15 +193,17 @@ Future createAtClient(ArgResults ar) async { String nameSpace = 'at_auth_cli'; String atSign = AtUtils.fixAtSign(ar[AuthCliArgs.argNameAtSign]); CLIBase cliBase = CLIBase( - atSign: atSign, - atKeysFilePath: ar[AuthCliArgs.argNameAtKeys], - nameSpace: nameSpace, - rootDomain: ar[AuthCliArgs.argNameAtDirectoryFqdn], - homeDir: getHomeDirectory(), - storageDir: '${getHomeDirectory()}/.atsign/$nameSpace/$atSign/storage' - .replaceAll('/', Platform.pathSeparator), - verbose: ar[AuthCliArgs.argNameVerbose] || ar[AuthCliArgs.argNameDebug], - syncDisabled: true); + atSign: atSign, + atKeysFilePath: ar[AuthCliArgs.argNameAtKeys], + nameSpace: nameSpace, + rootDomain: ar[AuthCliArgs.argNameAtDirectoryFqdn], + homeDir: getHomeDirectory(), + storageDir: '${getHomeDirectory()}/.atsign/$nameSpace/$atSign/storage' + .replaceAll('/', Platform.pathSeparator), + verbose: ar[AuthCliArgs.argNameVerbose] || ar[AuthCliArgs.argNameDebug], + syncDisabled: true, + maxConnectAttempts: 10, // 10 * 3 == 30 seconds + ); await cliBase.init(); @@ -448,13 +450,13 @@ Future _list( final e = unfiltered[ek]; String appName = e['appName'] as String; if (ar != null) { - if (! ar.hasMatch(appName)) { + if (!ar.hasMatch(appName)) { continue; } } String deviceName = e['deviceName'] as String; if (dr != null) { - if (! dr.hasMatch(deviceName)) { + if (!dr.hasMatch(deviceName)) { continue; } } diff --git a/packages/at_onboarding_cli/pubspec.yaml b/packages/at_onboarding_cli/pubspec.yaml index dbf3293e..94ed7552 100644 --- a/packages/at_onboarding_cli/pubspec.yaml +++ b/packages/at_onboarding_cli/pubspec.yaml @@ -12,6 +12,13 @@ executables: at_register: register_cli at_activate: activate_cli +dependency_overrides: + at_cli_commons: + git: + url: https://github.com/atsign-foundation/at_libraries + ref: gkc/add_max_connect_attempts + path: packages/at_cli_commons + dependencies: args: ^2.5.0 crypton: ^2.2.1 From 675cb2f4bb5752c3a42aafde652ae27086121235 Mon Sep 17 00:00:00 2001 From: gkc Date: Tue, 7 May 2024 12:58:09 +0100 Subject: [PATCH 15/19] feat: auth_cli: add maxConnectAttempts param to auth_cli --- packages/at_onboarding_cli/lib/src/cli/auth_cli.dart | 2 +- .../at_onboarding_cli/lib/src/cli/auth_cli_args.dart | 9 +++++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/packages/at_onboarding_cli/lib/src/cli/auth_cli.dart b/packages/at_onboarding_cli/lib/src/cli/auth_cli.dart index 758443aa..68e93ef3 100644 --- a/packages/at_onboarding_cli/lib/src/cli/auth_cli.dart +++ b/packages/at_onboarding_cli/lib/src/cli/auth_cli.dart @@ -202,7 +202,7 @@ Future createAtClient(ArgResults ar) async { .replaceAll('/', Platform.pathSeparator), verbose: ar[AuthCliArgs.argNameVerbose] || ar[AuthCliArgs.argNameDebug], syncDisabled: true, - maxConnectAttempts: 10, // 10 * 3 == 30 seconds + maxConnectAttempts: int.parse(ar[AuthCliArgs.argNameMaxConnectAttempts]), // 10 * 3 == 30 seconds ); await cliBase.init(); diff --git a/packages/at_onboarding_cli/lib/src/cli/auth_cli_args.dart b/packages/at_onboarding_cli/lib/src/cli/auth_cli_args.dart index 4d695598..00a85d28 100644 --- a/packages/at_onboarding_cli/lib/src/cli/auth_cli_args.dart +++ b/packages/at_onboarding_cli/lib/src/cli/auth_cli_args.dart @@ -82,6 +82,7 @@ class AuthCliArgs { static const argNameEnrollmentStatus = 'enrollmentStatus'; static const argNameAppNameRegex = 'arx'; static const argNameDeviceNameRegex = 'drx'; + static const argNameMaxConnectAttempts = 'mca'; ArgParser get parser { return _aap; @@ -202,6 +203,14 @@ class AuthCliArgs { negatable: false, hide: true, ); + p.addOption( + argNameMaxConnectAttempts, + help: 'Max # attempts to make initial connection to atServer.' + ' Note: there is a 3-second delay between connection attempts.', + mandatory: false, + defaultsTo: "10", + hide: hide, + ); p.addOption( argNameRegistrarFqdn, abbr: 'g', From 29b129bc93183c33e682a99902fe638c72249724 Mon Sep 17 00:00:00 2001 From: gkc Date: Tue, 7 May 2024 13:40:30 +0100 Subject: [PATCH 16/19] build: using published version 1.1.0 of at_cli_commons --- packages/at_onboarding_cli/pubspec.yaml | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/packages/at_onboarding_cli/pubspec.yaml b/packages/at_onboarding_cli/pubspec.yaml index 94ed7552..76af8e78 100644 --- a/packages/at_onboarding_cli/pubspec.yaml +++ b/packages/at_onboarding_cli/pubspec.yaml @@ -12,13 +12,6 @@ executables: at_register: register_cli at_activate: activate_cli -dependency_overrides: - at_cli_commons: - git: - url: https://github.com/atsign-foundation/at_libraries - ref: gkc/add_max_connect_attempts - path: packages/at_cli_commons - dependencies: args: ^2.5.0 crypton: ^2.2.1 @@ -35,7 +28,7 @@ dependencies: at_lookup: ^3.0.47 at_server_status: ^1.0.4 at_utils: ^3.0.16 - at_cli_commons: ^1.0.5 + at_cli_commons: ^1.1.0 dev_dependencies: lints: ^2.1.0 From 7fb44deac726ea691fee3feed3d0b150059d8044 Mon Sep 17 00:00:00 2001 From: gkc Date: Tue, 7 May 2024 14:00:17 +0100 Subject: [PATCH 17/19] test: modify cli functional tests pubspec to use at_onboarding_cli from pub.dev rather than local path. This is slightly inconvenient but we have a circular dependency now between at_onboarding_cli and at_cli_commons. --- tests/at_onboarding_cli_functional_tests/pubspec.yaml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tests/at_onboarding_cli_functional_tests/pubspec.yaml b/tests/at_onboarding_cli_functional_tests/pubspec.yaml index 27873a82..c7940891 100644 --- a/tests/at_onboarding_cli_functional_tests/pubspec.yaml +++ b/tests/at_onboarding_cli_functional_tests/pubspec.yaml @@ -7,8 +7,7 @@ environment: sdk: '>=2.14.4 <4.0.0' dependencies: - at_onboarding_cli: - path: ../../packages/at_onboarding_cli + at_onboarding_cli: ^1.4.4 dependency_overrides: at_auth: From 157dc63968bda04c8887e14562f437d4a53532bf Mon Sep 17 00:00:00 2001 From: gkc Date: Mon, 13 May 2024 15:41:18 +0100 Subject: [PATCH 18/19] at_onboarding_cli: Improve the terminal output from activate_cli. Also some refactoring - Deprecated `AtOnboardingPreference.apkamAuthRetryDurationMins` - Added three new methods to AtOnboardingService - sendEnrollRequest, awaitApproval and createAtKeysFile. Extracted those methods from AtOnboardingServiceImpl.enroll so it now just calls those three methods. The refactoring provides more control to application code. - For readability, refactored the code related to pkam auth in the context of enrollment success checking --- .../example/apkam_examples/apkam_enroll.dart | 10 +- .../lib/src/cli/auth_cli.dart | 63 ++-- .../src/onboard/at_onboarding_service.dart | 57 +++- .../onboard/at_onboarding_service_impl.dart | 269 ++++++++++-------- .../src/util/at_onboarding_preference.dart | 1 + .../test/enrollment_test.dart | 3 +- 6 files changed, 251 insertions(+), 152 deletions(-) diff --git a/packages/at_onboarding_cli/example/apkam_examples/apkam_enroll.dart b/packages/at_onboarding_cli/example/apkam_examples/apkam_enroll.dart index f600d798..b8ccc24d 100644 --- a/packages/at_onboarding_cli/example/apkam_examples/apkam_enroll.dart +++ b/packages/at_onboarding_cli/example/apkam_examples/apkam_enroll.dart @@ -15,14 +15,18 @@ Future main(List args) async { ..atKeysFilePath = argResults['atKeysPath'] ..appName = 'buzz' ..deviceName = 'iphone' - ..rootDomain = 'vip.ve.atsign.zone' - ..apkamAuthRetryDurationMins = 1; + ..rootDomain = 'vip.ve.atsign.zone'; AtOnboardingService? onboardingService = AtOnboardingServiceImpl(atSign, atOnboardingPreference); Map namespaces = {"buzz": "rw"}; // run totp:get from enrolled client and pass the otp var enrollmentResponse = await onboardingService.enroll( - 'buzz', 'iphone', argResults['otp'], namespaces); + 'buzz', + 'iphone', + argResults['otp'], + namespaces, + retryInterval: Duration(seconds: 10), + ); print('enrollmentResponse: $enrollmentResponse'); } diff --git a/packages/at_onboarding_cli/lib/src/cli/auth_cli.dart b/packages/at_onboarding_cli/lib/src/cli/auth_cli.dart index 68e93ef3..14e37f8f 100644 --- a/packages/at_onboarding_cli/lib/src/cli/auth_cli.dart +++ b/packages/at_onboarding_cli/lib/src/cli/auth_cli.dart @@ -252,16 +252,19 @@ Future onboard(ArgResults argResults, {AtOnboardingService? svc}) async { /// to try to auth, and act appropriately on the atServer response @visibleForTesting Future enroll(ArgResults argResults, {AtOnboardingService? svc}) async { - svc ??= createOnboardingService(argResults); - logger - .info('Root server is ${argResults[AuthCliArgs.argNameAtDirectoryFqdn]}'); - logger.info( - 'Registrar url provided is ${argResults[AuthCliArgs.argNameRegistrarFqdn]}'); - if (!argResults.wasParsed(AuthCliArgs.argNameAtKeys)) { throw ArgumentError('The --${AuthCliArgs.argNameAtKeys} option is' ' mandatory for the "enroll" command'); } + + File f = File(argResults[AuthCliArgs.argNameAtKeys]); + if (f.existsSync()) { + stderr.writeln('Error: atKeys file ${f.path} already exists'); + return; + } + + svc ??= createOnboardingService(argResults); + Map namespaces = {}; String nsArg = argResults[AuthCliArgs.argNameNamespaceAccessList]; List nsList = nsArg.split(','); @@ -272,13 +275,24 @@ Future enroll(ArgResults argResults, {AtOnboardingService? svc}) async { namespaces[namespace] = permission; } try { - await svc.enroll( + stderr.writeln('Submitting enrollment request'); + AtEnrollmentResponse er = await svc.sendEnrollRequest( argResults[AuthCliArgs.argNameAppName], argResults[AuthCliArgs.argNameDeviceName], argResults[AuthCliArgs.argNamePasscode], namespaces, + ); + stdout.writeln('Enrollment ID: ${er.enrollmentId}'); + + stderr.writeln('Waiting for approval; will check every 10 seconds'); + await svc.awaitApproval( + er, retryInterval: Duration(seconds: 10), + logProgress: true, ); + + stderr.writeln('Creating atKeys file'); + await svc.createAtKeysFile(er, allowOverwrite: false); } on InvalidDataException catch (e) { stderr.writeln( '[Error] Enrollment failed. Invalid data provided by user. Please try again\nCause: ${e.message}'); @@ -297,7 +311,6 @@ Future enroll(ArgResults argResults, {AtOnboardingService? svc}) async { ' Cause: $e\n' ' Please try again or contact support@atsign.com'); } - logger.finest('svc.enroll() has returned'); } @visibleForTesting @@ -313,7 +326,7 @@ Future setSpp(ArgResults argResults, AtClient atClient) async { String? response = await atLookup.executeCommand('otp:put:$spp\n', auth: true); - logger.shout('Server response: $response'); + stdout.writeln('Server response: $response'); } @visibleForTesting @@ -325,7 +338,7 @@ Future generateOtp(ArgResults argResults, AtClient atClient) async { if (response != null && response.startsWith('data:')) { stdout.writeln(response.substring('data:'.length)); } else { - logger.shout('Failed to generate OTP: server response was $response'); + stderr.writeln('Failed to generate OTP: server response was $response'); } } @@ -462,10 +475,10 @@ Future _list( } filtered[ek.substring(0, ek.indexOf('.'))] = e; } - logger.shout("Found ${filtered.length} matching enrollment records"); + stdout.writeln("Found ${filtered.length} matching enrollment records"); return filtered; } else { - logger.shout('Exiting: Unexpected server response: $rawResponse'); + stderr.writeln('Exiting: Unexpected server response: $rawResponse'); exit(1); } } @@ -509,7 +522,7 @@ Future _fetch(String eId, AtLookUp atLookup) async { // response is a Map return jsonDecode(rawResponse); } else { - logger.shout('Exiting: Unexpected server response: $rawResponse'); + stderr.writeln('Exiting: Unexpected server response: $rawResponse'); exit(1); } } @@ -520,10 +533,10 @@ Future fetch(ArgResults argResults, AtClient atClient) async { Map? er = await _fetch(eId, atLookup); if (er == null) { - logger.shout('Enrollment ID $eId not found'); + stderr.writeln('Enrollment ID $eId not found'); return; } else { - stderr.writeln('Fetched enrollment OK: $er'); + stdout.writeln('Fetched enrollment OK: $er'); } } @@ -547,7 +560,7 @@ Future _fetchOrListAndFilter( // First fetch the enrollment request Map? er = await _fetch(eId, atLookup); if (er == null) { - logger.shout('Enrollment ID $eId not found'); + stderr.writeln('Enrollment ID $eId not found'); } enrollmentMap[eId] = er; } else { @@ -573,14 +586,14 @@ Future approve(ArgResults ar, AtClient atClient) async { ); if (toApprove.isEmpty) { - logger.shout('No matching enrollment(s) found'); + stderr.writeln('No matching enrollment(s) found'); return; } // Iterate through the requests, approve each one for (String eId in toApprove.keys) { Map er = toApprove[eId]; - logger.shout('Approving enrollmentId $eId'); + stdout.writeln('Approving enrollmentId $eId'); // Then make a 'decision' object using data from the enrollment request EnrollmentRequestDecision decision = EnrollmentRequestDecision.approved( ApprovedRequestDecisionBuilder( @@ -592,7 +605,7 @@ Future approve(ArgResults ar, AtClient atClient) async { .atEnrollment(atClient.getCurrentAtSign()!) .approve(decision, atLookup); // 'enroll:approve:{"enrollmentId":"$enrollmentId"}' - logger.shout('Server response: $response'); + stdout.writeln('Server response: $response'); } } @@ -608,17 +621,17 @@ Future deny(ArgResults ar, AtClient atClient) async { ); if (toDeny.isEmpty) { - logger.shout('No matching enrollment(s) found'); + stderr.writeln('No matching enrollment(s) found'); return; } // Iterate through the requests, deny each one for (String eId in toDeny.keys) { - logger.shout('Denying enrollmentId $eId'); + stdout.writeln('Denying enrollmentId $eId'); // 'enroll:deny:{"enrollmentId":"$enrollmentId"}' String? response = await atLookup .executeCommand('enroll:deny:{"enrollmentId":"$eId"}\n', auth: true); - logger.shout('Server response: $response'); + stdout.writeln('Server response: $response'); } } @@ -634,17 +647,17 @@ Future revoke(ArgResults ar, AtClient atClient) async { ); if (toRevoke.isEmpty) { - logger.shout('No matching enrollment(s) found'); + stderr.writeln('No matching enrollment(s) found'); return; } // Iterate through the requests, revoke each one for (String eId in toRevoke.keys) { - logger.shout('Revoking enrollmentId $eId'); + stdout.writeln('Revoking enrollmentId $eId'); // 'enroll:revoke:{"enrollmentid":"$enrollmentId"}' String? response = await atLookup .executeCommand('enroll:revoke:{"enrollmentId":"$eId"}\n', auth: true); - logger.shout('Server response: $response'); + stdout.writeln('Server response: $response'); } } diff --git a/packages/at_onboarding_cli/lib/src/onboard/at_onboarding_service.dart b/packages/at_onboarding_cli/lib/src/onboard/at_onboarding_service.dart index b94d686a..1298e654 100644 --- a/packages/at_onboarding_cli/lib/src/onboard/at_onboarding_service.dart +++ b/packages/at_onboarding_cli/lib/src/onboard/at_onboarding_service.dart @@ -1,10 +1,13 @@ +import 'dart:io'; + import 'package:at_chops/at_chops.dart'; import 'package:at_client/at_client.dart'; import 'package:at_lookup/at_lookup.dart'; import 'package:at_auth/at_auth.dart'; -import 'package:at_onboarding_cli/at_onboarding_cli.dart'; abstract class AtOnboardingService { + static const Duration defaultApkamRetryInterval = Duration(seconds: 10); + ///perform initial one_time authentication to activate the atsign ///returns true if onboarded Future onboard(); @@ -14,20 +17,54 @@ abstract class AtOnboardingService { ///returns true if authenticated Future authenticate({String? enrollmentId}); - /// Sends an enroll request to the server. Apps that are already enrolled will receive notifications for this enroll request and can approve/deny the request - /// appName - application name of the client e.g wavi,buzz, atmosphere etc., - /// deviceName - device identifier from the requesting application e.g iphone,any unique ID that identifies the requesting client - /// otp - otp retrieved from an already enrolled app - /// namespaces - key-value pair of namespace-access of the requesting client e.g {"wavi":"rw","contacts":"r"} - /// pkamRetryIntervalMins - optional param which specifies interval in mins for pkam retry for this enrollment. - /// The passed value will override the value in [AtOnboardingPreference] + /// Sends an enroll request to the server, and waits for the request to be + /// approved. Apps that are already enrolled will receive + /// notifications for this enroll request and can approve/deny the request. + /// If the request is denied, or times out, an exception will be thrown. + /// + /// Calling this method is exactly equivalent to calling + /// [sendEnrollRequest], [awaitApproval] and [createAtKeysFile] in turn. + /// + /// [appName] - application name of the client e.g wavi,buzz, atmosphere etc., + /// [deviceName] - device identifier from the requesting application e.g iphone,any unique ID that identifies the requesting client + /// [otp] - otp generated via an already enrolled app + /// [namespaces] - key-value pair of namespace-access of the requesting client e.g {"wavi":"rw","contacts":"r"} + /// [retryInterval] - how frequently to re-check if the request + /// has been approved or denied. Future enroll( String appName, String deviceName, String otp, Map namespaces, { - @Deprecated('Use retryInterval') int? pkamRetryIntervalMins, - Duration? retryInterval, + Duration retryInterval = defaultApkamRetryInterval, + }); + + /// Sends enrollment request. Application code may subsequently call + /// [awaitApproval]. + Future sendEnrollRequest( + String appName, + String deviceName, + String otp, + Map namespaces, + ); + + /// Attempts PKAM auth until successful (i.e. request was approved). + /// If the request was denied, or times out, then an exception is thrown. + /// + /// Once successful, the full set of keys are available in + /// [enrollmentResponse].atAuthKeys + Future awaitApproval( + AtEnrollmentResponse enrollmentResponse, { + Duration retryInterval = defaultApkamRetryInterval, + bool logProgress = true, + }); + + /// Create a file in the standardized format which apps may use to + /// authenticate to an atServer. + Future createAtKeysFile( + AtEnrollmentResponse er, { + File? atKeysFile, + bool allowOverwrite = false, }); ///returns an authenticated instance of AtClient diff --git a/packages/at_onboarding_cli/lib/src/onboard/at_onboarding_service_impl.dart b/packages/at_onboarding_cli/lib/src/onboard/at_onboarding_service_impl.dart index 1d49eec6..a61cdca5 100644 --- a/packages/at_onboarding_cli/lib/src/onboard/at_onboarding_service_impl.dart +++ b/packages/at_onboarding_cli/lib/src/onboard/at_onboarding_service_impl.dart @@ -4,6 +4,7 @@ import 'dart:async'; import 'dart:convert'; import 'dart:io'; +import 'package:at_auth/at_auth.dart'; import 'package:at_chops/at_chops.dart'; import 'package:at_client/at_client.dart'; import 'package:at_auth/at_auth.dart' as at_auth; @@ -30,11 +31,6 @@ class AtOnboardingServiceImpl implements AtOnboardingService { AtOnboardingPreference atOnboardingPreference; AtLookUp? _atLookUp; - final StreamController _pkamSuccessController = - StreamController(); - - Stream get _onPkamSuccess => _pkamSuccessController.stream; - /// The object which controls what types of AtClients, NotificationServices /// and SyncServices get created when we call [AtClientManager.setCurrentAtSign]. /// If [atServiceFactory] is not set, AtClientManager.setCurrentAtSign will use @@ -124,7 +120,9 @@ class AtOnboardingServiceImpl implements AtOnboardingService { logger.finer( 'Onboarding successful.Generating keyfile in path: ${atOnboardingPreference.atKeysFilePath}'); await _generateAtKeysFile( - atOnboardingResponse.enrollmentId, atOnboardingResponse.atAuthKeys!); + atOnboardingResponse.atAuthKeys!, + enrollmentId: atOnboardingResponse.enrollmentId, + ); } _isAtsignOnboarded = atOnboardingResponse.isSuccessful; return _isAtsignOnboarded; @@ -132,29 +130,84 @@ class AtOnboardingServiceImpl implements AtOnboardingService { @override Future enroll( + String appName, + String deviceName, + String otp, + Map namespaces, { + Duration retryInterval = AtOnboardingService.defaultApkamRetryInterval, + File? atKeysFile, + bool allowOverwrite = false, + }) async { + AtEnrollmentResponse enrollmentResponse = await sendEnrollRequest( + appName, + deviceName, + otp, + namespaces, + ); + logger.finer('EnrollmentResponse from server: $enrollmentResponse'); + + await awaitApproval(enrollmentResponse, retryInterval: retryInterval); + + await createAtKeysFile( + enrollmentResponse, + atKeysFile: atKeysFile, + allowOverwrite: allowOverwrite, + ); + + return enrollmentResponse; + } + + @override + Future createAtKeysFile( + AtEnrollmentResponse er, { + File? atKeysFile, + bool allowOverwrite = false, + }) async { + return await _generateAtKeysFile( + er.atAuthKeys!, + enrollmentId: er.enrollmentId, + atKeysFile: atKeysFile, + allowOverwrite: allowOverwrite, + ); + } + + @override + Future sendEnrollRequest( String appName, String deviceName, String otp, - Map namespaces, { - @Deprecated('Use retryInterval') int? pkamRetryIntervalMins, - Duration? retryInterval, - }) async { + Map namespaces, + ) async { if (appName == null || deviceName == null) { throw AtEnrollmentException( 'appName and deviceName are mandatory for enrollment'); } - retryInterval ??= Duration( - minutes: pkamRetryIntervalMins ?? - atOnboardingPreference.apkamAuthRetryDurationMins); + at_auth.EnrollmentRequest newClientEnrollmentRequest = + at_auth.EnrollmentRequest( + appName: appName, + deviceName: deviceName, + namespaces: namespaces, + otp: otp, + ); AtLookupImpl atLookUpImpl = AtLookupImpl(_atSign, atOnboardingPreference.rootDomain, atOnboardingPreference.rootPort); + logger.finer('sendEnrollRequest: submitting enrollment request'); + AtEnrollmentResponse response = await _atEnrollment!.submit( + newClientEnrollmentRequest, + atLookUpImpl, + ); + logger.finer('sendEnrollRequest: received server response: $response'); + + return response; + } - //2. Send enroll request to server - at_auth.AtEnrollmentResponse enrollmentResponse = await _sendEnrollRequest( - appName, deviceName, otp, namespaces, atLookUpImpl); - logger.finer('EnrollmentResponse from server: $enrollmentResponse'); - + @override + Future awaitApproval( + AtEnrollmentResponse enrollmentResponse, { + Duration retryInterval = AtOnboardingService.defaultApkamRetryInterval, + bool logProgress = true, + }) async { AtChopsKeys atChopsKeys = AtChopsKeys.create( AtEncryptionKeyPair.create( enrollmentResponse.atAuthKeys!.defaultEncryptionPublicKey!, ''), @@ -163,53 +216,34 @@ class AtOnboardingServiceImpl implements AtOnboardingService { atChopsKeys.apkamSymmetricKey = AESKey(enrollmentResponse.atAuthKeys!.apkamSymmetricKey!); - atLookUpImpl.atChops = AtChopsImpl(atChopsKeys); - - // Pkam auth will be attempted asynchronously until enrollment is approved/denied - await _attemptPkamAuthAsync( - atLookUpImpl, enrollmentResponse.enrollmentId, retryInterval); - - // Upon successful pkam auth, callback _listenToPkamSuccessStream will be invoked - await _listenToPkamSuccessStream( - atLookUpImpl, - enrollmentResponse.atAuthKeys!.apkamSymmetricKey!, - enrollmentResponse.atAuthKeys!.defaultEncryptionPublicKey!, - enrollmentResponse.atAuthKeys!.apkamPublicKey!, - enrollmentResponse.atAuthKeys!.apkamPrivateKey!); + AtLookupImpl atLookUpImpl = AtLookupImpl(_atSign, + atOnboardingPreference.rootDomain, atOnboardingPreference.rootPort); - return enrollmentResponse; - } + atLookUpImpl.atChops = AtChopsImpl(atChopsKeys); - Future _listenToPkamSuccessStream ( - AtLookupImpl atLookUpImpl, - String apkamSymmetricKey, - String defaultEncryptionPublicKey, - String apkamPublicKey, - String apkamPrivateKey) async { - Completer c = Completer(); - _onPkamSuccess.listen((enrollmentIdFromServer) async { - logger.finer('_listenToPkamSuccessStream invoked'); - var decryptedEncryptionPrivateKey = EncryptionUtil.decryptValue( - await _getEncryptionPrivateKeyFromServer( - enrollmentIdFromServer, atLookUpImpl), - apkamSymmetricKey); - var decryptedSelfEncryptionKey = EncryptionUtil.decryptValue( - await _getSelfEncryptionKeyFromServer( - enrollmentIdFromServer, atLookUpImpl), - apkamSymmetricKey); - - var atAuthKeys = at_auth.AtAuthKeys() - ..defaultEncryptionPrivateKey = decryptedEncryptionPrivateKey - ..defaultEncryptionPublicKey = defaultEncryptionPublicKey - ..apkamSymmetricKey = apkamSymmetricKey - ..defaultSelfEncryptionKey = decryptedSelfEncryptionKey - ..apkamPublicKey = apkamPublicKey - ..apkamPrivateKey = apkamPrivateKey; - logger.finer('Generating keys file for $enrollmentIdFromServer'); - await _generateAtKeysFile(enrollmentIdFromServer, atAuthKeys); - c.complete(); - }); - return c.future; + // Pkam auth will be attempted asynchronously until enrollment is approved + // or denied or times out. If denied or timed out, an exception will be + // thrown + await _waitForPkamAuthSuccess( + atLookUpImpl, + enrollmentResponse.enrollmentId, + retryInterval, + logProgress: logProgress, + ); + + var decryptedEncryptionPrivateKey = EncryptionUtil.decryptValue( + await _getEncryptionPrivateKeyFromServer( + enrollmentResponse.enrollmentId, atLookUpImpl), + enrollmentResponse.atAuthKeys!.apkamSymmetricKey!); + var decryptedSelfEncryptionKey = EncryptionUtil.decryptValue( + await _getSelfEncryptionKeyFromServer( + enrollmentResponse.enrollmentId, atLookUpImpl), + enrollmentResponse.atAuthKeys!.apkamSymmetricKey!); + + enrollmentResponse.atAuthKeys!.defaultEncryptionPrivateKey = + decryptedEncryptionPrivateKey; + enrollmentResponse.atAuthKeys!.defaultSelfEncryptionKey = + decryptedSelfEncryptionKey; } Future _getEncryptionPrivateKeyFromServer( @@ -257,30 +291,47 @@ class AtOnboardingServiceImpl implements AtOnboardingService { return selfEncryptionKeyFromServer; } - Future _attemptPkamAuthAsync(AtLookupImpl atLookUpImpl, - String enrollmentIdFromServer, Duration retryInterval) async { - // Pkam auth will be retried until server approves/denies/expires the enrollment + /// Pkam auth will be retried until server approves/denies/expires the enrollment + Future _waitForPkamAuthSuccess( + AtLookupImpl atLookUpImpl, + String enrollmentIdFromServer, + Duration retryInterval, { + bool logProgress = true, + }) async { while (true) { - logger.shout('Attempting to authenticate'); - bool pkamAuthResult = await _attemptPkamAuth( - atLookUpImpl, enrollmentIdFromServer, retryInterval); - if (pkamAuthResult) { - logger.shout('Authentication succeeded - enrollment request was approved'); - _pkamSuccessController.add(enrollmentIdFromServer); - break; + logger.info('Attempting pkam auth'); + if (logProgress) { + stderr.write('Checking ... '); + } + bool pkamAuthSucceeded = await _attemptPkamAuth( + atLookUpImpl, + enrollmentIdFromServer, + ); + if (pkamAuthSucceeded) { + if (logProgress) { + stderr.writeln(' approved.'); + } + logger.info('Authentication succeeded - request was approved'); + return; + } else { + if (logProgress) { + stderr.writeln(' not approved. Will retry' + ' in ${retryInterval.inSeconds} seconds'); + } + logger.info('Will retry pkam in ${retryInterval.inSeconds} seconds'); + await Future.delayed(retryInterval); // Delay and retry } - logger.shout('Will retry pkam in ${retryInterval.inSeconds} seconds'); - await Future.delayed(retryInterval); // Delay and retry } } - Future _attemptPkamAuth(AtLookUp atLookUp, - String enrollmentIdFromServer, Duration retryInterval) async { + /// Try a single PKAM auth + Future _attemptPkamAuth(AtLookUp atLookUp, String enrollmentId) async { try { logger.finer('_attemptPkamAuth: Calling atLookUp.pkamAuthenticate'); var pkamResult = - await atLookUp.pkamAuthenticate(enrollmentId: enrollmentIdFromServer); - logger.finer('_attemptPkamAuth: atLookUp.pkamAuthenticate returned $pkamResult'); + await atLookUp.pkamAuthenticate(enrollmentId: enrollmentId); + logger.finer( + '_attemptPkamAuth: atLookUp.pkamAuthenticate returned $pkamResult'); if (pkamResult) { return true; } @@ -290,8 +341,6 @@ class AtOnboardingServiceImpl implements AtOnboardingService { logger.info('Pkam auth failed: ${e.message}'); return false; } else if (e.message.contains('error:AT0025')) { - logger.shout( - 'enrollmentId $enrollmentIdFromServer denied. Exiting pkam retry logic'); throw AtEnrollmentException('enrollment denied'); } } catch (e) { @@ -303,26 +352,29 @@ class AtOnboardingServiceImpl implements AtOnboardingService { return false; } - Future _sendEnrollRequest( - String appName, - String deviceName, - String otp, - Map namespaces, - AtLookupImpl atLookUpImpl) async { - at_auth.EnrollmentRequest newClientEnrollmentRequest = - at_auth.EnrollmentRequest( - appName: appName, - deviceName: deviceName, - namespaces: namespaces, - otp: otp); - logger.finer('calling at_enrollment_impl submit enrollment'); - return await _atEnrollment! - .submit(newClientEnrollmentRequest, atLookUpImpl); - } - ///write newly created encryption keypairs into atKeys file - Future _generateAtKeysFile( - String? currentEnrollmentId, at_auth.AtAuthKeys atAuthKeys) async { + Future _generateAtKeysFile( + at_auth.AtAuthKeys atAuthKeys, { + String? enrollmentId, + File? atKeysFile, + bool allowOverwrite = true, + }) async { + if (atKeysFile == null) { + if (!atOnboardingPreference.atKeysFilePath!.endsWith('.atKeys')) { + atOnboardingPreference.atKeysFilePath = + path.join(atOnboardingPreference.atKeysFilePath!, '.atKeys'); + } + + atKeysFile = File(atOnboardingPreference.atKeysFilePath!); + } + + if (atKeysFile.existsSync() && !allowOverwrite) { + throw StateError('atKeys file ${atKeysFile.path} already exists'); + } + + logger.finer('Generating keys file at ${atKeysFile.path}' + ' with enrollmentId $enrollmentId'); + final atKeysMap = { AuthKeyType.pkamPublicKey: EncryptionUtil.encryptValue( atAuthKeys.apkamPublicKey!, @@ -341,8 +393,8 @@ class AtOnboardingServiceImpl implements AtOnboardingService { AuthKeyType.apkamSymmetricKey: atAuthKeys.apkamSymmetricKey! }; - if (currentEnrollmentId != null) { - atKeysMap['enrollmentId'] = currentEnrollmentId; + if (enrollmentId != null) { + atKeysMap['enrollmentId'] = enrollmentId; } if (atOnboardingPreference.authMode == PkamAuthMode.keysFile) { @@ -350,16 +402,7 @@ class AtOnboardingServiceImpl implements AtOnboardingService { atAuthKeys.apkamPrivateKey!, atAuthKeys.defaultSelfEncryptionKey!); } - if (!atOnboardingPreference.atKeysFilePath!.endsWith('.atKeys')) { - atOnboardingPreference.atKeysFilePath = - path.join(atOnboardingPreference.atKeysFilePath!, '.atKeys'); - } - - File atKeysFile = File(atOnboardingPreference.atKeysFilePath!); - - if (!atKeysFile.existsSync()) { - atKeysFile.createSync(recursive: true); - } + atKeysFile.createSync(recursive: true); IOSink fileWriter = atKeysFile.openWrite(); //generating .atKeys file at path provided in onboardingConfig @@ -368,6 +411,8 @@ class AtOnboardingServiceImpl implements AtOnboardingService { await fileWriter.close(); stdout.writeln( '[Success] Your .atKeys file saved at ${atOnboardingPreference.atKeysFilePath}\n'); + + return atKeysFile; } ///back-up encryption keys to local secondary diff --git a/packages/at_onboarding_cli/lib/src/util/at_onboarding_preference.dart b/packages/at_onboarding_cli/lib/src/util/at_onboarding_preference.dart index ccf58597..54b555db 100644 --- a/packages/at_onboarding_cli/lib/src/util/at_onboarding_preference.dart +++ b/packages/at_onboarding_cli/lib/src/util/at_onboarding_preference.dart @@ -25,5 +25,6 @@ class AtOnboardingPreference extends AtClientPreference { String? deviceName; + @Deprecated("No longer used") int apkamAuthRetryDurationMins = 30; } diff --git a/tests/at_onboarding_cli_functional_tests/test/enrollment_test.dart b/tests/at_onboarding_cli_functional_tests/test/enrollment_test.dart index 12554f7e..3b1f7570 100644 --- a/tests/at_onboarding_cli_functional_tests/test/enrollment_test.dart +++ b/tests/at_onboarding_cli_functional_tests/test/enrollment_test.dart @@ -322,8 +322,7 @@ AtOnboardingPreference getPreferenceForEnroll(String atSign) { '${Platform.environment['HOME']}/.atsign/keys/${atSign}_buzzkey.atKeys' ..appName = 'buzz' ..deviceName = 'iphone' - ..rootDomain = 'vip.ve.atsign.zone' - ..apkamAuthRetryDurationMins = 1; + ..rootDomain = 'vip.ve.atsign.zone'; return atOnboardingPreference; } From ac7fd0f3ff939e5928ae5126c941fa6ed72a97d5 Mon Sep 17 00:00:00 2001 From: gkc Date: Mon, 13 May 2024 15:42:25 +0100 Subject: [PATCH 19/19] chore: run dart format --- .../lib/src/cli/auth_cli.dart | 3 +- .../lib/src/cli/auth_cli_arg_validation.dart | 1 - .../lib/src/cli/auth_cli_args.dart | 78 ++++++++++--------- .../onboard/at_onboarding_service_impl.dart | 24 +++--- .../lib/src/register_cli/register.dart | 3 +- 5 files changed, 58 insertions(+), 51 deletions(-) diff --git a/packages/at_onboarding_cli/lib/src/cli/auth_cli.dart b/packages/at_onboarding_cli/lib/src/cli/auth_cli.dart index 14e37f8f..605ebf1f 100644 --- a/packages/at_onboarding_cli/lib/src/cli/auth_cli.dart +++ b/packages/at_onboarding_cli/lib/src/cli/auth_cli.dart @@ -202,7 +202,8 @@ Future createAtClient(ArgResults ar) async { .replaceAll('/', Platform.pathSeparator), verbose: ar[AuthCliArgs.argNameVerbose] || ar[AuthCliArgs.argNameDebug], syncDisabled: true, - maxConnectAttempts: int.parse(ar[AuthCliArgs.argNameMaxConnectAttempts]), // 10 * 3 == 30 seconds + maxConnectAttempts: int.parse( + ar[AuthCliArgs.argNameMaxConnectAttempts]), // 10 * 3 == 30 seconds ); await cliBase.init(); diff --git a/packages/at_onboarding_cli/lib/src/cli/auth_cli_arg_validation.dart b/packages/at_onboarding_cli/lib/src/cli/auth_cli_arg_validation.dart index 8331e3ce..24d3d9cf 100644 --- a/packages/at_onboarding_cli/lib/src/cli/auth_cli_arg_validation.dart +++ b/packages/at_onboarding_cli/lib/src/cli/auth_cli_arg_validation.dart @@ -5,4 +5,3 @@ const String invalidSppMsg = 'SPP must be $sppFormatHelp'; bool invalidSpp(String test) { return RegExp(sppRegex).allMatches(test).first.group(0) != test; } - diff --git a/packages/at_onboarding_cli/lib/src/cli/auth_cli_args.dart b/packages/at_onboarding_cli/lib/src/cli/auth_cli_args.dart index 00a85d28..b9114c9f 100644 --- a/packages/at_onboarding_cli/lib/src/cli/auth_cli_args.dart +++ b/packages/at_onboarding_cli/lib/src/cli/auth_cli_args.dart @@ -6,46 +6,50 @@ import 'package:meta/meta.dart'; enum AuthCliCommand { help(usage: 'Show help'), - onboard(usage: '"onboard" is used when first authenticating to an atServer.' - ' It generates "atKeys" (stored to filesystem or keychain) which' - ' may be used for authentication thereafter.' - '\n\n' - 'When another program' - ' needs to be able to authenticate, it may use the atKeys file if it is' - ' available - however when the program is on a different device, or on ' - ' the same device but in a different, sandboxed app, the recommended' - ' approach is to use the "enroll" command.'), - otp(usage: 'Generate a one-time passcode which may be used by a single' - ' enrollment request.' - '\n\n' - 'Note that the passcode is used only to allow the' - ' atServer to identify that an enrollment request is not' - ' spurious / malicious / spam - i.e. enrollment requests which have a valid' - ' passcode will be accepted.'), - spp(usage: 'Set a semi-permanent passcode which may be used by multiple' - ' enrollment requests. This is particularly useful when programs will ' - ' need to be run on many different devices.' - '\n\n' - 'Note that the passcode is used only to allow the' - ' atServer to identify that an enrollment request is not' - ' spurious / malicious / spam - i.e. enrollment requests which have a valid' - ' passcode will be accepted.'), + onboard( + usage: '"onboard" is used when first authenticating to an atServer.' + ' It generates "atKeys" (stored to filesystem or keychain) which' + ' may be used for authentication thereafter.' + '\n\n' + 'When another program' + ' needs to be able to authenticate, it may use the atKeys file if it is' + ' available - however when the program is on a different device, or on ' + ' the same device but in a different, sandboxed app, the recommended' + ' approach is to use the "enroll" command.'), + otp( + usage: 'Generate a one-time passcode which may be used by a single' + ' enrollment request.' + '\n\n' + 'Note that the passcode is used only to allow the' + ' atServer to identify that an enrollment request is not' + ' spurious / malicious / spam - i.e. enrollment requests which have a valid' + ' passcode will be accepted.'), + spp( + usage: 'Set a semi-permanent passcode which may be used by multiple' + ' enrollment requests. This is particularly useful when programs will ' + ' need to be run on many different devices.' + '\n\n' + 'Note that the passcode is used only to allow the' + ' atServer to identify that an enrollment request is not' + ' spurious / malicious / spam - i.e. enrollment requests which have a valid' + ' passcode will be accepted.'), interactive(usage: 'Run in interactive mode'), list(usage: 'List enrollment requests'), fetch(usage: 'Fetch a specific enrollment request'), approve(usage: 'Approve a pending enrollment request'), deny(usage: 'Deny a pending enrollment request'), revoke(usage: 'Revoke approval of a previously-approved enrollment'), - enroll(usage: 'Enroll is used when a program needs to authenticate and' - ' "atKeys" are not available, and "onboard" has already been run' - ' by another program.' - '\n\n' - 'Enrollment requests require a valid passcode in order' - ' for them to be accepted by the atServer; accepted requests will then' - ' be delivered to some other program(s) which have' - ' permission to approve or deny the requests. Typically that will be' - ' the program which first onboarded; however it can also be an enrolled' - ' program which has "rw" access to the "__manage" namespace.'); + enroll( + usage: 'Enroll is used when a program needs to authenticate and' + ' "atKeys" are not available, and "onboard" has already been run' + ' by another program.' + '\n\n' + 'Enrollment requests require a valid passcode in order' + ' for them to be accepted by the atServer; accepted requests will then' + ' be delivered to some other program(s) which have' + ' permission to approve or deny the requests. Typically that will be' + ' the program which first onboarded; however it can also be an enrolled' + ' program which has "rw" access to the "__manage" namespace.'); const AuthCliCommand({this.usage = ''}); final String usage; @@ -159,7 +163,8 @@ class AuthCliArgs { /// Make an ArgParser with the args which are common to every command @visibleForTesting - ArgParser createSharedArgParser({required bool hide, bool forOnboard=false}) { + ArgParser createSharedArgParser( + {required bool hide, bool forOnboard = false}) { ArgParser p = ArgParser( usageLineLength: stdout.hasTerminal ? stdout.terminalColumns : null); p.addOption( @@ -180,7 +185,8 @@ class AuthCliArgs { p.addOption( argNameAtKeys, abbr: 'k', - help: 'Path to atKeys file to create (onboard / enroll) or use (approve / deny / etc)', + help: + 'Path to atKeys file to create (onboard / enroll) or use (approve / deny / etc)', mandatory: false, hide: hide, ); diff --git a/packages/at_onboarding_cli/lib/src/onboard/at_onboarding_service_impl.dart b/packages/at_onboarding_cli/lib/src/onboard/at_onboarding_service_impl.dart index a61cdca5..7c1302aa 100644 --- a/packages/at_onboarding_cli/lib/src/onboard/at_onboarding_service_impl.dart +++ b/packages/at_onboarding_cli/lib/src/onboard/at_onboarding_service_impl.dart @@ -130,14 +130,14 @@ class AtOnboardingServiceImpl implements AtOnboardingService { @override Future enroll( - String appName, - String deviceName, - String otp, - Map namespaces, { - Duration retryInterval = AtOnboardingService.defaultApkamRetryInterval, - File? atKeysFile, - bool allowOverwrite = false, - }) async { + String appName, + String deviceName, + String otp, + Map namespaces, { + Duration retryInterval = AtOnboardingService.defaultApkamRetryInterval, + File? atKeysFile, + bool allowOverwrite = false, + }) async { AtEnrollmentResponse enrollmentResponse = await sendEnrollRequest( appName, deviceName, @@ -159,10 +159,10 @@ class AtOnboardingServiceImpl implements AtOnboardingService { @override Future createAtKeysFile( - AtEnrollmentResponse er, { - File? atKeysFile, - bool allowOverwrite = false, - }) async { + AtEnrollmentResponse er, { + File? atKeysFile, + bool allowOverwrite = false, + }) async { return await _generateAtKeysFile( er.atAuthKeys!, enrollmentId: er.enrollmentId, diff --git a/packages/at_onboarding_cli/lib/src/register_cli/register.dart b/packages/at_onboarding_cli/lib/src/register_cli/register.dart index 40effc88..0ecb1dbe 100644 --- a/packages/at_onboarding_cli/lib/src/register_cli/register.dart +++ b/packages/at_onboarding_cli/lib/src/register_cli/register.dart @@ -64,7 +64,8 @@ class Register { .add(ValidateOtp()) .start(); - await activate_cli.main(['-a', params['atsign']!, '-c', params['cramkey']!]); + await activate_cli + .main(['-a', params['atsign']!, '-c', params['cramkey']!]); } }