From 9ffe18737a4a8dcfb4ffea475feac84c2c92c98d Mon Sep 17 00:00:00 2001 From: Eric Seidel Date: Fri, 6 Feb 2026 11:57:50 -0800 Subject: [PATCH] feat(shorebird_cli): add --public-key-cmd and --sign-cmd support (#3605) --- CLAUDE.md | 29 ++ .../shorebird_cli/lib/src/code_signer.dart | 111 +++++- .../lib/src/commands/patch/aar_patcher.dart | 10 +- .../src/commands/patch/android_patcher.dart | 10 +- .../commands/patch/ios_framework_patcher.dart | 7 +- .../lib/src/commands/patch/ios_patcher.dart | 7 +- .../lib/src/commands/patch/linux_patcher.dart | 7 +- .../lib/src/commands/patch/macos_patcher.dart | 7 +- .../lib/src/commands/patch/patch_command.dart | 14 +- .../lib/src/commands/patch/patcher.dart | 87 +++++ .../src/commands/patch/windows_patcher.dart | 7 +- .../src/commands/release/aar_releaser.dart | 3 +- .../commands/release/android_releaser.dart | 2 +- .../release/ios_framework_releaser.dart | 3 +- .../src/commands/release/ios_releaser.dart | 3 +- .../src/commands/release/linux_releaser.dart | 3 +- .../src/commands/release/macos_releaser.dart | 3 +- .../src/commands/release/release_command.dart | 21 +- .../lib/src/commands/release/releaser.dart | 6 + .../commands/release/windows_releaser.dart | 5 +- .../lib/src/common_arguments.dart | 19 + .../lib/src/extensions/arg_results.dart | 134 +++++++ .../test/src/code_signer_test.dart | 254 +++++++++++++ .../src/commands/patch/aar_patcher_test.dart | 21 +- .../commands/patch/android_patcher_test.dart | 21 +- .../patch/ios_framework_patcher_test.dart | 18 +- .../src/commands/patch/ios_patcher_test.dart | 18 +- .../commands/patch/linux_patcher_test.dart | 15 +- .../commands/patch/macos_patcher_test.dart | 14 + .../commands/patch/patch_command_test.dart | 6 + .../test/src/commands/patch/patcher_test.dart | 359 ++++++++++++++++++ .../commands/patch/windows_patcher_test.dart | 15 +- .../commands/release/aar_releaser_test.dart | 56 ++- .../release/android_releaser_test.dart | 60 ++- .../release/ios_framework_releaser_test.dart | 58 ++- .../commands/release/ios_releaser_test.dart | 59 ++- .../commands/release/linux_releaser_test.dart | 42 +- .../commands/release/macos_releaser_test.dart | 41 ++ .../release/release_command_test.dart | 7 +- .../release/windows_releaser_test.dart | 49 ++- .../test/src/extensions/arg_results_test.dart | 239 ++++++++++++ 41 files changed, 1750 insertions(+), 100 deletions(-) create mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..707b2a9c --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,29 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Build Commands + +```bash +# Run all tests (must run from packages/ directory to avoid cache conflicts) +cd packages && very_good test -r + +# Run tests for a single package (can use -r failures-only to reduce output) +dart test packages/shorebird_cli +``` + +## Architecture + +Dart monorepo. Main package is `shorebird_cli`. + +**Platform-specific operations** use the Releaser/Patcher pattern: +- `commands/release/` - `Releaser` base class with platform implementations +- `commands/patch/` - `Patcher` base class with platform implementations + +**Dependency injection** uses `scoped_deps` with zone-based refs (see any `*Ref` variable). + +## Code Style + +- PR titles must follow semantic commit format (enforced in CI) +- CSpell: use inline `// cspell:words` for 1-2 files; add to global config for more +- Prefer new commits over amending in PRs - history gets squashed anyway diff --git a/packages/shorebird_cli/lib/src/code_signer.dart b/packages/shorebird_cli/lib/src/code_signer.dart index 67877ce8..b7aa774c 100644 --- a/packages/shorebird_cli/lib/src/code_signer.dart +++ b/packages/shorebird_cli/lib/src/code_signer.dart @@ -6,6 +6,7 @@ import 'dart:typed_data'; import 'package:pem/pem.dart'; import 'package:pointycastle/pointycastle.dart'; import 'package:scoped_deps/scoped_deps.dart'; +import 'package:shorebird_cli/src/shorebird_process.dart'; /// A reference to a [CodeSigner] instance. final codeSignerRef = create(CodeSigner.new); @@ -53,8 +54,13 @@ class CodeSigner { /// simply the modulus and exponent of the public key, without information /// about the algorithm or or ASN1 object type identifier. String base64PublicKey(File publicKeyPemFile) { + return base64PublicKeyFromPem(publicKeyPemFile.readAsStringSync()); + } + + /// Extracts the base64 encoded DER from a PEM-encoded public key string. + String base64PublicKeyFromPem(String publicKeyPem) { final publicKey = _RSAPublicKeyFromBytes.rsaPublicKeyFromBytes( - _pemBytes(pemFile: publicKeyPemFile, type: PemLabel.publicKey), + PemCodec(PemLabel.publicKey).decode(publicKeyPem), ); final publicKeySeq = ASN1Sequence() @@ -64,11 +70,104 @@ class CodeSigner { return base64.encode(publicKeySeq.encodedBytes!); } - /// Reads a PEM file containing a key of type [type] and returns its contents - /// as bytes. - List _pemBytes({required File pemFile, required PemLabel type}) { - final privateKeyString = pemFile.readAsStringSync(); - return PemCodec(type).decode(privateKeyString); + /// Runs a command and returns its stdout, expected to be a PEM public key. + Future runPublicKeyCmd(String command) async { + final result = await process.run( + 'sh', + ['-c', command], + ); + + if (result.exitCode != 0) { + throw ProcessException( + command, + [], + 'Command failed with exit code ${result.exitCode}: ${result.stderr}', + result.exitCode, + ); + } + + final output = '${result.stdout}'.trim(); + if (!output.contains('-----BEGIN') || !output.contains('PUBLIC KEY')) { + if (output.isEmpty) { + throw const FormatException( + 'Command produced no output. ' + 'Expected a PEM-encoded public key.', + ); + } + final preview = output.length > 100 + ? '${output.substring(0, 100)}...' + : output; + throw FormatException( + 'Command output does not appear to be a PEM-encoded public key: ' + '$preview', + ); + } + + return output; + } + + /// Signs data by piping it to a command's stdin and reading the base64 + /// signature from stdout. + Future signWithCmd({ + required String data, + required String command, + }) async { + final proc = await process.start( + 'sh', + ['-c', command], + ); + + // Write data to stdin and close it + proc.stdin.write(data); + await proc.stdin.close(); + + // Read stdout and stderr concurrently to avoid potential deadlock + final results = await Future.wait([ + proc.stdout.transform(utf8.decoder).join(), + proc.stderr.transform(utf8.decoder).join(), + ]); + final stdout = results[0]; + final stderr = results[1]; + final exitCode = await proc.exitCode; + + if (exitCode != 0) { + throw ProcessException( + command, + [], + 'Sign command failed with exit code $exitCode: $stderr', + exitCode, + ); + } + + final signature = stdout.trim(); + if (signature.isEmpty) { + throw const FormatException('Sign command produced no output'); + } + + return signature; + } + + /// Verifies a signature against a message using a PEM-encoded public key. + bool verify({ + required String message, + required String signature, + required String publicKeyPem, + }) { + final publicKey = _RSAPublicKeyFromBytes.rsaPublicKeyFromBytes( + PemCodec(PemLabel.publicKey).decode(publicKeyPem), + ); + + final signer = Signer('SHA-256/RSA') + ..init(false, PublicKeyParameter(publicKey)); + + try { + return signer.verifySignature( + utf8.encode(message), + RSASignature(base64.decode(signature)), + ); + } on Exception { + return false; + } } /// Parses a PEM-encoded private key string and returns the key bytes along diff --git a/packages/shorebird_cli/lib/src/commands/patch/aar_patcher.dart b/packages/shorebird_cli/lib/src/commands/patch/aar_patcher.dart index 622d73a2..f192e96f 100644 --- a/packages/shorebird_cli/lib/src/commands/patch/aar_patcher.dart +++ b/packages/shorebird_cli/lib/src/commands/patch/aar_patcher.dart @@ -9,9 +9,7 @@ import 'package:shorebird_cli/src/archive_analysis/android_archive_differ.dart'; import 'package:shorebird_cli/src/artifact_builder/artifact_builder.dart'; import 'package:shorebird_cli/src/artifact_manager.dart'; import 'package:shorebird_cli/src/code_push_client_wrapper.dart'; -import 'package:shorebird_cli/src/code_signer.dart'; import 'package:shorebird_cli/src/commands/patch/patch.dart'; -import 'package:shorebird_cli/src/common_arguments.dart'; import 'package:shorebird_cli/src/extensions/arg_results.dart'; import 'package:shorebird_cli/src/logging/logging.dart'; import 'package:shorebird_cli/src/patch_diff_checker.dart'; @@ -143,13 +141,7 @@ class AarPatcher extends Patcher { logger.detail('Creating artifact for $artifactPath'); final patchArtifact = File(artifactPath); final hash = sha256.convert(await patchArtifact.readAsBytes()).toString(); - - final privateKeyFile = argResults.file( - CommonArguments.privateKeyArg.name, - ); - final hashSignature = privateKeyFile != null - ? codeSigner.sign(message: hash, privateKeyPemFile: privateKeyFile) - : null; + final hashSignature = await signHash(hash); try { final diffPath = await artifactManager.createDiff( diff --git a/packages/shorebird_cli/lib/src/commands/patch/android_patcher.dart b/packages/shorebird_cli/lib/src/commands/patch/android_patcher.dart index d4ed88b9..aed1e968 100644 --- a/packages/shorebird_cli/lib/src/commands/patch/android_patcher.dart +++ b/packages/shorebird_cli/lib/src/commands/patch/android_patcher.dart @@ -9,9 +9,7 @@ import 'package:shorebird_cli/src/archive_analysis/android_archive_differ.dart'; import 'package:shorebird_cli/src/artifact_builder/artifact_builder.dart'; import 'package:shorebird_cli/src/artifact_manager.dart'; import 'package:shorebird_cli/src/code_push_client_wrapper.dart'; -import 'package:shorebird_cli/src/code_signer.dart'; import 'package:shorebird_cli/src/commands/patch/patcher.dart'; -import 'package:shorebird_cli/src/common_arguments.dart'; import 'package:shorebird_cli/src/doctor.dart'; import 'package:shorebird_cli/src/extensions/arg_results.dart'; import 'package:shorebird_cli/src/logging/logging.dart'; @@ -189,13 +187,7 @@ Please refer to ${link(uri: Uri.parse('https://github.com/shorebirdtech/shorebir logger.detail('Creating artifact for $patchArtifactPath'); final patchArtifact = File(patchArtifactPath); final hash = sha256.convert(await patchArtifact.readAsBytes()).toString(); - - final privateKeyFile = argResults.file( - CommonArguments.privateKeyArg.name, - ); - final hashSignature = privateKeyFile != null - ? codeSigner.sign(message: hash, privateKeyPemFile: privateKeyFile) - : null; + final hashSignature = await signHash(hash); try { final diffPath = await artifactManager.createDiff( diff --git a/packages/shorebird_cli/lib/src/commands/patch/ios_framework_patcher.dart b/packages/shorebird_cli/lib/src/commands/patch/ios_framework_patcher.dart index 87af7f72..383c7f11 100644 --- a/packages/shorebird_cli/lib/src/commands/patch/ios_framework_patcher.dart +++ b/packages/shorebird_cli/lib/src/commands/patch/ios_framework_patcher.dart @@ -10,9 +10,7 @@ import 'package:shorebird_cli/src/archive_analysis/apple_archive_differ.dart'; import 'package:shorebird_cli/src/artifact_builder/artifact_builder.dart'; import 'package:shorebird_cli/src/artifact_manager.dart'; import 'package:shorebird_cli/src/code_push_client_wrapper.dart'; -import 'package:shorebird_cli/src/code_signer.dart'; import 'package:shorebird_cli/src/commands/patch/patch.dart'; -import 'package:shorebird_cli/src/common_arguments.dart'; import 'package:shorebird_cli/src/doctor.dart'; import 'package:shorebird_cli/src/executables/aot_tools.dart'; import 'package:shorebird_cli/src/executables/xcodebuild.dart'; @@ -232,11 +230,8 @@ class IosFrameworkPatcher extends Patcher { } final patchFileSize = patchFile.statSync().size; - final privateKeyFile = argResults.file(CommonArguments.privateKeyArg.name); final hash = sha256.convert(patchBuildFile.readAsBytesSync()).toString(); - final hashSignature = privateKeyFile != null - ? codeSigner.sign(message: hash, privateKeyPemFile: privateKeyFile) - : null; + final hashSignature = await signHash(hash); return { Arch.arm64: PatchArtifactBundle( diff --git a/packages/shorebird_cli/lib/src/commands/patch/ios_patcher.dart b/packages/shorebird_cli/lib/src/commands/patch/ios_patcher.dart index 7ab276b9..2fc7b82d 100644 --- a/packages/shorebird_cli/lib/src/commands/patch/ios_patcher.dart +++ b/packages/shorebird_cli/lib/src/commands/patch/ios_patcher.dart @@ -11,9 +11,7 @@ import 'package:shorebird_cli/src/archive_analysis/archive_analysis.dart'; import 'package:shorebird_cli/src/artifact_builder/artifact_builder.dart'; import 'package:shorebird_cli/src/artifact_manager.dart'; import 'package:shorebird_cli/src/code_push_client_wrapper.dart'; -import 'package:shorebird_cli/src/code_signer.dart'; import 'package:shorebird_cli/src/commands/patch/patcher.dart'; -import 'package:shorebird_cli/src/common_arguments.dart'; import 'package:shorebird_cli/src/doctor.dart'; import 'package:shorebird_cli/src/executables/executables.dart'; import 'package:shorebird_cli/src/extensions/arg_results.dart'; @@ -304,11 +302,8 @@ For more information see: ${supportedFlutterVersionsUrl.toLink()}'''); } final patchFileSize = patchFile.statSync().size; - final privateKeyFile = argResults.file(CommonArguments.privateKeyArg.name); final hash = sha256.convert(patchBuildFile.readAsBytesSync()).toString(); - final hashSignature = privateKeyFile != null - ? codeSigner.sign(message: hash, privateKeyPemFile: privateKeyFile) - : null; + final hashSignature = await signHash(hash); return { Arch.arm64: PatchArtifactBundle( diff --git a/packages/shorebird_cli/lib/src/commands/patch/linux_patcher.dart b/packages/shorebird_cli/lib/src/commands/patch/linux_patcher.dart index d3016eb6..e1b3c8bb 100644 --- a/packages/shorebird_cli/lib/src/commands/patch/linux_patcher.dart +++ b/packages/shorebird_cli/lib/src/commands/patch/linux_patcher.dart @@ -8,9 +8,7 @@ import 'package:shorebird_cli/src/archive_analysis/linux_bundle_differ.dart'; import 'package:shorebird_cli/src/artifact_builder/artifact_builder.dart'; import 'package:shorebird_cli/src/artifact_manager.dart'; import 'package:shorebird_cli/src/code_push_client_wrapper.dart'; -import 'package:shorebird_cli/src/code_signer.dart'; import 'package:shorebird_cli/src/commands/commands.dart'; -import 'package:shorebird_cli/src/common_arguments.dart'; import 'package:shorebird_cli/src/extensions/arg_results.dart'; import 'package:shorebird_cli/src/logging/logging.dart'; import 'package:shorebird_cli/src/patch_diff_checker.dart'; @@ -85,10 +83,7 @@ class LinuxPatcher extends Patcher { // build/linux/x64/release/bundle final appSoPath = p.join(tempDir.path, 'lib', 'libapp.so'); - final privateKeyFile = argResults.file(CommonArguments.privateKeyArg.name); - final hashSignature = privateKeyFile != null - ? codeSigner.sign(message: hash, privateKeyPemFile: privateKeyFile) - : null; + final hashSignature = await signHash(hash); final String diffPath; try { diff --git a/packages/shorebird_cli/lib/src/commands/patch/macos_patcher.dart b/packages/shorebird_cli/lib/src/commands/patch/macos_patcher.dart index 24d91529..047a0e6c 100644 --- a/packages/shorebird_cli/lib/src/commands/patch/macos_patcher.dart +++ b/packages/shorebird_cli/lib/src/commands/patch/macos_patcher.dart @@ -8,9 +8,7 @@ import 'package:shorebird_cli/src/archive_analysis/apple_archive_differ.dart'; import 'package:shorebird_cli/src/artifact_builder/artifact_builder.dart'; import 'package:shorebird_cli/src/artifact_manager.dart'; import 'package:shorebird_cli/src/code_push_client_wrapper.dart'; -import 'package:shorebird_cli/src/code_signer.dart'; import 'package:shorebird_cli/src/commands/patch/patch.dart'; -import 'package:shorebird_cli/src/common_arguments.dart'; import 'package:shorebird_cli/src/doctor.dart'; import 'package:shorebird_cli/src/executables/executables.dart'; import 'package:shorebird_cli/src/extensions/arg_results.dart'; @@ -201,11 +199,8 @@ For more information see: ${supportedFlutterVersionsUrl.toLink()}'''); final patchFile = File(patchFilePath); final patchFileSize = patchFile.statSync().size; - final privateKeyFile = argResults.file(CommonArguments.privateKeyArg.name); final hash = sha256.convert(patchArtifact.readAsBytesSync()).toString(); - final hashSignature = privateKeyFile != null - ? codeSigner.sign(message: hash, privateKeyPemFile: privateKeyFile) - : null; + final hashSignature = await signHash(hash); return PatchArtifactBundle( arch: arch.arch, diff --git a/packages/shorebird_cli/lib/src/commands/patch/patch_command.dart b/packages/shorebird_cli/lib/src/commands/patch/patch_command.dart index c5a95309..f7668a0f 100644 --- a/packages/shorebird_cli/lib/src/commands/patch/patch_command.dart +++ b/packages/shorebird_cli/lib/src/commands/patch/patch_command.dart @@ -140,6 +140,14 @@ To target the latest release (e.g. the release that was most recently updated) u CommonArguments.publicKeyArg.name, help: CommonArguments.publicKeyArg.description, ) + ..addOption( + CommonArguments.publicKeyCmd.name, + help: CommonArguments.publicKeyCmd.description, + ) + ..addOption( + CommonArguments.signCmd.name, + help: CommonArguments.signCmd.description, + ) ..addOption( CommonArguments.splitDebugInfoArg.name, help: CommonArguments.splitDebugInfoArg.description, @@ -287,7 +295,7 @@ NOTE: this is ${styleBold.wrap('not')} recommended. Asset changes cannot be incl Future createPatch(Patcher patcher) async { await patcher.assertPreconditions(); await patcher.assertArgsAreValid(); - results.assertAbsentOrValidKeyPair(); + results.assertAbsentOrValidKeyPairOrCommands(); try { await shorebirdValidator.validateFlavors( @@ -444,7 +452,9 @@ Building patch with Flutter $flutterVersionString usedIgnoreNativeChangesFlag: allowNativeDiffs, hasNativeChanges: diffStatus.hasNativeChanges, inferredReleaseVersion: inferredReleaseVersion, - isSigned: results.wasParsed(CommonArguments.privateKeyArg.name), + isSigned: + results.wasParsed(CommonArguments.privateKeyArg.name) || + results.wasParsed(CommonArguments.signCmd.name), environment: BuildEnvironmentMetadata( flutterRevision: shorebirdEnv.flutterRevision, operatingSystem: platform.operatingSystem, diff --git a/packages/shorebird_cli/lib/src/commands/patch/patcher.dart b/packages/shorebird_cli/lib/src/commands/patch/patcher.dart index eca40a93..ae6044ba 100644 --- a/packages/shorebird_cli/lib/src/commands/patch/patcher.dart +++ b/packages/shorebird_cli/lib/src/commands/patch/patcher.dart @@ -4,16 +4,19 @@ import 'package:args/args.dart'; import 'package:mason_logger/mason_logger.dart'; import 'package:path/path.dart' as p; import 'package:shorebird_cli/src/code_push_client_wrapper.dart'; +import 'package:shorebird_cli/src/code_signer.dart'; import 'package:shorebird_cli/src/common_arguments.dart'; import 'package:shorebird_cli/src/deployment_track.dart'; import 'package:shorebird_cli/src/extensions/arg_results.dart'; import 'package:shorebird_cli/src/extensions/iterable.dart'; +import 'package:shorebird_cli/src/logging/logging.dart'; import 'package:shorebird_cli/src/metadata/metadata.dart'; import 'package:shorebird_cli/src/patch_diff_checker.dart'; import 'package:shorebird_cli/src/platform/platform.dart'; import 'package:shorebird_cli/src/release_type.dart'; import 'package:shorebird_cli/src/shorebird_documentation.dart'; import 'package:shorebird_cli/src/shorebird_env.dart'; +import 'package:shorebird_cli/src/third_party/flutter_tools/lib/flutter_tools.dart'; import 'package:shorebird_code_push_client/shorebird_code_push_client.dart'; import 'package:shorebird_code_push_protocol/shorebird_code_push_protocol.dart'; @@ -127,6 +130,90 @@ More info: ${troubleshootingUrl.toLink()}. /// Whether to allow changes in native code (--allow-native-diffs). bool get allowNativeDiffs => argResults['allow-native-diffs'] == true; + /// Returns a function that signs data, or null if no signing is configured. + Future Function(String)? _resolveSigner() { + final privateKeyFile = argResults.file(CommonArguments.privateKeyArg.name); + if (privateKeyFile != null) { + return (hash) async => + codeSigner.sign(message: hash, privateKeyPemFile: privateKeyFile); + } + + final signCmd = argResults[CommonArguments.signCmd.name] as String?; + if (signCmd != null) { + return (hash) async { + try { + return await codeSigner.signWithCmd(data: hash, command: signCmd); + } on ProcessException catch (e) { + logger.err( + 'Failed to run --${CommonArguments.signCmd.name}: ${e.message}', + ); + throw ProcessExit(ExitCode.software.code); + } on FormatException catch (e) { + logger.err( + '--${CommonArguments.signCmd.name} produced invalid output: ' + '${e.message}', + ); + throw ProcessExit(ExitCode.software.code); + } + }; + } + + return null; + } + + /// Returns the public key PEM from the configured source, or null. + Future _resolvePublicKeyPem() async { + try { + return await argResults.resolvePublicKeyPem(); + } on ProcessException catch (e) { + logger.err( + 'Failed to run ' + '--${CommonArguments.publicKeyCmd.name}: ${e.message}', + ); + throw ProcessExit(ExitCode.software.code); + } on FormatException catch (e) { + logger.err( + '--${CommonArguments.publicKeyCmd.name} produced invalid output: ' + '${e.message}', + ); + throw ProcessExit(ExitCode.software.code); + } + } + + /// Signs a hash using the configured signing method. + /// + /// Returns null if no signing is configured. + Future signHash(String hash) async { + final signer = _resolveSigner(); + if (signer == null) return null; + + final signature = await signer(hash); + final publicKeyPem = await _resolvePublicKeyPem(); + + if (publicKeyPem == null) { + logger.err( + 'A public key is required for code signing. ' + 'Provide --${CommonArguments.publicKeyArg.name} or ' + '--${CommonArguments.publicKeyCmd.name}.', + ); + throw ProcessExit(ExitCode.usage.code); + } + + if (!codeSigner.verify( + message: hash, + signature: signature, + publicKeyPem: publicKeyPem, + )) { + logger.err( + 'Signature verification failed. The signature does not match ' + 'the provided public key.', + ); + throw ProcessExit(ExitCode.software.code); + } + + return signature; + } + /// The link percentage for the generated patch artifact if applicable. /// Returns `null` if the platform does not use a linker or if the linking /// step has not yet been run. diff --git a/packages/shorebird_cli/lib/src/commands/patch/windows_patcher.dart b/packages/shorebird_cli/lib/src/commands/patch/windows_patcher.dart index 88016927..0dda88c9 100644 --- a/packages/shorebird_cli/lib/src/commands/patch/windows_patcher.dart +++ b/packages/shorebird_cli/lib/src/commands/patch/windows_patcher.dart @@ -9,9 +9,7 @@ import 'package:shorebird_cli/src/archive_analysis/windows_archive_differ.dart'; import 'package:shorebird_cli/src/artifact_builder/artifact_builder.dart'; import 'package:shorebird_cli/src/artifact_manager.dart'; import 'package:shorebird_cli/src/code_push_client_wrapper.dart'; -import 'package:shorebird_cli/src/code_signer.dart'; import 'package:shorebird_cli/src/commands/patch/patcher.dart'; -import 'package:shorebird_cli/src/common_arguments.dart'; import 'package:shorebird_cli/src/doctor.dart'; import 'package:shorebird_cli/src/executables/executables.dart'; import 'package:shorebird_cli/src/extensions/arg_results.dart'; @@ -109,10 +107,7 @@ class WindowsPatcher extends Patcher { // build/windows/x64/runner/Release final appSoPath = p.join(tempDir.path, 'data', 'app.so'); - final privateKeyFile = argResults.file(CommonArguments.privateKeyArg.name); - final hashSignature = privateKeyFile != null - ? codeSigner.sign(message: hash, privateKeyPemFile: privateKeyFile) - : null; + final hashSignature = await signHash(hash); final String diffPath; try { diff --git a/packages/shorebird_cli/lib/src/commands/release/aar_releaser.dart b/packages/shorebird_cli/lib/src/commands/release/aar_releaser.dart index 3243f185..96967850 100644 --- a/packages/shorebird_cli/lib/src/commands/release/aar_releaser.dart +++ b/packages/shorebird_cli/lib/src/commands/release/aar_releaser.dart @@ -74,11 +74,12 @@ class AarReleaser extends Releaser { @override Future buildReleaseArtifacts() async { + final base64PublicKey = await getEncodedPublicKey(); await artifactBuilder.buildAar( buildNumber: buildNumber, targetPlatforms: architectures, args: argResults.forwardedArgs, - base64PublicKey: argResults.encodedPublicKey, + base64PublicKey: base64PublicKey, ); // Copy release AAR to a new directory to avoid overwriting with diff --git a/packages/shorebird_cli/lib/src/commands/release/android_releaser.dart b/packages/shorebird_cli/lib/src/commands/release/android_releaser.dart index 997cba01..b9ed5ed7 100644 --- a/packages/shorebird_cli/lib/src/commands/release/android_releaser.dart +++ b/packages/shorebird_cli/lib/src/commands/release/android_releaser.dart @@ -102,7 +102,7 @@ Please comment and upvote ${link(uri: Uri.parse('https://github.com/shorebirdtec @override Future buildReleaseArtifacts() async { - final base64PublicKey = argResults.encodedPublicKey; + final base64PublicKey = await getEncodedPublicKey(); final aab = await artifactBuilder.buildAppBundle( flavor: flavor, target: target, diff --git a/packages/shorebird_cli/lib/src/commands/release/ios_framework_releaser.dart b/packages/shorebird_cli/lib/src/commands/release/ios_framework_releaser.dart index 86420555..f2b30dee 100644 --- a/packages/shorebird_cli/lib/src/commands/release/ios_framework_releaser.dart +++ b/packages/shorebird_cli/lib/src/commands/release/ios_framework_releaser.dart @@ -78,9 +78,10 @@ class IosFrameworkReleaser extends Releaser { shorebirdSupplementDir!.deleteSync(recursive: true); } + final base64PublicKey = await getEncodedPublicKey(); await artifactBuilder.buildIosFramework( args: argResults.forwardedArgs, - base64PublicKey: argResults.encodedPublicKey, + base64PublicKey: base64PublicKey, ); // Copy release xcframework to a new directory to avoid overwriting with diff --git a/packages/shorebird_cli/lib/src/commands/release/ios_releaser.dart b/packages/shorebird_cli/lib/src/commands/release/ios_releaser.dart index f27c4dba..70b3eca5 100644 --- a/packages/shorebird_cli/lib/src/commands/release/ios_releaser.dart +++ b/packages/shorebird_cli/lib/src/commands/release/ios_releaser.dart @@ -102,12 +102,13 @@ To change the version of this release, change your app's version in your pubspec shorebirdSupplementDir!.deleteSync(recursive: true); } + final base64PublicKey = await getEncodedPublicKey(); await artifactBuilder.buildIpa( codesign: codesign, flavor: flavor, target: target, args: argResults.forwardedArgs, - base64PublicKey: argResults.encodedPublicKey, + base64PublicKey: base64PublicKey, ); final xcarchiveDirectory = artifactManager.getXcarchiveDirectory(); diff --git a/packages/shorebird_cli/lib/src/commands/release/linux_releaser.dart b/packages/shorebird_cli/lib/src/commands/release/linux_releaser.dart index 37346950..a168c423 100644 --- a/packages/shorebird_cli/lib/src/commands/release/linux_releaser.dart +++ b/packages/shorebird_cli/lib/src/commands/release/linux_releaser.dart @@ -64,10 +64,11 @@ To change the version of this release, change your app's version in your pubspec @override Future buildReleaseArtifacts() async { + final base64PublicKey = await getEncodedPublicKey(); await artifactBuilder.buildLinuxApp( target: target, args: argResults.forwardedArgs, - base64PublicKey: argResults.encodedPublicKey, + base64PublicKey: base64PublicKey, ); return artifactManager.linuxBundleDirectory; diff --git a/packages/shorebird_cli/lib/src/commands/release/macos_releaser.dart b/packages/shorebird_cli/lib/src/commands/release/macos_releaser.dart index 39cef48c..67c3257b 100644 --- a/packages/shorebird_cli/lib/src/commands/release/macos_releaser.dart +++ b/packages/shorebird_cli/lib/src/commands/release/macos_releaser.dart @@ -93,12 +93,13 @@ To change the version of this release, change your app's version in your pubspec ); } + final base64PublicKey = await getEncodedPublicKey(); await artifactBuilder.buildMacos( codesign: codesign, flavor: flavor, target: target, args: argResults.forwardedArgs, - base64PublicKey: argResults.encodedPublicKey, + base64PublicKey: base64PublicKey, ); final appDirectory = artifactManager.getMacOSAppDirectory(flavor: flavor); diff --git a/packages/shorebird_cli/lib/src/commands/release/release_command.dart b/packages/shorebird_cli/lib/src/commands/release/release_command.dart index c3df9c5b..ed855a75 100644 --- a/packages/shorebird_cli/lib/src/commands/release/release_command.dart +++ b/packages/shorebird_cli/lib/src/commands/release/release_command.dart @@ -145,6 +145,10 @@ of the iOS app that is using this module. (aar and ios-framework only)''', CommonArguments.publicKeyArg.name, help: CommonArguments.publicKeyArg.description, ) + ..addOption( + CommonArguments.publicKeyCmd.name, + help: CommonArguments.publicKeyCmd.description, + ) ..addOption( CommonArguments.splitDebugInfoArg.name, help: CommonArguments.splitDebugInfoArg.description, @@ -367,14 +371,18 @@ of the iOS app that is using this module. (aar and ios-framework only)''', /// Validates arguments that are common to all release types. Future assertArgsAreValid(Releaser releaser) async { - results.assertAbsentOrValidPublicKey(); + results.assertAbsentOrValidPublicKeyOrCmd(); final shorebirdYaml = shorebirdEnv.getShorebirdYaml(); - if (shorebirdYaml?.patchVerification != null && - !results.wasParsed(CommonArguments.publicKeyArg.name)) { + final hasPublicKey = + results.wasParsed(CommonArguments.publicKeyArg.name) || + results.wasParsed(CommonArguments.publicKeyCmd.name); + if (shorebirdYaml?.patchVerification != null && !hasPublicKey) { logger.warn( 'patch_verification is set in shorebird.yaml but ' - '--${CommonArguments.publicKeyArg.name} was not provided.\n' + 'no public key was provided ' + '(--${CommonArguments.publicKeyArg.name} ' + 'or --${CommonArguments.publicKeyCmd.name}).\n' 'patch_verification configuration will have no effect.', ); } @@ -554,10 +562,13 @@ ${summary.join('\n')} required Release release, required Releaser releaser, }) async { + final hasPublicKey = + results.wasParsed(CommonArguments.publicKeyArg.name) || + results.wasParsed(CommonArguments.publicKeyCmd.name); final baseMetadata = UpdateReleaseMetadata( releasePlatform: releaser.releaseType.releasePlatform, flutterVersionOverride: flutterVersionArg, - includesPublicKey: results.wasParsed(CommonArguments.publicKeyArg.name), + includesPublicKey: hasPublicKey, environment: BuildEnvironmentMetadata( flutterRevision: shorebirdEnv.flutterRevision, operatingSystem: platform.operatingSystem, diff --git a/packages/shorebird_cli/lib/src/commands/release/releaser.dart b/packages/shorebird_cli/lib/src/commands/release/releaser.dart index 18192e14..d114bffd 100644 --- a/packages/shorebird_cli/lib/src/commands/release/releaser.dart +++ b/packages/shorebird_cli/lib/src/commands/release/releaser.dart @@ -2,6 +2,7 @@ import 'dart:io'; import 'package:args/args.dart'; import 'package:pub_semver/pub_semver.dart'; +import 'package:shorebird_cli/src/extensions/arg_results.dart'; import 'package:shorebird_cli/src/metadata/metadata.dart'; import 'package:shorebird_cli/src/release_type.dart'; import 'package:shorebird_cli/src/shorebird_env.dart'; @@ -81,4 +82,9 @@ abstract class Releaser { Future getReleaseVersion({ required FileSystemEntity releaseArtifactRoot, }); + + /// Gets the base64-encoded public key from either file or command. + /// + /// Returns null if no public key is configured. + Future getEncodedPublicKey() => argResults.getEncodedPublicKey(); } diff --git a/packages/shorebird_cli/lib/src/commands/release/windows_releaser.dart b/packages/shorebird_cli/lib/src/commands/release/windows_releaser.dart index 46ce53a3..3edc8cb1 100644 --- a/packages/shorebird_cli/lib/src/commands/release/windows_releaser.dart +++ b/packages/shorebird_cli/lib/src/commands/release/windows_releaser.dart @@ -66,11 +66,12 @@ To change the version of this release, change your app's version in your pubspec } @override - Future buildReleaseArtifacts() { + Future buildReleaseArtifacts() async { + final base64PublicKey = await getEncodedPublicKey(); return artifactBuilder.buildWindowsApp( target: target, args: argResults.forwardedArgs, - base64PublicKey: argResults.encodedPublicKey, + base64PublicKey: base64PublicKey, ); } diff --git a/packages/shorebird_cli/lib/src/common_arguments.dart b/packages/shorebird_cli/lib/src/common_arguments.dart index 63b4af46..8ddbe392 100644 --- a/packages/shorebird_cli/lib/src/common_arguments.dart +++ b/packages/shorebird_cli/lib/src/common_arguments.dart @@ -110,6 +110,25 @@ The path for a private key .pem file that will be used to sign the patch artifac ''', ); + /// An argument that allows the user to specify a command that outputs a + /// PEM-encoded public key to stdout. + static const publicKeyCmd = ArgumentDescriber( + name: 'public-key-cmd', + description: ''' +Command that outputs a PEM-encoded public key to stdout for patch signature validation. +''', + ); + + /// An argument that allows the user to specify a command that signs data. + /// The command reads data from stdin and outputs a base64 signature to + /// stdout. + static const signCmd = ArgumentDescriber( + name: 'sign-cmd', + description: ''' +Command that reads data from stdin and outputs a base64 signature to stdout. +''', + ); + /// An argument that allows the user to specify a release version. You will /// most likely want to provide a custom description for this argument that /// more thoroughly explains what the release version is used for. diff --git a/packages/shorebird_cli/lib/src/extensions/arg_results.dart b/packages/shorebird_cli/lib/src/extensions/arg_results.dart index e5d81709..e69df105 100644 --- a/packages/shorebird_cli/lib/src/extensions/arg_results.dart +++ b/packages/shorebird_cli/lib/src/extensions/arg_results.dart @@ -73,6 +73,25 @@ extension CodeSign on ArgResults { } } + /// Resolves the public key PEM string from the configured source. + /// + /// Returns null if no public key is configured. Throws + /// [ProcessException] or [FormatException] if a command-based key + /// fails. + Future resolvePublicKeyPem() async { + final publicKeyFile = file(CommonArguments.publicKeyArg.name); + if (publicKeyFile != null) { + return publicKeyFile.readAsStringSync(); + } + + final publicKeyCmd = this[CommonArguments.publicKeyCmd.name] as String?; + if (publicKeyCmd != null && wasParsed(CommonArguments.publicKeyCmd.name)) { + return codeSigner.runPublicKeyCmd(publicKeyCmd); + } + + return null; + } + /// Read the public key file and encode it to base64 if any. String? get encodedPublicKey { final publicKeyFile = file(CommonArguments.publicKeyArg.name); @@ -81,6 +100,121 @@ extension CodeSign on ArgResults { ? codeSigner.base64PublicKey(publicKeyFile) : null; } + + /// Validates key arguments for patch commands. + /// + /// Valid configurations: + /// - No signing (nothing provided) + /// - File-based: --public-key-path + --private-key-path + /// - Command-based: --public-key-cmd + --sign-cmd + /// - Mixed: --public-key-path + --sign-cmd + /// + /// Invalid configurations: + /// - Both --public-key-path and --public-key-cmd (ambiguous public key) + /// - Both --private-key-path and --sign-cmd (ambiguous signing method) + /// - --sign-cmd without a public key source + /// - --private-key-path without --public-key-path + void assertAbsentOrValidKeyPairOrCommands() { + final hasPublicKeyFile = wasParsed(CommonArguments.publicKeyArg.name); + final hasPrivateKeyFile = wasParsed(CommonArguments.privateKeyArg.name); + final hasPublicKeyCmd = wasParsed(CommonArguments.publicKeyCmd.name); + final hasSignCmd = wasParsed(CommonArguments.signCmd.name); + + // Can't have two public key sources + if (hasPublicKeyFile && hasPublicKeyCmd) { + logger.err( + 'Cannot specify both --${CommonArguments.publicKeyArg.name} and ' + '--${CommonArguments.publicKeyCmd.name}.', + ); + throw ProcessExit(ExitCode.usage.code); + } + + // Can't have two signing methods + if (hasPrivateKeyFile && hasSignCmd) { + logger.err( + 'Cannot specify both --${CommonArguments.privateKeyArg.name} and ' + '--${CommonArguments.signCmd.name}.', + ); + throw ProcessExit(ExitCode.usage.code); + } + + // File-based signing requires both file args + if (hasPrivateKeyFile || (hasPublicKeyFile && !hasSignCmd)) { + assertAbsentOrValidKeyPair(); + } + + // --sign-cmd requires a public key source + if (hasSignCmd && !hasPublicKeyFile && !hasPublicKeyCmd) { + logger.err( + '--${CommonArguments.signCmd.name} requires a public key ' + '(--${CommonArguments.publicKeyArg.name} or ' + '--${CommonArguments.publicKeyCmd.name}).', + ); + throw ProcessExit(ExitCode.usage.code); + } + + // Validate the public key file exists if provided + if (hasPublicKeyFile && hasSignCmd) { + assertAbsentOrValidPublicKey(); + } + } + + /// Validates public key arguments for release commands. + /// + /// Valid configurations: + /// - No public key (no signing) + /// - --public-key-path with valid file + /// - --public-key-cmd + /// + /// Invalid: mixing --public-key-path and --public-key-cmd + void assertAbsentOrValidPublicKeyOrCmd() { + final hasFilePath = wasParsed(CommonArguments.publicKeyArg.name); + final hasCmd = wasParsed(CommonArguments.publicKeyCmd.name); + + if (hasFilePath && hasCmd) { + logger.err( + 'Cannot specify both --${CommonArguments.publicKeyArg.name} and ' + '--${CommonArguments.publicKeyCmd.name}.', + ); + throw ProcessExit(ExitCode.usage.code); + } + + if (hasFilePath) { + assertAbsentOrValidPublicKey(); + } + } + + /// Get base64-encoded public key from either file or command. + /// + /// Returns null if no public key is configured. + Future getEncodedPublicKey() async { + try { + final pem = await resolvePublicKeyPem(); + if (pem == null) return null; + return codeSigner.base64PublicKeyFromPem(pem); + } on ProcessException catch (e) { + logger.err( + 'Failed to run ' + '--${CommonArguments.publicKeyCmd.name}: ${e.message}', + ); + throw ProcessExit(ExitCode.software.code); + } on FormatException catch (e) { + logger.err( + '--${CommonArguments.publicKeyCmd.name} produced invalid output: ' + '${e.message}', + ); + throw ProcessExit(ExitCode.software.code); + // Malformed PEM content causes ASN1 parsing errors (RangeError, etc.) + // ignore: avoid_catching_errors + } on Error catch (e) { + // ASN1 parsing errors for malformed PEM content + logger.err( + '--${CommonArguments.publicKeyCmd.name} output is not a valid ' + 'public key: $e', + ); + throw ProcessExit(ExitCode.software.code); + } + } } /// Extension on [ArgResults] to provide file related extensions. diff --git a/packages/shorebird_cli/test/src/code_signer_test.dart b/packages/shorebird_cli/test/src/code_signer_test.dart index 6b6bfcbe..4e9ae6fd 100644 --- a/packages/shorebird_cli/test/src/code_signer_test.dart +++ b/packages/shorebird_cli/test/src/code_signer_test.dart @@ -1,11 +1,24 @@ // cspell:words pubin dgst outform +import 'dart:async'; import 'dart:convert'; import 'dart:io'; +import 'package:mocktail/mocktail.dart'; import 'package:path/path.dart' as p; +import 'package:scoped_deps/scoped_deps.dart'; import 'package:shorebird_cli/src/code_signer.dart'; +import 'package:shorebird_cli/src/shorebird_process.dart'; import 'package:test/test.dart'; +class MockShorebirdProcess extends Mock implements ShorebirdProcess {} + +class MockShorebirdProcessResult extends Mock + implements ShorebirdProcessResult {} + +class MockProcess extends Mock implements Process {} + +class MockIOSink extends Mock implements IOSink {} + void main() { group( CodeSigner, @@ -119,9 +132,250 @@ void main() { ); }); }); + + group('base64PublicKeyFromPem', () { + test('output matches base64PublicKey from file', () { + final publicKeyPem = publicKeyFile.readAsStringSync(); + expect( + codeSigner.base64PublicKeyFromPem(publicKeyPem), + equals(codeSigner.base64PublicKey(publicKeyFile)), + ); + }); + }); + + group('verify', () { + const message = + '6b86b273ff34fce19d6b804eff5a3f5747ada4eaa22f1d49c01e52ddb7875b4b'; + + test('returns true for valid signature', () { + final signature = codeSigner.sign( + message: message, + privateKeyPemFile: privateKeyFile, + ); + final publicKeyPem = publicKeyFile.readAsStringSync(); + + expect( + codeSigner.verify( + message: message, + signature: signature, + publicKeyPem: publicKeyPem, + ), + isTrue, + ); + }); + + test('returns false for invalid signature', () { + final publicKeyPem = publicKeyFile.readAsStringSync(); + + expect( + codeSigner.verify( + message: message, + signature: 'invalid-signature', + publicKeyPem: publicKeyPem, + ), + isFalse, + ); + }); + + test('returns false for wrong message', () { + final signature = codeSigner.sign( + message: message, + privateKeyPemFile: privateKeyFile, + ); + final publicKeyPem = publicKeyFile.readAsStringSync(); + + expect( + codeSigner.verify( + message: 'wrong-message', + signature: signature, + publicKeyPem: publicKeyPem, + ), + isFalse, + ); + }); + }); }, onPlatform: { 'windows': const Skip('Does not have openssl installed by default'), }, ); + + group('CodeSigner command-based signing', () { + late ShorebirdProcess shorebirdProcess; + late CodeSigner codeSigner; + + setUp(() { + shorebirdProcess = MockShorebirdProcess(); + codeSigner = CodeSigner(); + }); + + group('runPublicKeyCmd', () { + test('returns trimmed stdout on success', () async { + final result = MockShorebirdProcessResult(); + when(() => result.exitCode).thenReturn(0); + when(() => result.stdout).thenReturn(''' +-----BEGIN PUBLIC KEY----- +MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA +-----END PUBLIC KEY----- +'''); + when( + () => shorebirdProcess.run(any(), any()), + ).thenAnswer((_) async => result); + + await runScoped( + () async { + final output = await codeSigner.runPublicKeyCmd('cat key.pem'); + expect(output, contains('-----BEGIN PUBLIC KEY-----')); + expect(output, contains('-----END PUBLIC KEY-----')); + }, + values: {processRef.overrideWith(() => shorebirdProcess)}, + ); + }); + + test('throws ProcessException on non-zero exit code', () async { + final result = MockShorebirdProcessResult(); + when(() => result.exitCode).thenReturn(1); + when(() => result.stderr).thenReturn('command not found'); + when( + () => shorebirdProcess.run(any(), any()), + ).thenAnswer((_) async => result); + + await runScoped( + () async { + await expectLater( + () => codeSigner.runPublicKeyCmd('invalid-command'), + throwsA(isA()), + ); + }, + values: {processRef.overrideWith(() => shorebirdProcess)}, + ); + }); + + test('throws FormatException when output is empty', () async { + final result = MockShorebirdProcessResult(); + when(() => result.exitCode).thenReturn(0); + when(() => result.stdout).thenReturn(''); + when( + () => shorebirdProcess.run(any(), any()), + ).thenAnswer((_) async => result); + + await runScoped( + () async { + await expectLater( + () => codeSigner.runPublicKeyCmd('empty-cmd'), + throwsA( + isA().having( + (e) => e.message, + 'message', + contains('produced no output'), + ), + ), + ); + }, + values: {processRef.overrideWith(() => shorebirdProcess)}, + ); + }); + + test('throws FormatException when output is not a PEM key', () async { + final result = MockShorebirdProcessResult(); + when(() => result.exitCode).thenReturn(0); + when(() => result.stdout).thenReturn('not a pem key'); + when( + () => shorebirdProcess.run(any(), any()), + ).thenAnswer((_) async => result); + + await runScoped( + () async { + await expectLater( + () => codeSigner.runPublicKeyCmd('echo "not a pem key"'), + throwsA(isA()), + ); + }, + values: {processRef.overrideWith(() => shorebirdProcess)}, + ); + }); + }); + + group('signWithCmd', () { + late MockProcess proc; + late MockIOSink stdin; + + setUp(() { + proc = MockProcess(); + stdin = MockIOSink(); + when(() => proc.stdin).thenReturn(stdin); + when(() => stdin.close()).thenAnswer((_) async {}); + }); + + test('returns trimmed stdout on success', () async { + when(() => proc.stdout).thenAnswer( + (_) => Stream.value(utf8.encode('base64signature\n')), + ); + when(() => proc.stderr).thenAnswer((_) => const Stream.empty()); + when(() => proc.exitCode).thenAnswer((_) async => 0); + when( + () => shorebirdProcess.start(any(), any()), + ).thenAnswer((_) async => proc); + + await runScoped( + () async { + final signature = await codeSigner.signWithCmd( + data: 'hash-to-sign', + command: 'sign-script.sh', + ); + expect(signature, equals('base64signature')); + }, + values: {processRef.overrideWith(() => shorebirdProcess)}, + ); + }); + + test('throws ProcessException on non-zero exit code', () async { + when(() => proc.stdout).thenAnswer((_) => const Stream.empty()); + when(() => proc.stderr).thenAnswer( + (_) => Stream.value(utf8.encode('error')), + ); + when(() => proc.exitCode).thenAnswer((_) async => 1); + when( + () => shorebirdProcess.start(any(), any()), + ).thenAnswer((_) async => proc); + + await runScoped( + () async { + await expectLater( + () => codeSigner.signWithCmd( + data: 'hash-to-sign', + command: 'failing-script.sh', + ), + throwsA(isA()), + ); + }, + values: {processRef.overrideWith(() => shorebirdProcess)}, + ); + }); + + test('throws FormatException when output is empty', () async { + when(() => proc.stdout).thenAnswer( + (_) => Stream.value(utf8.encode(' \n ')), + ); + when(() => proc.stderr).thenAnswer((_) => const Stream.empty()); + when(() => proc.exitCode).thenAnswer((_) async => 0); + when( + () => shorebirdProcess.start(any(), any()), + ).thenAnswer((_) async => proc); + + await runScoped( + () async { + await expectLater( + () => codeSigner.signWithCmd( + data: 'hash-to-sign', + command: 'empty-output-script.sh', + ), + throwsA(isA()), + ); + }, + values: {processRef.overrideWith(() => shorebirdProcess)}, + ); + }); + }); + }); } diff --git a/packages/shorebird_cli/test/src/commands/patch/aar_patcher_test.dart b/packages/shorebird_cli/test/src/commands/patch/aar_patcher_test.dart index 3433a15f..8780d6de 100644 --- a/packages/shorebird_cli/test/src/commands/patch/aar_patcher_test.dart +++ b/packages/shorebird_cli/test/src/commands/patch/aar_patcher_test.dart @@ -551,16 +551,18 @@ void main() { group('when a private key is provided', () { setUp(() { - final privateKey = File( - p.join( - Directory.systemTemp.createTempSync().path, - 'test-private.pem', - ), - )..createSync(); + final tempDir = Directory.systemTemp.createTempSync(); + final privateKey = File(p.join(tempDir.path, 'test-private.pem')) + ..createSync(); + final publicKey = File(p.join(tempDir.path, 'test-public.pem')) + ..writeAsStringSync('public-key-pem'); when( () => argResults[CommonArguments.privateKeyArg.name], ).thenReturn(privateKey.path); + when( + () => argResults[CommonArguments.publicKeyArg.name], + ).thenReturn(publicKey.path); when( () => codeSigner.sign( @@ -571,6 +573,13 @@ void main() { final message = invocation.namedArguments[#message] as String; return '$message-signature'; }); + when( + () => codeSigner.verify( + message: any(named: 'message'), + signature: any(named: 'signature'), + publicKeyPem: any(named: 'publicKeyPem'), + ), + ).thenReturn(true); }); test( diff --git a/packages/shorebird_cli/test/src/commands/patch/android_patcher_test.dart b/packages/shorebird_cli/test/src/commands/patch/android_patcher_test.dart index 9ab55825..4d91b1c3 100644 --- a/packages/shorebird_cli/test/src/commands/patch/android_patcher_test.dart +++ b/packages/shorebird_cli/test/src/commands/patch/android_patcher_test.dart @@ -594,16 +594,18 @@ Looked in: group('when a private key is provided', () { setUp(() { - final privateKey = File( - p.join( - Directory.systemTemp.createTempSync().path, - 'test-private.pem', - ), - )..createSync(); + final tempDir = Directory.systemTemp.createTempSync(); + final privateKey = File(p.join(tempDir.path, 'test-private.pem')) + ..createSync(); + final publicKey = File(p.join(tempDir.path, 'test-public.pem')) + ..writeAsStringSync('public-key-pem'); when( () => argResults[CommonArguments.privateKeyArg.name], ).thenReturn(privateKey.path); + when( + () => argResults[CommonArguments.publicKeyArg.name], + ).thenReturn(publicKey.path); when( () => codeSigner.sign( @@ -614,6 +616,13 @@ Looked in: final message = invocation.namedArguments[#message] as String; return '$message-signature'; }); + when( + () => codeSigner.verify( + message: any(named: 'message'), + signature: any(named: 'signature'), + publicKeyPem: any(named: 'publicKeyPem'), + ), + ).thenReturn(true); }); test( diff --git a/packages/shorebird_cli/test/src/commands/patch/ios_framework_patcher_test.dart b/packages/shorebird_cli/test/src/commands/patch/ios_framework_patcher_test.dart index acf0f419..49986270 100644 --- a/packages/shorebird_cli/test/src/commands/patch/ios_framework_patcher_test.dart +++ b/packages/shorebird_cli/test/src/commands/patch/ios_framework_patcher_test.dart @@ -872,16 +872,19 @@ void main() { group('when code signing the patch', () { setUp(() { + final tempDir = Directory.systemTemp.createTempSync(); final privateKey = File( - p.join( - Directory.systemTemp.createTempSync().path, - 'test-private.pem', - ), + p.join(tempDir.path, 'test-private.pem'), )..createSync(); + final publicKey = File(p.join(tempDir.path, 'test-public.pem')) + ..writeAsStringSync('public-key-pem'); when( () => argResults[CommonArguments.privateKeyArg.name], ).thenReturn(privateKey.path); + when( + () => argResults[CommonArguments.publicKeyArg.name], + ).thenReturn(publicKey.path); when( () => codeSigner.sign( @@ -892,6 +895,13 @@ void main() { final message = invocation.namedArguments[#message] as String; return '$message-signature'; }); + when( + () => codeSigner.verify( + message: any(named: 'message'), + signature: any(named: 'signature'), + publicKeyPem: any(named: 'publicKeyPem'), + ), + ).thenReturn(true); }); test( diff --git a/packages/shorebird_cli/test/src/commands/patch/ios_patcher_test.dart b/packages/shorebird_cli/test/src/commands/patch/ios_patcher_test.dart index d4ea02c5..dd78febb 100644 --- a/packages/shorebird_cli/test/src/commands/patch/ios_patcher_test.dart +++ b/packages/shorebird_cli/test/src/commands/patch/ios_patcher_test.dart @@ -1169,16 +1169,19 @@ For more information see: ${supportedFlutterVersionsUrl.toLink()}'''), group('when code signing the patch', () { setUp(() { + final tempDir = Directory.systemTemp.createTempSync(); final privateKey = File( - p.join( - Directory.systemTemp.createTempSync().path, - 'test-private.pem', - ), + p.join(tempDir.path, 'test-private.pem'), )..createSync(); + final publicKey = File(p.join(tempDir.path, 'test-public.pem')) + ..writeAsStringSync('public-key-pem'); when( () => argResults[CommonArguments.privateKeyArg.name], ).thenReturn(privateKey.path); + when( + () => argResults[CommonArguments.publicKeyArg.name], + ).thenReturn(publicKey.path); when( () => codeSigner.sign( @@ -1189,6 +1192,13 @@ For more information see: ${supportedFlutterVersionsUrl.toLink()}'''), final message = invocation.namedArguments[#message] as String; return '$message-signature'; }); + when( + () => codeSigner.verify( + message: any(named: 'message'), + signature: any(named: 'signature'), + publicKeyPem: any(named: 'publicKeyPem'), + ), + ).thenReturn(true); }); test( diff --git a/packages/shorebird_cli/test/src/commands/patch/linux_patcher_test.dart b/packages/shorebird_cli/test/src/commands/patch/linux_patcher_test.dart index 37514f7a..cdec281f 100644 --- a/packages/shorebird_cli/test/src/commands/patch/linux_patcher_test.dart +++ b/packages/shorebird_cli/test/src/commands/patch/linux_patcher_test.dart @@ -366,6 +366,12 @@ void main() { group('when signing keys are provided', () { setUp(() { + final publicKeyFile = File( + p.join( + Directory.systemTemp.createTempSync().path, + 'public-key.pem', + ), + )..createSync(); when( () => artifactManager.createDiff( releaseArtifactPath: any(named: 'releaseArtifactPath'), @@ -374,7 +380,7 @@ void main() { ).thenAnswer((_) async => diffFile.path); when( () => argResults[CommonArguments.publicKeyArg.name], - ).thenReturn('public-key.pem'); + ).thenReturn(publicKeyFile.path); when( () => argResults[CommonArguments.privateKeyArg.name], ).thenReturn('private-key.pem'); @@ -384,6 +390,13 @@ void main() { privateKeyPemFile: any(named: 'privateKeyPemFile'), ), ).thenReturn('signature'); + when( + () => codeSigner.verify( + message: any(named: 'message'), + signature: any(named: 'signature'), + publicKeyPem: any(named: 'publicKeyPem'), + ), + ).thenReturn(true); }); test('signs patch', () async { diff --git a/packages/shorebird_cli/test/src/commands/patch/macos_patcher_test.dart b/packages/shorebird_cli/test/src/commands/patch/macos_patcher_test.dart index 25d2149a..20bc3c4b 100644 --- a/packages/shorebird_cli/test/src/commands/patch/macos_patcher_test.dart +++ b/packages/shorebird_cli/test/src/commands/patch/macos_patcher_test.dart @@ -952,6 +952,13 @@ For more information see: ${supportedFlutterVersionsUrl.toLink()}'''), when( () => argResults[CommonArguments.privateKeyArg.name], ).thenReturn(createTempFile('private.pem').path); + when( + () => argResults[CommonArguments.publicKeyArg.name], + ).thenReturn( + (createTempFile( + 'public.pem', + )..writeAsStringSync('public-key-pem')).path, + ); when( () => codeSigner.sign( @@ -959,6 +966,13 @@ For more information see: ${supportedFlutterVersionsUrl.toLink()}'''), privateKeyPemFile: any(named: 'privateKeyPemFile'), ), ).thenReturn('my-signature'); + when( + () => codeSigner.verify( + message: any(named: 'message'), + signature: any(named: 'signature'), + publicKeyPem: any(named: 'publicKeyPem'), + ), + ).thenReturn(true); }); test('returns artifact bundles with non-null hash signature', () async { diff --git a/packages/shorebird_cli/test/src/commands/patch/patch_command_test.dart b/packages/shorebird_cli/test/src/commands/patch/patch_command_test.dart index 08f5b5ec..0a5a955d 100644 --- a/packages/shorebird_cli/test/src/commands/patch/patch_command_test.dart +++ b/packages/shorebird_cli/test/src/commands/patch/patch_command_test.dart @@ -194,6 +194,12 @@ void main() { when( () => argResults.wasParsed(CommonArguments.publicKeyArg.name), ).thenReturn(false); + when( + () => argResults.wasParsed(CommonArguments.publicKeyCmd.name), + ).thenReturn(false); + when( + () => argResults.wasParsed(CommonArguments.signCmd.name), + ).thenReturn(false); when(aotTools.isLinkDebugInfoSupported).thenAnswer((_) async => true); diff --git a/packages/shorebird_cli/test/src/commands/patch/patcher_test.dart b/packages/shorebird_cli/test/src/commands/patch/patcher_test.dart index 51f41f58..664da30e 100644 --- a/packages/shorebird_cli/test/src/commands/patch/patcher_test.dart +++ b/packages/shorebird_cli/test/src/commands/patch/patcher_test.dart @@ -2,24 +2,31 @@ import 'dart:io'; import 'package:args/args.dart'; import 'package:mocktail/mocktail.dart'; +import 'package:path/path.dart' as p; import 'package:scoped_deps/scoped_deps.dart'; import 'package:shorebird_cli/src/code_push_client_wrapper.dart'; +import 'package:shorebird_cli/src/code_signer.dart'; import 'package:shorebird_cli/src/commands/commands.dart'; import 'package:shorebird_cli/src/common_arguments.dart'; import 'package:shorebird_cli/src/deployment_track.dart'; +import 'package:shorebird_cli/src/logging/logging.dart'; import 'package:shorebird_cli/src/patch_diff_checker.dart'; import 'package:shorebird_cli/src/platform/platform.dart'; import 'package:shorebird_cli/src/release_type.dart'; +import 'package:shorebird_cli/src/third_party/flutter_tools/lib/flutter_tools.dart'; import 'package:shorebird_code_push_client/shorebird_code_push_client.dart'; import 'package:test/test.dart'; import '../../mocks.dart'; +class FakeFile extends Fake implements File {} + void main() { group(Patcher, () { setUpAll(() { registerFallbackValue(ReleasePlatform.android); registerFallbackValue(DeploymentTrack.stable); + registerFallbackValue(FakeFile()); }); group('linkPercentage', () { @@ -236,6 +243,358 @@ void main() { ).called(1); }); }); + + group('signHash', () { + final cryptoFixturesBasePath = p.join('test', 'fixtures', 'crypto'); + final privateKeyFile = File( + p.join(cryptoFixturesBasePath, 'private.pem'), + ); + + late ArgParser argParser; + late ArgResults argResults; + late CodeSigner codeSigner; + late ShorebirdLogger logger; + late File publicKeyTempFile; + + setUp(() { + argParser = ArgParser() + ..addOption(CommonArguments.publicKeyArg.name) + ..addOption(CommonArguments.privateKeyArg.name) + ..addOption(CommonArguments.publicKeyCmd.name) + ..addOption(CommonArguments.signCmd.name); + codeSigner = MockCodeSigner(); + logger = MockShorebirdLogger(); + publicKeyTempFile = File( + p.join( + Directory.systemTemp.createTempSync().path, + 'public.pem', + ), + )..writeAsStringSync('fake-public-key-pem'); + }); + + test('returns null when no signing is configured', () async { + argResults = argParser.parse([]); + + final patcher = _TestPatcher( + argParser: argParser, + argResults: argResults, + flavor: null, + target: null, + ); + + await runScoped( + () async { + final result = await patcher.signHash('test-hash'); + expect(result, isNull); + }, + values: {codeSignerRef.overrideWith(() => codeSigner)}, + ); + }); + + test('returns signature from file-based signing', () async { + argResults = argParser.parse([ + '--${CommonArguments.publicKeyArg.name}=${publicKeyTempFile.path}', + '--${CommonArguments.privateKeyArg.name}=${privateKeyFile.path}', + ]); + + when( + () => codeSigner.sign( + message: any(named: 'message'), + privateKeyPemFile: any(named: 'privateKeyPemFile'), + ), + ).thenReturn('file-signature'); + when( + () => codeSigner.verify( + message: any(named: 'message'), + signature: any(named: 'signature'), + publicKeyPem: any(named: 'publicKeyPem'), + ), + ).thenReturn(true); + + final patcher = _TestPatcher( + argParser: argParser, + argResults: argResults, + flavor: null, + target: null, + ); + + await runScoped( + () async { + final result = await patcher.signHash('test-hash'); + expect(result, equals('file-signature')); + }, + values: {codeSignerRef.overrideWith(() => codeSigner)}, + ); + }); + + test( + 'throws ProcessExit when signer present but no public key', + () async { + argResults = argParser.parse([ + '--${CommonArguments.privateKeyArg.name}=${privateKeyFile.path}', + ]); + + when( + () => codeSigner.sign( + message: any(named: 'message'), + privateKeyPemFile: any(named: 'privateKeyPemFile'), + ), + ).thenReturn('file-signature'); + + final patcher = _TestPatcher( + argParser: argParser, + argResults: argResults, + flavor: null, + target: null, + ); + + await runScoped( + () async { + await expectLater( + () => patcher.signHash('test-hash'), + throwsA(isA()), + ); + verify( + () => logger.err( + any(that: contains('public key is required')), + ), + ).called(1); + }, + values: { + codeSignerRef.overrideWith(() => codeSigner), + loggerRef.overrideWith(() => logger), + }, + ); + }, + ); + + test('returns signature from command-based signing when valid', () async { + argResults = argParser.parse([ + '--${CommonArguments.publicKeyCmd.name}=get-key-cmd', + '--${CommonArguments.signCmd.name}=sign-cmd', + ]); + + when( + () => codeSigner.signWithCmd( + data: any(named: 'data'), + command: any(named: 'command'), + ), + ).thenAnswer((_) async => 'cmd-signature'); + when( + () => codeSigner.runPublicKeyCmd(any()), + ).thenAnswer((_) async => 'pem-public-key'); + when( + () => codeSigner.verify( + message: any(named: 'message'), + signature: any(named: 'signature'), + publicKeyPem: any(named: 'publicKeyPem'), + ), + ).thenReturn(true); + + final patcher = _TestPatcher( + argParser: argParser, + argResults: argResults, + flavor: null, + target: null, + ); + + await runScoped( + () async { + final result = await patcher.signHash('test-hash'); + expect(result, equals('cmd-signature')); + verify( + () => codeSigner.signWithCmd( + data: 'test-hash', + command: 'sign-cmd', + ), + ).called(1); + verify(() => codeSigner.runPublicKeyCmd('get-key-cmd')).called(1); + verify( + () => codeSigner.verify( + message: 'test-hash', + signature: 'cmd-signature', + publicKeyPem: 'pem-public-key', + ), + ).called(1); + }, + values: {codeSignerRef.overrideWith(() => codeSigner)}, + ); + }); + + test('throws ProcessExit when sign-cmd fails', () async { + argResults = argParser.parse([ + '--${CommonArguments.publicKeyCmd.name}=get-key-cmd', + '--${CommonArguments.signCmd.name}=bad-cmd', + ]); + + when( + () => codeSigner.signWithCmd( + data: any(named: 'data'), + command: any(named: 'command'), + ), + ).thenThrow( + const ProcessException('bad-cmd', [], 'command not found', 127), + ); + + final patcher = _TestPatcher( + argParser: argParser, + argResults: argResults, + flavor: null, + target: null, + ); + + await runScoped( + () async { + await expectLater( + () => patcher.signHash('test-hash'), + throwsA(isA()), + ); + verify( + () => logger.err(any(that: contains('--sign-cmd'))), + ).called(1); + }, + values: { + codeSignerRef.overrideWith(() => codeSigner), + loggerRef.overrideWith(() => logger), + }, + ); + }); + + test('throws ProcessExit when public-key-cmd fails', () async { + argResults = argParser.parse([ + '--${CommonArguments.publicKeyCmd.name}=bad-key-cmd', + '--${CommonArguments.signCmd.name}=sign-cmd', + ]); + + when( + () => codeSigner.signWithCmd( + data: any(named: 'data'), + command: any(named: 'command'), + ), + ).thenAnswer((_) async => 'signature'); + when( + () => codeSigner.runPublicKeyCmd(any()), + ).thenThrow( + const ProcessException( + 'bad-key-cmd', + [], + 'command not found', + 127, + ), + ); + + final patcher = _TestPatcher( + argParser: argParser, + argResults: argResults, + flavor: null, + target: null, + ); + + await runScoped( + () async { + await expectLater( + () => patcher.signHash('test-hash'), + throwsA(isA()), + ); + verify( + () => logger.err(any(that: contains('--public-key-cmd'))), + ).called(1); + }, + values: { + codeSignerRef.overrideWith(() => codeSigner), + loggerRef.overrideWith(() => logger), + }, + ); + }); + + test('supports mixed signing (public key file + sign cmd)', () async { + argResults = argParser.parse([ + '--${CommonArguments.publicKeyArg.name}=${publicKeyTempFile.path}', + '--${CommonArguments.signCmd.name}=sign-cmd', + ]); + + when( + () => codeSigner.signWithCmd( + data: any(named: 'data'), + command: any(named: 'command'), + ), + ).thenAnswer((_) async => 'cmd-signature'); + when( + () => codeSigner.verify( + message: any(named: 'message'), + signature: any(named: 'signature'), + publicKeyPem: any(named: 'publicKeyPem'), + ), + ).thenReturn(true); + + final patcher = _TestPatcher( + argParser: argParser, + argResults: argResults, + flavor: null, + target: null, + ); + + await runScoped( + () async { + final result = await patcher.signHash('test-hash'); + expect(result, equals('cmd-signature')); + verifyNever(() => codeSigner.runPublicKeyCmd(any())); + verify( + () => codeSigner.verify( + message: 'test-hash', + signature: 'cmd-signature', + publicKeyPem: any(named: 'publicKeyPem'), + ), + ).called(1); + }, + values: {codeSignerRef.overrideWith(() => codeSigner)}, + ); + }); + + test('throws ProcessExit when signature verification fails', () async { + argResults = argParser.parse([ + '--${CommonArguments.publicKeyCmd.name}=get-key-cmd', + '--${CommonArguments.signCmd.name}=sign-cmd', + ]); + + when( + () => codeSigner.signWithCmd( + data: any(named: 'data'), + command: any(named: 'command'), + ), + ).thenAnswer((_) async => 'bad-signature'); + when( + () => codeSigner.runPublicKeyCmd(any()), + ).thenAnswer((_) async => 'pem-public-key'); + when( + () => codeSigner.verify( + message: any(named: 'message'), + signature: any(named: 'signature'), + publicKeyPem: any(named: 'publicKeyPem'), + ), + ).thenReturn(false); + + final patcher = _TestPatcher( + argParser: argParser, + argResults: argResults, + flavor: null, + target: null, + ); + + await runScoped( + () async { + await expectLater( + () => patcher.signHash('test-hash'), + throwsA(isA()), + ); + }, + values: { + codeSignerRef.overrideWith(() => codeSigner), + loggerRef.overrideWith(() => logger), + }, + ); + }); + }); }); } diff --git a/packages/shorebird_cli/test/src/commands/patch/windows_patcher_test.dart b/packages/shorebird_cli/test/src/commands/patch/windows_patcher_test.dart index 39b5e697..c6496967 100644 --- a/packages/shorebird_cli/test/src/commands/patch/windows_patcher_test.dart +++ b/packages/shorebird_cli/test/src/commands/patch/windows_patcher_test.dart @@ -467,6 +467,12 @@ void main() { group('when signing keys are provided', () { setUp(() { + final publicKeyFile = File( + p.join( + Directory.systemTemp.createTempSync().path, + 'public-key.pem', + ), + )..createSync(); when( () => artifactManager.createDiff( releaseArtifactPath: any(named: 'releaseArtifactPath'), @@ -475,7 +481,7 @@ void main() { ).thenAnswer((_) async => diffFile.path); when( () => argResults[CommonArguments.publicKeyArg.name], - ).thenReturn('public-key.pem'); + ).thenReturn(publicKeyFile.path); when( () => argResults[CommonArguments.privateKeyArg.name], ).thenReturn('private-key.pem'); @@ -485,6 +491,13 @@ void main() { privateKeyPemFile: any(named: 'privateKeyPemFile'), ), ).thenReturn('signature'); + when( + () => codeSigner.verify( + message: any(named: 'message'), + signature: any(named: 'signature'), + publicKeyPem: any(named: 'publicKeyPem'), + ), + ).thenReturn(true); }); test('signs patch', () async { diff --git a/packages/shorebird_cli/test/src/commands/release/aar_releaser_test.dart b/packages/shorebird_cli/test/src/commands/release/aar_releaser_test.dart index d4984f22..01186da6 100644 --- a/packages/shorebird_cli/test/src/commands/release/aar_releaser_test.dart +++ b/packages/shorebird_cli/test/src/commands/release/aar_releaser_test.dart @@ -326,6 +326,9 @@ void main() { when( () => argResults[CommonArguments.publicKeyArg.name], ).thenReturn(patchSigningPublicKeyFile.path); + when( + () => argResults.wasParsed(CommonArguments.publicKeyArg.name), + ).thenReturn(true); when( () => artifactBuilder.buildAar( @@ -337,7 +340,7 @@ void main() { ).thenAnswer((_) async => File('')); when( - () => codeSigner.base64PublicKey(any()), + () => codeSigner.base64PublicKeyFromPem(any()), ).thenReturn(base64PublicKey); }); @@ -357,6 +360,57 @@ void main() { }, ); }); + + group('when a public-key-cmd is provided', () { + const base64PublicKey = 'base64PublicKeyFromCmd'; + + setUp(() { + when( + () => argResults[CommonArguments.publicKeyCmd.name], + ).thenReturn('get-key-cmd'); + when( + () => argResults.wasParsed(CommonArguments.publicKeyCmd.name), + ).thenReturn(true); + + when( + () => artifactBuilder.buildAar( + buildNumber: any(named: 'buildNumber'), + targetPlatforms: any(named: 'targetPlatforms'), + args: any(named: 'args'), + base64PublicKey: any(named: 'base64PublicKey'), + ), + ).thenAnswer((_) async => File('')); + + when( + () => codeSigner.runPublicKeyCmd(any()), + ).thenAnswer((_) async => 'pem-public-key'); + when( + () => codeSigner.base64PublicKeyFromPem(any()), + ).thenReturn(base64PublicKey); + }); + + test( + 'runs public key cmd and forwards encoded key to buildAar', + () async { + await runWithOverrides(() => aarReleaser.buildReleaseArtifacts()); + + verify( + () => codeSigner.runPublicKeyCmd('get-key-cmd'), + ).called(1); + verify( + () => codeSigner.base64PublicKeyFromPem('pem-public-key'), + ).called(1); + verify( + () => artifactBuilder.buildAar( + buildNumber: buildNumber, + targetPlatforms: any(named: 'targetPlatforms'), + args: any(named: 'args'), + base64PublicKey: base64PublicKey, + ), + ).called(1); + }, + ); + }); }); }); diff --git a/packages/shorebird_cli/test/src/commands/release/android_releaser_test.dart b/packages/shorebird_cli/test/src/commands/release/android_releaser_test.dart index 35fc8a64..262922e0 100644 --- a/packages/shorebird_cli/test/src/commands/release/android_releaser_test.dart +++ b/packages/shorebird_cli/test/src/commands/release/android_releaser_test.dart @@ -417,6 +417,9 @@ To change the version of this release, change your app's version in your pubspec when( () => argResults[CommonArguments.publicKeyArg.name], ).thenReturn(patchSigningPublicKeyFile.path); + when( + () => argResults.wasParsed(CommonArguments.publicKeyArg.name), + ).thenReturn(true); when( () => artifactBuilder.buildAppBundle( @@ -438,7 +441,7 @@ To change the version of this release, change your app's version in your pubspec ).thenAnswer((_) async => File('')); when( - () => codeSigner.base64PublicKey(any()), + () => codeSigner.base64PublicKeyFromPem(any()), ).thenReturn(base64PublicKey); }); @@ -496,6 +499,61 @@ To change the version of this release, change your app's version in your pubspec ); }); }); + + group('when a public-key-cmd is provided', () { + const base64PublicKey = 'base64PublicKeyFromCmd'; + + setUp(() { + when( + () => argResults[CommonArguments.publicKeyCmd.name], + ).thenReturn('get-key-cmd'); + when( + () => argResults.wasParsed(CommonArguments.publicKeyCmd.name), + ).thenReturn(true); + + when( + () => artifactBuilder.buildAppBundle( + flavor: any(named: 'flavor'), + target: any(named: 'target'), + targetPlatforms: any(named: 'targetPlatforms'), + args: any(named: 'args'), + base64PublicKey: any(named: 'base64PublicKey'), + ), + ).thenAnswer((_) async => aabFile); + + when( + () => codeSigner.runPublicKeyCmd(any()), + ).thenAnswer((_) async => 'pem-public-key'); + when( + () => codeSigner.base64PublicKeyFromPem(any()), + ).thenReturn(base64PublicKey); + }); + + test( + 'runs public key cmd and forwards encoded key to buildAab', + () async { + await runWithOverrides( + () => androidReleaser.buildReleaseArtifacts(), + ); + + verify( + () => codeSigner.runPublicKeyCmd('get-key-cmd'), + ).called(1); + verify( + () => codeSigner.base64PublicKeyFromPem('pem-public-key'), + ).called(1); + verify( + () => artifactBuilder.buildAppBundle( + flavor: any(named: 'flavor'), + target: any(named: 'target'), + targetPlatforms: any(named: 'targetPlatforms'), + args: any(named: 'args'), + base64PublicKey: base64PublicKey, + ), + ).called(1); + }, + ); + }); }); group('getReleaseVersion', () { diff --git a/packages/shorebird_cli/test/src/commands/release/ios_framework_releaser_test.dart b/packages/shorebird_cli/test/src/commands/release/ios_framework_releaser_test.dart index 081da84a..a84682d6 100644 --- a/packages/shorebird_cli/test/src/commands/release/ios_framework_releaser_test.dart +++ b/packages/shorebird_cli/test/src/commands/release/ios_framework_releaser_test.dart @@ -293,6 +293,9 @@ void main() { when( () => argResults[CommonArguments.publicKeyArg.name], ).thenReturn(patchSigningPublicKeyFile.path); + when( + () => argResults.wasParsed(CommonArguments.publicKeyArg.name), + ).thenReturn(true); when( () => artifactBuilder.buildIosFramework( @@ -304,7 +307,7 @@ void main() { AppleBuildResult(kernelFile: File('/path/to/app.dill')), ); when( - () => codeSigner.base64PublicKey(any()), + () => codeSigner.base64PublicKeyFromPem(any()), ).thenReturn(base64PublicKey); }); @@ -326,6 +329,59 @@ void main() { ); }); + group('when a public-key-cmd is provided', () { + const base64PublicKey = 'base64PublicKeyFromCmd'; + + setUp(() { + when( + () => argResults[CommonArguments.publicKeyCmd.name], + ).thenReturn('get-key-cmd'); + when( + () => argResults.wasParsed(CommonArguments.publicKeyCmd.name), + ).thenReturn(true); + + when( + () => artifactBuilder.buildIosFramework( + args: any(named: 'args'), + base64PublicKey: any(named: 'base64PublicKey'), + ), + ).thenAnswer( + (_) async => + AppleBuildResult(kernelFile: File('/path/to/app.dill')), + ); + + when( + () => codeSigner.runPublicKeyCmd(any()), + ).thenAnswer((_) async => 'pem-public-key'); + when( + () => codeSigner.base64PublicKeyFromPem(any()), + ).thenReturn(base64PublicKey); + }); + + test( + 'runs public key cmd and forwards encoded key to ' + 'buildIosFramework', + () async { + await runWithOverrides( + () => iosFrameworkReleaser.buildReleaseArtifacts(), + ); + + verify( + () => codeSigner.runPublicKeyCmd('get-key-cmd'), + ).called(1); + verify( + () => codeSigner.base64PublicKeyFromPem('pem-public-key'), + ).called(1); + verify( + () => artifactBuilder.buildIosFramework( + args: any(named: 'args'), + base64PublicKey: base64PublicKey, + ), + ).called(1); + }, + ); + }); + group('when stale build/ios/shorebird directory exists', () { late Directory shorebirdSupplementDir; diff --git a/packages/shorebird_cli/test/src/commands/release/ios_releaser_test.dart b/packages/shorebird_cli/test/src/commands/release/ios_releaser_test.dart index 3f1b5d7c..ddc31ac0 100644 --- a/packages/shorebird_cli/test/src/commands/release/ios_releaser_test.dart +++ b/packages/shorebird_cli/test/src/commands/release/ios_releaser_test.dart @@ -310,7 +310,7 @@ To change the version of this release, change your app's version in your pubspec ).thenReturn(xcarchiveDirectory); when( - () => codeSigner.base64PublicKey(any()), + () => codeSigner.base64PublicKeyFromPem(any()), ).thenReturn(base64PublicKey); when( @@ -332,6 +332,9 @@ To change the version of this release, change your app's version in your pubspec when( () => argResults[CommonArguments.publicKeyArg.name], ).thenReturn(patchSigningPublicKeyFile.path); + when( + () => argResults.wasParsed(CommonArguments.publicKeyArg.name), + ).thenReturn(true); when( () => artifactBuilder.buildIpa( @@ -365,6 +368,60 @@ To change the version of this release, change your app's version in your pubspec ); }); + group('when a public-key-cmd is provided', () { + setUp(() { + when( + () => argResults[CommonArguments.publicKeyCmd.name], + ).thenReturn('get-key-cmd'); + when( + () => argResults.wasParsed(CommonArguments.publicKeyCmd.name), + ).thenReturn(true); + + when( + () => artifactBuilder.buildIpa( + codesign: any(named: 'codesign'), + flavor: any(named: 'flavor'), + target: any(named: 'target'), + args: any(named: 'args'), + base64PublicKey: any(named: 'base64PublicKey'), + ), + ).thenAnswer( + (_) async => + AppleBuildResult(kernelFile: File('/path/to/app.dill')), + ); + + when( + () => codeSigner.runPublicKeyCmd(any()), + ).thenAnswer((_) async => 'pem-public-key'); + when( + () => codeSigner.base64PublicKeyFromPem(any()), + ).thenReturn('base64PublicKeyFromCmd'); + }); + + test( + 'runs public key cmd and forwards encoded key to buildIpa', + () async { + await runWithOverrides(() => iosReleaser.buildReleaseArtifacts()); + + verify( + () => codeSigner.runPublicKeyCmd('get-key-cmd'), + ).called(1); + verify( + () => codeSigner.base64PublicKeyFromPem('pem-public-key'), + ).called(1); + verify( + () => artifactBuilder.buildIpa( + codesign: any(named: 'codesign'), + flavor: any(named: 'flavor'), + target: any(named: 'target'), + args: any(named: 'args'), + base64PublicKey: 'base64PublicKeyFromCmd', + ), + ).called(1); + }, + ); + }); + group('when not codesigning', () { setUp(() { when(() => argResults['codesign']).thenReturn(false); diff --git a/packages/shorebird_cli/test/src/commands/release/linux_releaser_test.dart b/packages/shorebird_cli/test/src/commands/release/linux_releaser_test.dart index a3211bf7..a11f9bc2 100644 --- a/packages/shorebird_cli/test/src/commands/release/linux_releaser_test.dart +++ b/packages/shorebird_cli/test/src/commands/release/linux_releaser_test.dart @@ -296,14 +296,20 @@ To change the version of this release, change your app's version in your pubspec group('when public key is passed as an arg', () { setUp(() { + final publicKeyFile = File( + p.join( + Directory.systemTemp.createTempSync().path, + 'public-key.pem', + ), + )..createSync(recursive: true); when( () => argResults.wasParsed(CommonArguments.publicKeyArg.name), ).thenReturn(true); when( () => argResults[CommonArguments.publicKeyArg.name], - ).thenReturn('public_key'); + ).thenReturn(publicKeyFile.path); when( - () => codeSigner.base64PublicKey(any()), + () => codeSigner.base64PublicKeyFromPem(any()), ).thenReturn('encoded_public_key'); }); @@ -318,6 +324,38 @@ To change the version of this release, change your app's version in your pubspec ).called(1); }); }); + + group('when a public-key-cmd is provided', () { + setUp(() { + when( + () => argResults[CommonArguments.publicKeyCmd.name], + ).thenReturn('get-key-cmd'); + when( + () => argResults.wasParsed(CommonArguments.publicKeyCmd.name), + ).thenReturn(true); + + when( + () => codeSigner.runPublicKeyCmd(any()), + ).thenAnswer((_) async => 'pem-public-key'); + when( + () => codeSigner.base64PublicKeyFromPem(any()), + ).thenReturn('encoded_public_key_from_cmd'); + }); + + test('passes public key to buildLinuxApp', () async { + await runWithOverrides(releaser.buildReleaseArtifacts); + verify( + () => codeSigner.runPublicKeyCmd('get-key-cmd'), + ).called(1); + verify( + () => artifactBuilder.buildLinuxApp( + base64PublicKey: 'encoded_public_key_from_cmd', + target: any(named: 'target'), + args: any(named: 'args'), + ), + ).called(1); + }); + }); }); group('getReleaseVersion', () { diff --git a/packages/shorebird_cli/test/src/commands/release/macos_releaser_test.dart b/packages/shorebird_cli/test/src/commands/release/macos_releaser_test.dart index 6baa8065..51d3c9f5 100644 --- a/packages/shorebird_cli/test/src/commands/release/macos_releaser_test.dart +++ b/packages/shorebird_cli/test/src/commands/release/macos_releaser_test.dart @@ -11,7 +11,9 @@ import 'package:scoped_deps/scoped_deps.dart'; import 'package:shorebird_cli/src/artifact_builder/artifact_builder.dart'; import 'package:shorebird_cli/src/artifact_manager.dart'; import 'package:shorebird_cli/src/code_push_client_wrapper.dart'; +import 'package:shorebird_cli/src/code_signer.dart'; import 'package:shorebird_cli/src/commands/release/release.dart'; +import 'package:shorebird_cli/src/common_arguments.dart'; import 'package:shorebird_cli/src/config/config.dart'; import 'package:shorebird_cli/src/doctor.dart'; import 'package:shorebird_cli/src/executables/xcodebuild.dart'; @@ -35,6 +37,7 @@ void main() { late ArtifactBuilder artifactBuilder; late ArtifactManager artifactManager; late CodePushClientWrapper codePushClientWrapper; + late CodeSigner codeSigner; late Directory projectRoot; late Doctor doctor; late FlavorValidator flavorValidator; @@ -54,6 +57,7 @@ void main() { artifactBuilderRef.overrideWith(() => artifactBuilder), artifactManagerRef.overrideWith(() => artifactManager), codePushClientWrapperRef.overrideWith(() => codePushClientWrapper), + codeSignerRef.overrideWith(() => codeSigner), doctorRef.overrideWith(() => doctor), loggerRef.overrideWith(() => logger), shorebirdEnvRef.overrideWith(() => shorebirdEnv), @@ -69,6 +73,7 @@ void main() { artifactBuilder = MockArtifactBuilder(); artifactManager = MockArtifactManager(); codePushClientWrapper = MockCodePushClientWrapper(); + codeSigner = MockCodeSigner(); doctor = MockDoctor(); flavorValidator = MockFlavorValidator(); projectRoot = Directory.systemTemp.createTempSync(); @@ -269,6 +274,7 @@ To change the version of this release, change your app's version in your pubspec flavor: any(named: 'flavor'), target: any(named: 'target'), args: any(named: 'args'), + base64PublicKey: any(named: 'base64PublicKey'), ), ).thenAnswer( (_) async => AppleBuildResult(kernelFile: File('/path/to/app.dill')), @@ -356,6 +362,41 @@ To change the version of this release, change your app's version in your pubspec }); }); + group('when a public-key-cmd is provided', () { + setUp(() { + when( + () => argResults[CommonArguments.publicKeyCmd.name], + ).thenReturn('get-key-cmd'); + when( + () => argResults.wasParsed(CommonArguments.publicKeyCmd.name), + ).thenReturn(true); + + when( + () => codeSigner.runPublicKeyCmd(any()), + ).thenAnswer((_) async => 'pem-public-key'); + when( + () => codeSigner.base64PublicKeyFromPem(any()), + ).thenReturn('base64PublicKeyFromCmd'); + }); + + test('runs public key cmd and forwards encoded key', () async { + await runWithOverrides(releaser.buildReleaseArtifacts); + + verify( + () => codeSigner.runPublicKeyCmd('get-key-cmd'), + ).called(1); + verify( + () => codeSigner.base64PublicKeyFromPem('pem-public-key'), + ).called(1); + verify( + () => artifactBuilder.buildMacos( + base64PublicKey: 'base64PublicKeyFromCmd', + args: any(named: 'args'), + ), + ).called(1); + }); + }); + test('verifies artifacts exist and returns app path', () async { expect( await runWithOverrides(releaser.buildReleaseArtifacts), diff --git a/packages/shorebird_cli/test/src/commands/release/release_command_test.dart b/packages/shorebird_cli/test/src/commands/release/release_command_test.dart index 8f7980dc..10f16789 100644 --- a/packages/shorebird_cli/test/src/commands/release/release_command_test.dart +++ b/packages/shorebird_cli/test/src/commands/release/release_command_test.dart @@ -107,6 +107,9 @@ void main() { when(() => argResults['platforms']).thenReturn(['android']); when(() => argResults['flutter-version']).thenReturn('latest'); when(() => argResults.wasParsed(any())).thenReturn(true); + when( + () => argResults.wasParsed(CommonArguments.publicKeyCmd.name), + ).thenReturn(false); when(cache.updateAll).thenAnswer((_) async => {}); @@ -699,7 +702,9 @@ $exception'''), verify( () => logger.warn( 'patch_verification is set in shorebird.yaml but ' - '--${CommonArguments.publicKeyArg.name} was not provided.\n' + 'no public key was provided ' + '(--${CommonArguments.publicKeyArg.name} ' + 'or --${CommonArguments.publicKeyCmd.name}).\n' 'patch_verification configuration will have no effect.', ), ).called(1); diff --git a/packages/shorebird_cli/test/src/commands/release/windows_releaser_test.dart b/packages/shorebird_cli/test/src/commands/release/windows_releaser_test.dart index db33dc98..e594b795 100644 --- a/packages/shorebird_cli/test/src/commands/release/windows_releaser_test.dart +++ b/packages/shorebird_cli/test/src/commands/release/windows_releaser_test.dart @@ -297,6 +297,12 @@ To change the version of this release, change your app's version in your pubspec group('when public key is passed as an arg', () { setUp(() { + final publicKeyFile = File( + p.join( + Directory.systemTemp.createTempSync().path, + 'public-key.pem', + ), + )..createSync(recursive: true); when( () => artifactBuilder.buildWindowsApp( target: any(named: 'target'), @@ -309,9 +315,9 @@ To change the version of this release, change your app's version in your pubspec ).thenReturn(true); when( () => argResults[CommonArguments.publicKeyArg.name], - ).thenReturn('public_key'); + ).thenReturn(publicKeyFile.path); when( - () => codeSigner.base64PublicKey(any()), + () => codeSigner.base64PublicKeyFromPem(any()), ).thenReturn('encoded_public_key'); }); @@ -326,6 +332,45 @@ To change the version of this release, change your app's version in your pubspec ).called(1); }); }); + + group('when a public-key-cmd is provided', () { + setUp(() { + when( + () => artifactBuilder.buildWindowsApp( + target: any(named: 'target'), + args: any(named: 'args'), + base64PublicKey: any(named: 'base64PublicKey'), + ), + ).thenAnswer((_) async => projectRoot); + when( + () => argResults[CommonArguments.publicKeyCmd.name], + ).thenReturn('get-key-cmd'); + when( + () => argResults.wasParsed(CommonArguments.publicKeyCmd.name), + ).thenReturn(true); + + when( + () => codeSigner.runPublicKeyCmd(any()), + ).thenAnswer((_) async => 'pem-public-key'); + when( + () => codeSigner.base64PublicKeyFromPem(any()), + ).thenReturn('encoded_public_key_from_cmd'); + }); + + test('passes public key to buildWindowsApp', () async { + await runWithOverrides(releaser.buildReleaseArtifacts); + verify( + () => codeSigner.runPublicKeyCmd('get-key-cmd'), + ).called(1); + verify( + () => artifactBuilder.buildWindowsApp( + base64PublicKey: 'encoded_public_key_from_cmd', + target: any(named: 'target'), + args: any(named: 'args'), + ), + ).called(1); + }); + }); }); group('getReleaseVersion', () { diff --git a/packages/shorebird_cli/test/src/extensions/arg_results_test.dart b/packages/shorebird_cli/test/src/extensions/arg_results_test.dart index 8f47d330..a3027ca2 100644 --- a/packages/shorebird_cli/test/src/extensions/arg_results_test.dart +++ b/packages/shorebird_cli/test/src/extensions/arg_results_test.dart @@ -1,11 +1,29 @@ // cspell:ignore qwer +import 'dart:io'; + import 'package:args/args.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:path/path.dart' as p; +import 'package:scoped_deps/scoped_deps.dart'; +import 'package:shorebird_cli/src/code_signer.dart'; import 'package:shorebird_cli/src/common_arguments.dart'; import 'package:shorebird_cli/src/extensions/arg_results.dart'; +import 'package:shorebird_cli/src/logging/logging.dart'; import 'package:shorebird_cli/src/release_type.dart'; +import 'package:shorebird_cli/src/third_party/flutter_tools/lib/flutter_tools.dart'; import 'package:test/test.dart'; +class MockCodeSigner extends Mock implements CodeSigner {} + +class MockShorebirdLogger extends Mock implements ShorebirdLogger {} + +class FakeFile extends Fake implements File {} + void main() { + setUpAll(() { + registerFallbackValue(FakeFile()); + }); + group('OptionFinder', () { late ArgParser argParser; @@ -300,4 +318,225 @@ void main() { }); }); }); + + group('CodeSign', () { + final cryptoFixturesBasePath = p.join('test', 'fixtures', 'crypto'); + final publicKeyFile = File(p.join(cryptoFixturesBasePath, 'public.pem')); + + late ArgParser parser; + late ShorebirdLogger logger; + + setUp(() { + logger = MockShorebirdLogger(); + parser = ArgParser() + ..addOption(CommonArguments.publicKeyArg.name) + ..addOption(CommonArguments.privateKeyArg.name) + ..addOption(CommonArguments.publicKeyCmd.name) + ..addOption(CommonArguments.signCmd.name); + }); + + group('assertAbsentOrValidKeyPairOrCommands', () { + test('succeeds when no signing arguments provided', () { + final args = []; + final result = parser.parse(args); + expect(result.assertAbsentOrValidKeyPairOrCommands, returnsNormally); + }); + + test('throws when both public key sources provided', () { + final args = [ + '--${CommonArguments.publicKeyArg.name}=${publicKeyFile.path}', + '--${CommonArguments.publicKeyCmd.name}=get-key-cmd', + '--${CommonArguments.signCmd.name}=sign-cmd', + ]; + final result = parser.parse(args); + + runScoped( + () { + expect( + result.assertAbsentOrValidKeyPairOrCommands, + throwsA(isA()), + ); + }, + values: {loggerRef.overrideWith(() => logger)}, + ); + }); + + test('throws when both signing methods provided', () { + final privateKeyFile = File( + p.join(cryptoFixturesBasePath, 'private.pem'), + ); + final args = [ + '--${CommonArguments.publicKeyArg.name}=${publicKeyFile.path}', + '--${CommonArguments.privateKeyArg.name}=${privateKeyFile.path}', + '--${CommonArguments.signCmd.name}=sign-cmd', + ]; + final result = parser.parse(args); + + runScoped( + () { + expect( + result.assertAbsentOrValidKeyPairOrCommands, + throwsA(isA()), + ); + }, + values: {loggerRef.overrideWith(() => logger)}, + ); + }); + + test('throws when sign-cmd provided without any public key', () { + final args = ['--${CommonArguments.signCmd.name}=sign-cmd']; + final result = parser.parse(args); + + runScoped( + () { + expect( + result.assertAbsentOrValidKeyPairOrCommands, + throwsA(isA()), + ); + }, + values: {loggerRef.overrideWith(() => logger)}, + ); + }); + + test('succeeds when both cmd arguments provided', () { + final args = [ + '--${CommonArguments.publicKeyCmd.name}=get-key-cmd', + '--${CommonArguments.signCmd.name}=sign-cmd', + ]; + final result = parser.parse(args); + expect(result.assertAbsentOrValidKeyPairOrCommands, returnsNormally); + }); + + test('succeeds with public-key-path + sign-cmd (mixed)', () { + final args = [ + '--${CommonArguments.publicKeyArg.name}=${publicKeyFile.path}', + '--${CommonArguments.signCmd.name}=sign-cmd', + ]; + final result = parser.parse(args); + expect(result.assertAbsentOrValidKeyPairOrCommands, returnsNormally); + }); + + test('succeeds when both file arguments provided with valid files', () { + final privateKeyFile = File( + p.join(cryptoFixturesBasePath, 'private.pem'), + ); + final args = [ + '--${CommonArguments.publicKeyArg.name}=${publicKeyFile.path}', + '--${CommonArguments.privateKeyArg.name}=${privateKeyFile.path}', + ]; + final result = parser.parse(args); + expect(result.assertAbsentOrValidKeyPairOrCommands, returnsNormally); + }); + }); + + group('assertAbsentOrValidPublicKeyOrCmd', () { + test('succeeds when no public key arguments provided', () { + final args = []; + final result = parser.parse(args); + expect(result.assertAbsentOrValidPublicKeyOrCmd, returnsNormally); + }); + + test('succeeds when only public-key-path provided', () { + final args = [ + '--${CommonArguments.publicKeyArg.name}=${publicKeyFile.path}', + ]; + final result = parser.parse(args); + expect(result.assertAbsentOrValidPublicKeyOrCmd, returnsNormally); + }); + + test('succeeds when only public-key-cmd provided', () { + final args = ['--${CommonArguments.publicKeyCmd.name}=get-key-cmd']; + final result = parser.parse(args); + expect(result.assertAbsentOrValidPublicKeyOrCmd, returnsNormally); + }); + + test('throws when both public-key-path and public-key-cmd provided', () { + final args = [ + '--${CommonArguments.publicKeyArg.name}=${publicKeyFile.path}', + '--${CommonArguments.publicKeyCmd.name}=get-key-cmd', + ]; + final result = parser.parse(args); + + runScoped( + () { + expect( + result.assertAbsentOrValidPublicKeyOrCmd, + throwsA(isA()), + ); + }, + values: {loggerRef.overrideWith(() => logger)}, + ); + }); + }); + + group('getEncodedPublicKey', () { + late CodeSigner codeSigner; + + setUp(() { + codeSigner = MockCodeSigner(); + }); + + test('returns null when no public key configured', () async { + final args = []; + final result = parser.parse(args); + + await runScoped( + () async { + final encoded = await result.getEncodedPublicKey(); + expect(encoded, isNull); + }, + values: {codeSignerRef.overrideWith(() => codeSigner)}, + ); + }); + + test( + 'returns encoded key from file when public-key-path provided', + () async { + final args = [ + '--${CommonArguments.publicKeyArg.name}=${publicKeyFile.path}', + ]; + final result = parser.parse(args); + + when( + () => codeSigner.base64PublicKeyFromPem(any()), + ).thenReturn('encoded-key'); + + await runScoped( + () async { + final encoded = await result.getEncodedPublicKey(); + expect(encoded, equals('encoded-key')); + }, + values: {codeSignerRef.overrideWith(() => codeSigner)}, + ); + }, + ); + + test( + 'returns encoded key from cmd when public-key-cmd provided', + () async { + final args = ['--${CommonArguments.publicKeyCmd.name}=get-key-cmd']; + final result = parser.parse(args); + + when( + () => codeSigner.runPublicKeyCmd(any()), + ).thenAnswer((_) async => 'pem-key'); + when( + () => codeSigner.base64PublicKeyFromPem(any()), + ).thenReturn('encoded-key'); + + await runScoped( + () async { + final encoded = await result.getEncodedPublicKey(); + expect(encoded, equals('encoded-key')); + verify(() => codeSigner.runPublicKeyCmd('get-key-cmd')).called(1); + verify( + () => codeSigner.base64PublicKeyFromPem('pem-key'), + ).called(1); + }, + values: {codeSignerRef.overrideWith(() => codeSigner)}, + ); + }, + ); + }); + }); }