diff options
Diffstat (limited to 'platform')
253 files changed, 34865 insertions, 1438 deletions
diff --git a/platform/android/SCsub b/platform/android/SCsub index bc1b5e9200..8c88b419b3 100644 --- a/platform/android/SCsub +++ b/platform/android/SCsub @@ -95,25 +95,18 @@ if lib_arch_dir != "": else: gradle_process = ["./gradlew"] - if env["target"] != "editor" and env["dev_build"]: - subprocess.run( - gradle_process - + [ - "generateDevTemplate", - "--quiet", - ], - cwd="platform/android/java", - ) - else: - # Android editor with `dev_build=yes` is handled by the `generateGodotEditor` task. - subprocess.run( - gradle_process - + [ - "generateGodotEditor" if env["target"] == "editor" else "generateGodotTemplates", - "--quiet", - ], - cwd="platform/android/java", - ) + gradle_process += [ + "generateGodotEditor" if env["target"] == "editor" else "generateGodotTemplates", + "--quiet", + ] + + if env["debug_symbols"]: + gradle_process += ["-PdoNotStrip=true"] + + subprocess.run( + gradle_process, + cwd="platform/android/java", + ) if env["generate_apk"]: generate_apk_command = env_android.Command("generate_apk", [], generate_apk) diff --git a/platform/android/audio_driver_opensl.cpp b/platform/android/audio_driver_opensl.cpp index 51e89c720d..ef9c51db07 100644 --- a/platform/android/audio_driver_opensl.cpp +++ b/platform/android/audio_driver_opensl.cpp @@ -268,6 +268,10 @@ Error AudioDriverOpenSL::init_input_device() { } Error AudioDriverOpenSL::input_start() { + if (recordItf || recordBufferQueueItf) { + return ERR_ALREADY_IN_USE; + } + if (OS::get_singleton()->request_permission("RECORD_AUDIO")) { return init_input_device(); } @@ -277,6 +281,10 @@ Error AudioDriverOpenSL::input_start() { } Error AudioDriverOpenSL::input_stop() { + if (!recordItf || !recordBufferQueueItf) { + return ERR_CANT_OPEN; + } + SLuint32 state; SLresult res = (*recordItf)->GetRecordState(recordItf, &state); ERR_FAIL_COND_V(res != SL_RESULT_SUCCESS, ERR_CANT_OPEN); @@ -313,13 +321,36 @@ void AudioDriverOpenSL::unlock() { } void AudioDriverOpenSL::finish() { - (*sl)->Destroy(sl); + if (recordItf) { + (*recordItf)->SetRecordState(recordItf, SL_RECORDSTATE_STOPPED); + recordItf = nullptr; + } + if (recorder) { + (*recorder)->Destroy(recorder); + recorder = nullptr; + } + if (playItf) { + (*playItf)->SetPlayState(playItf, SL_PLAYSTATE_STOPPED); + playItf = nullptr; + } + if (player) { + (*player)->Destroy(player); + player = nullptr; + } + if (OutputMix) { + (*OutputMix)->Destroy(OutputMix); + OutputMix = nullptr; + } + if (sl) { + (*sl)->Destroy(sl); + sl = nullptr; + } } void AudioDriverOpenSL::set_pause(bool p_pause) { pause = p_pause; - if (active) { + if (active && playItf) { if (pause) { (*playItf)->SetPlayState(playItf, SL_PLAYSTATE_PAUSED); } else { diff --git a/platform/android/audio_driver_opensl.h b/platform/android/audio_driver_opensl.h index 6ea0f77def..bcd173826a 100644 --- a/platform/android/audio_driver_opensl.h +++ b/platform/android/audio_driver_opensl.h @@ -54,15 +54,15 @@ class AudioDriverOpenSL : public AudioDriver { Vector<int16_t> rec_buffer; - SLPlayItf playItf; - SLRecordItf recordItf; - SLObjectItf sl; - SLEngineItf EngineItf; - SLObjectItf OutputMix; - SLObjectItf player; - SLObjectItf recorder; - SLAndroidSimpleBufferQueueItf bufferQueueItf; - SLAndroidSimpleBufferQueueItf recordBufferQueueItf; + SLPlayItf playItf = nullptr; + SLRecordItf recordItf = nullptr; + SLObjectItf sl = nullptr; + SLEngineItf EngineItf = nullptr; + SLObjectItf OutputMix = nullptr; + SLObjectItf player = nullptr; + SLObjectItf recorder = nullptr; + SLAndroidSimpleBufferQueueItf bufferQueueItf = nullptr; + SLAndroidSimpleBufferQueueItf recordBufferQueueItf = nullptr; SLDataSource audioSource; SLDataFormat_PCM pcm; SLDataSink audioSink; diff --git a/platform/android/detect.py b/platform/android/detect.py index 0b182aca90..0a10754e24 100644 --- a/platform/android/detect.py +++ b/platform/android/detect.py @@ -190,6 +190,8 @@ def configure(env: "SConsEnvironment"): env.Append(CCFLAGS=["-mfix-cortex-a53-835769"]) env.Append(CPPDEFINES=["__ARM_ARCH_8A__"]) + env.Append(CCFLAGS=["-ffp-contract=off"]) + # Link flags env.Append(LINKFLAGS="-Wl,--gc-sections -Wl,--no-undefined -Wl,-z,now".split()) diff --git a/platform/android/dir_access_jandroid.cpp b/platform/android/dir_access_jandroid.cpp index ab90527bfa..19c18eb96e 100644 --- a/platform/android/dir_access_jandroid.cpp +++ b/platform/android/dir_access_jandroid.cpp @@ -68,7 +68,7 @@ String DirAccessJAndroid::get_next() { if (_dir_next) { JNIEnv *env = get_jni_env(); ERR_FAIL_NULL_V(env, ""); - jstring str = (jstring)env->CallObjectMethod(dir_access_handler, _dir_next, get_access_type(), id); + jstring str = (jstring)env->CallObjectMethod(dir_access_handler, _dir_next, id); if (!str) { return ""; } @@ -85,7 +85,7 @@ bool DirAccessJAndroid::current_is_dir() const { if (_dir_is_dir) { JNIEnv *env = get_jni_env(); ERR_FAIL_NULL_V(env, false); - return env->CallBooleanMethod(dir_access_handler, _dir_is_dir, get_access_type(), id); + return env->CallBooleanMethod(dir_access_handler, _dir_is_dir, id); } else { return false; } @@ -95,7 +95,7 @@ bool DirAccessJAndroid::current_is_hidden() const { if (_current_is_hidden) { JNIEnv *env = get_jni_env(); ERR_FAIL_NULL_V(env, false); - return env->CallBooleanMethod(dir_access_handler, _current_is_hidden, get_access_type(), id); + return env->CallBooleanMethod(dir_access_handler, _current_is_hidden, id); } return false; } @@ -218,7 +218,7 @@ bool DirAccessJAndroid::dir_exists(String p_dir) { } } -Error DirAccessJAndroid::make_dir_recursive(const String &p_dir) { +Error DirAccessJAndroid::make_dir(String p_dir) { // Check if the directory exists already if (dir_exists(p_dir)) { return ERR_ALREADY_EXISTS; @@ -242,8 +242,12 @@ Error DirAccessJAndroid::make_dir_recursive(const String &p_dir) { } } -Error DirAccessJAndroid::make_dir(String p_dir) { - return make_dir_recursive(p_dir); +Error DirAccessJAndroid::make_dir_recursive(const String &p_dir) { + Error err = make_dir(p_dir); + if (err != OK && err != ERR_ALREADY_EXISTS) { + ERR_FAIL_V_MSG(err, "Could not create directory: " + p_dir); + } + return OK; } Error DirAccessJAndroid::rename(String p_from, String p_to) { @@ -307,9 +311,9 @@ void DirAccessJAndroid::setup(jobject p_dir_access_handler) { cls = (jclass)env->NewGlobalRef(c); _dir_open = env->GetMethodID(cls, "dirOpen", "(ILjava/lang/String;)I"); - _dir_next = env->GetMethodID(cls, "dirNext", "(II)Ljava/lang/String;"); - _dir_close = env->GetMethodID(cls, "dirClose", "(II)V"); - _dir_is_dir = env->GetMethodID(cls, "dirIsDir", "(II)Z"); + _dir_next = env->GetMethodID(cls, "dirNext", "(I)Ljava/lang/String;"); + _dir_close = env->GetMethodID(cls, "dirClose", "(I)V"); + _dir_is_dir = env->GetMethodID(cls, "dirIsDir", "(I)Z"); _dir_exists = env->GetMethodID(cls, "dirExists", "(ILjava/lang/String;)Z"); _file_exists = env->GetMethodID(cls, "fileExists", "(ILjava/lang/String;)Z"); _get_drive_count = env->GetMethodID(cls, "getDriveCount", "(I)I"); @@ -318,7 +322,7 @@ void DirAccessJAndroid::setup(jobject p_dir_access_handler) { _get_space_left = env->GetMethodID(cls, "getSpaceLeft", "(I)J"); _rename = env->GetMethodID(cls, "rename", "(ILjava/lang/String;Ljava/lang/String;)Z"); _remove = env->GetMethodID(cls, "remove", "(ILjava/lang/String;)Z"); - _current_is_hidden = env->GetMethodID(cls, "isCurrentHidden", "(II)Z"); + _current_is_hidden = env->GetMethodID(cls, "isCurrentHidden", "(I)Z"); } void DirAccessJAndroid::terminate() { @@ -355,6 +359,6 @@ void DirAccessJAndroid::dir_close(int p_id) { if (_dir_close) { JNIEnv *env = get_jni_env(); ERR_FAIL_NULL(env); - env->CallVoidMethod(dir_access_handler, _dir_close, get_access_type(), p_id); + env->CallVoidMethod(dir_access_handler, _dir_close, p_id); } } diff --git a/platform/android/dir_access_jandroid.h b/platform/android/dir_access_jandroid.h index 68578b0fa9..1d8fe906f3 100644 --- a/platform/android/dir_access_jandroid.h +++ b/platform/android/dir_access_jandroid.h @@ -84,7 +84,7 @@ public: virtual bool is_link(String p_file) override { return false; } virtual String read_link(String p_file) override { return p_file; } - virtual Error create_link(String p_source, String p_target) override { return FAILED; } + virtual Error create_link(String p_source, String p_target) override { return ERR_UNAVAILABLE; } virtual uint64_t get_space_left() override; diff --git a/platform/android/display_server_android.cpp b/platform/android/display_server_android.cpp index 06b304dcde..8dc0e869d0 100644 --- a/platform/android/display_server_android.cpp +++ b/platform/android/display_server_android.cpp @@ -651,7 +651,6 @@ DisplayServerAndroid::DisplayServerAndroid(const String &p_rendering_driver, Dis #endif Input::get_singleton()->set_event_dispatch_function(_dispatch_input_events); - Input::get_singleton()->set_use_input_buffering(true); // Needed because events will come directly from the UI thread r_error = OK; } diff --git a/platform/android/export/export.cpp b/platform/android/export/export.cpp index 6a6d7149ff..3f4624d09c 100644 --- a/platform/android/export/export.cpp +++ b/platform/android/export/export.cpp @@ -42,16 +42,17 @@ void register_android_exporter_types() { } void register_android_exporter() { -#ifndef ANDROID_ENABLED - EDITOR_DEF("export/android/java_sdk_path", OS::get_singleton()->get_environment("JAVA_HOME")); - EditorSettings::get_singleton()->add_property_hint(PropertyInfo(Variant::STRING, "export/android/java_sdk_path", PROPERTY_HINT_GLOBAL_DIR)); - EDITOR_DEF("export/android/android_sdk_path", OS::get_singleton()->get_environment("ANDROID_HOME")); - EditorSettings::get_singleton()->add_property_hint(PropertyInfo(Variant::STRING, "export/android/android_sdk_path", PROPERTY_HINT_GLOBAL_DIR)); EDITOR_DEF("export/android/debug_keystore", EditorPaths::get_singleton()->get_debug_keystore_path()); EditorSettings::get_singleton()->add_property_hint(PropertyInfo(Variant::STRING, "export/android/debug_keystore", PROPERTY_HINT_GLOBAL_FILE, "*.keystore,*.jks")); EDITOR_DEF("export/android/debug_keystore_user", DEFAULT_ANDROID_KEYSTORE_DEBUG_USER); EDITOR_DEF("export/android/debug_keystore_pass", DEFAULT_ANDROID_KEYSTORE_DEBUG_PASSWORD); EditorSettings::get_singleton()->add_property_hint(PropertyInfo(Variant::STRING, "export/android/debug_keystore_pass", PROPERTY_HINT_PASSWORD)); + +#ifndef ANDROID_ENABLED + EDITOR_DEF("export/android/java_sdk_path", OS::get_singleton()->get_environment("JAVA_HOME")); + EditorSettings::get_singleton()->add_property_hint(PropertyInfo(Variant::STRING, "export/android/java_sdk_path", PROPERTY_HINT_GLOBAL_DIR)); + EDITOR_DEF("export/android/android_sdk_path", OS::get_singleton()->get_environment("ANDROID_HOME")); + EditorSettings::get_singleton()->add_property_hint(PropertyInfo(Variant::STRING, "export/android/android_sdk_path", PROPERTY_HINT_GLOBAL_DIR)); EDITOR_DEF("export/android/force_system_user", false); EDITOR_DEF("export/android/shutdown_adb_on_exit", true); diff --git a/platform/android/export/export_plugin.cpp b/platform/android/export/export_plugin.cpp index 479158c91f..0fdaca4839 100644 --- a/platform/android/export/export_plugin.cpp +++ b/platform/android/export/export_plugin.cpp @@ -57,6 +57,10 @@ #include "modules/svg/image_loader_svg.h" #endif +#ifdef ANDROID_ENABLED +#include "../os_android.h" +#endif + #include <string.h> static const char *android_perms[] = { @@ -441,6 +445,7 @@ void EditorExportPlatformAndroid::_update_preset_status() { } else { has_runnable_preset.clear(); } + devices_changed.set(); } #endif @@ -2322,7 +2327,8 @@ static bool has_valid_keystore_credentials(String &r_error_str, const String &p_ args.push_back(p_password); args.push_back("-alias"); args.push_back(p_username); - Error error = OS::get_singleton()->execute("keytool", args, &output, nullptr, true); + String keytool_path = EditorExportPlatformAndroid::get_keytool_path(); + Error error = OS::get_singleton()->execute(keytool_path, args, &output, nullptr, true); String keytool_error = "keytool error:"; bool valid = output.substr(0, keytool_error.length()) != keytool_error; @@ -2415,6 +2421,10 @@ bool EditorExportPlatformAndroid::has_valid_export_configuration(const Ref<Edito err += template_err; } } else { +#ifdef ANDROID_ENABLED + err += TTR("Gradle build is not supported for the Android editor.") + "\n"; + valid = false; +#else // Validate the custom gradle android source template. bool android_source_template_valid = false; const String android_source_template = p_preset->get("gradle_build/android_source_template"); @@ -2437,6 +2447,7 @@ bool EditorExportPlatformAndroid::has_valid_export_configuration(const Ref<Edito } valid = installed_android_build_template && !r_missing_templates; +#endif } // Validate the rest of the export configuration. @@ -2473,6 +2484,7 @@ bool EditorExportPlatformAndroid::has_valid_export_configuration(const Ref<Edito err += TTR("Release keystore incorrectly configured in the export preset.") + "\n"; } +#ifndef ANDROID_ENABLED String java_sdk_path = EDITOR_GET("export/android/java_sdk_path"); if (java_sdk_path.is_empty()) { err += TTR("A valid Java SDK path is required in Editor Settings.") + "\n"; @@ -2545,6 +2557,7 @@ bool EditorExportPlatformAndroid::has_valid_export_configuration(const Ref<Edito valid = false; } } +#endif if (!err.is_empty()) { r_error = err; @@ -2715,23 +2728,9 @@ void EditorExportPlatformAndroid::get_command_line_flags(const Ref<EditorExportP Error EditorExportPlatformAndroid::sign_apk(const Ref<EditorExportPreset> &p_preset, bool p_debug, const String &export_path, EditorProgress &ep) { int export_format = int(p_preset->get("gradle_build/export_format")); - String export_label = export_format == EXPORT_FORMAT_AAB ? "AAB" : "APK"; - String release_keystore = _get_keystore_path(p_preset, false); - String release_username = p_preset->get_or_env("keystore/release_user", ENV_ANDROID_KEYSTORE_RELEASE_USER); - String release_password = p_preset->get_or_env("keystore/release_password", ENV_ANDROID_KEYSTORE_RELEASE_PASS); - String target_sdk_version = p_preset->get("gradle_build/target_sdk"); - if (!target_sdk_version.is_valid_int()) { - target_sdk_version = itos(DEFAULT_TARGET_SDK_VERSION); - } - String apksigner = get_apksigner_path(target_sdk_version.to_int(), true); - print_verbose("Starting signing of the " + export_label + " binary using " + apksigner); - if (apksigner == "<FAILED>") { - add_message(EXPORT_MESSAGE_WARNING, TTR("Code Signing"), vformat(TTR("All 'apksigner' tools located in Android SDK 'build-tools' directory failed to execute. Please check that you have the correct version installed for your target sdk version. The resulting %s is unsigned."), export_label)); - return OK; - } - if (!FileAccess::exists(apksigner)) { - add_message(EXPORT_MESSAGE_WARNING, TTR("Code Signing"), vformat(TTR("'apksigner' could not be found. Please check that the command is available in the Android SDK build-tools directory. The resulting %s is unsigned."), export_label)); - return OK; + if (export_format == EXPORT_FORMAT_AAB) { + add_message(EXPORT_MESSAGE_ERROR, TTR("Code Signing"), TTR("AAB signing is not supported")); + return FAILED; } String keystore; @@ -2748,15 +2747,15 @@ Error EditorExportPlatformAndroid::sign_apk(const Ref<EditorExportPreset> &p_pre user = EDITOR_GET("export/android/debug_keystore_user"); } - if (ep.step(vformat(TTR("Signing debug %s..."), export_label), 104)) { + if (ep.step(TTR("Signing debug APK..."), 104)) { return ERR_SKIP; } } else { - keystore = release_keystore; - password = release_password; - user = release_username; + keystore = _get_keystore_path(p_preset, false); + password = p_preset->get_or_env("keystore/release_password", ENV_ANDROID_KEYSTORE_RELEASE_PASS); + user = p_preset->get_or_env("keystore/release_user", ENV_ANDROID_KEYSTORE_RELEASE_USER); - if (ep.step(vformat(TTR("Signing release %s..."), export_label), 104)) { + if (ep.step(TTR("Signing release APK..."), 104)) { return ERR_SKIP; } } @@ -2766,6 +2765,36 @@ Error EditorExportPlatformAndroid::sign_apk(const Ref<EditorExportPreset> &p_pre return ERR_FILE_CANT_OPEN; } + String apk_path = export_path; + if (apk_path.is_relative_path()) { + apk_path = OS::get_singleton()->get_resource_dir().path_join(apk_path); + } + apk_path = ProjectSettings::get_singleton()->globalize_path(apk_path).simplify_path(); + + Error err; +#ifdef ANDROID_ENABLED + err = OS_Android::get_singleton()->sign_apk(apk_path, apk_path, keystore, user, password); + if (err != OK) { + add_message(EXPORT_MESSAGE_ERROR, TTR("Code Signing"), TTR("Unable to sign apk.")); + return err; + } +#else + String target_sdk_version = p_preset->get("gradle_build/target_sdk"); + if (!target_sdk_version.is_valid_int()) { + target_sdk_version = itos(DEFAULT_TARGET_SDK_VERSION); + } + + String apksigner = get_apksigner_path(target_sdk_version.to_int(), true); + print_verbose("Starting signing of the APK binary using " + apksigner); + if (apksigner == "<FAILED>") { + add_message(EXPORT_MESSAGE_WARNING, TTR("Code Signing"), TTR("All 'apksigner' tools located in Android SDK 'build-tools' directory failed to execute. Please check that you have the correct version installed for your target sdk version. The resulting APK is unsigned.")); + return OK; + } + if (!FileAccess::exists(apksigner)) { + add_message(EXPORT_MESSAGE_WARNING, TTR("Code Signing"), TTR("'apksigner' could not be found. Please check that the command is available in the Android SDK build-tools directory. The resulting APK is unsigned.")); + return OK; + } + String output; List<String> args; args.push_back("sign"); @@ -2776,7 +2805,7 @@ Error EditorExportPlatformAndroid::sign_apk(const Ref<EditorExportPreset> &p_pre args.push_back("pass:" + password); args.push_back("--ks-key-alias"); args.push_back(user); - args.push_back(export_path); + args.push_back(apk_path); if (OS::get_singleton()->is_stdout_verbose() && p_debug) { // We only print verbose logs with credentials for debug builds to avoid leaking release keystore credentials. print_verbose("Signing debug binary using: " + String("\n") + apksigner + " " + join_list(args, String(" "))); @@ -2788,7 +2817,7 @@ Error EditorExportPlatformAndroid::sign_apk(const Ref<EditorExportPreset> &p_pre print_line("Signing binary using: " + String("\n") + apksigner + " " + join_list(redacted_args, String(" "))); } int retval; - Error err = OS::get_singleton()->execute(apksigner, args, &output, &retval, true); + err = OS::get_singleton()->execute(apksigner, args, &output, &retval, true); if (err != OK) { add_message(EXPORT_MESSAGE_WARNING, TTR("Code Signing"), TTR("Could not start apksigner executable.")); return err; @@ -2800,15 +2829,23 @@ Error EditorExportPlatformAndroid::sign_apk(const Ref<EditorExportPreset> &p_pre add_message(EXPORT_MESSAGE_WARNING, TTR("Code Signing"), vformat(TTR("output: \n%s"), output)); return ERR_CANT_CREATE; } +#endif - if (ep.step(vformat(TTR("Verifying %s..."), export_label), 105)) { + if (ep.step(TTR("Verifying APK..."), 105)) { return ERR_SKIP; } +#ifdef ANDROID_ENABLED + err = OS_Android::get_singleton()->verify_apk(apk_path); + if (err != OK) { + add_message(EXPORT_MESSAGE_ERROR, TTR("Code Signing"), TTR("Unable to verify signed apk.")); + return err; + } +#else args.clear(); args.push_back("verify"); args.push_back("--verbose"); - args.push_back(export_path); + args.push_back(apk_path); if (p_debug) { print_verbose("Verifying signed build using: " + String("\n") + apksigner + " " + join_list(args, String(" "))); } @@ -2821,10 +2858,11 @@ Error EditorExportPlatformAndroid::sign_apk(const Ref<EditorExportPreset> &p_pre } print_verbose(output); if (retval) { - add_message(EXPORT_MESSAGE_WARNING, TTR("Code Signing"), vformat(TTR("'apksigner' verification of %s failed."), export_label)); + add_message(EXPORT_MESSAGE_WARNING, TTR("Code Signing"), TTR("'apksigner' verification of APK failed.")); add_message(EXPORT_MESSAGE_WARNING, TTR("Code Signing"), vformat(TTR("output: \n%s"), output)); return ERR_CANT_CREATE; } +#endif print_verbose("Successfully completed signing build."); return OK; @@ -3268,18 +3306,17 @@ Error EditorExportPlatformAndroid::export_project_helper(const Ref<EditorExportP } List<String> copy_args; - String copy_command; - if (export_format == EXPORT_FORMAT_AAB) { - copy_command = vformat("copyAndRename%sAab", build_type); - } else if (export_format == EXPORT_FORMAT_APK) { - copy_command = vformat("copyAndRename%sApk", build_type); - } - + String copy_command = "copyAndRenameBinary"; copy_args.push_back(copy_command); copy_args.push_back("-p"); // argument to specify the start directory. copy_args.push_back(build_path); // start directory. + copy_args.push_back("-Pexport_build_type=" + build_type.to_lower()); + + String export_format_arg = export_format == EXPORT_FORMAT_AAB ? "aab" : "apk"; + copy_args.push_back("-Pexport_format=" + export_format_arg); + String export_filename = p_path.get_file(); String export_path = p_path.get_base_dir(); if (export_path.is_relative_path()) { @@ -3318,7 +3355,7 @@ Error EditorExportPlatformAndroid::export_project_helper(const Ref<EditorExportP src_apk = find_export_template("android_release.apk"); } if (src_apk.is_empty()) { - add_message(EXPORT_MESSAGE_ERROR, TTR("Export"), vformat(TTR("Package not found: \"%s\"."), src_apk)); + add_message(EXPORT_MESSAGE_ERROR, TTR("Export"), vformat(TTR("%s export template not found: \"%s\"."), (p_debug ? "Debug" : "Release"), src_apk)); return ERR_FILE_NOT_FOUND; } } diff --git a/platform/android/file_access_android.h b/platform/android/file_access_android.h index e79daeafb3..b465a92c78 100644 --- a/platform/android/file_access_android.h +++ b/platform/android/file_access_android.h @@ -86,7 +86,7 @@ public: virtual uint64_t _get_modified_time(const String &p_file) override { return 0; } virtual BitField<FileAccess::UnixPermissionFlags> _get_unix_permissions(const String &p_file) override { return 0; } - virtual Error _set_unix_permissions(const String &p_file, BitField<FileAccess::UnixPermissionFlags> p_permissions) override { return FAILED; } + virtual Error _set_unix_permissions(const String &p_file, BitField<FileAccess::UnixPermissionFlags> p_permissions) override { return ERR_UNAVAILABLE; } virtual bool _get_hidden_attribute(const String &p_file) override { return false; } virtual Error _set_hidden_attribute(const String &p_file, bool p_hidden) override { return ERR_UNAVAILABLE; } diff --git a/platform/android/file_access_filesystem_jandroid.cpp b/platform/android/file_access_filesystem_jandroid.cpp index f28d469d07..9ae48dfb10 100644 --- a/platform/android/file_access_filesystem_jandroid.cpp +++ b/platform/android/file_access_filesystem_jandroid.cpp @@ -77,15 +77,9 @@ Error FileAccessFilesystemJAndroid::open_internal(const String &p_path, int p_mo int res = env->CallIntMethod(file_access_handler, _file_open, js, p_mode_flags); env->DeleteLocalRef(js); - if (res <= 0) { - switch (res) { - case 0: - default: - return ERR_FILE_CANT_OPEN; - - case -2: - return ERR_FILE_NOT_FOUND; - } + if (res < 0) { + // Errors are passed back as their negative value to differentiate from the positive file id. + return static_cast<Error>(-res); } id = res; @@ -331,19 +325,7 @@ Error FileAccessFilesystemJAndroid::resize(int64_t p_length) { ERR_FAIL_NULL_V(env, FAILED); ERR_FAIL_COND_V_MSG(!is_open(), FAILED, "File must be opened before use."); int res = env->CallIntMethod(file_access_handler, _file_resize, id, p_length); - switch (res) { - case 0: - return OK; - case -4: - return ERR_INVALID_PARAMETER; - case -3: - return ERR_FILE_CANT_OPEN; - case -2: - return ERR_FILE_NOT_FOUND; - case -1: - default: - return FAILED; - } + return static_cast<Error>(res); } else { return ERR_UNAVAILABLE; } diff --git a/platform/android/file_access_filesystem_jandroid.h b/platform/android/file_access_filesystem_jandroid.h index 6a8fc524b7..2795ac02ac 100644 --- a/platform/android/file_access_filesystem_jandroid.h +++ b/platform/android/file_access_filesystem_jandroid.h @@ -101,7 +101,7 @@ public: virtual uint64_t _get_modified_time(const String &p_file) override; virtual BitField<FileAccess::UnixPermissionFlags> _get_unix_permissions(const String &p_file) override { return 0; } - virtual Error _set_unix_permissions(const String &p_file, BitField<FileAccess::UnixPermissionFlags> p_permissions) override { return FAILED; } + virtual Error _set_unix_permissions(const String &p_file, BitField<FileAccess::UnixPermissionFlags> p_permissions) override { return ERR_UNAVAILABLE; } virtual bool _get_hidden_attribute(const String &p_file) override { return false; } virtual Error _set_hidden_attribute(const String &p_file, bool p_hidden) override { return ERR_UNAVAILABLE; } diff --git a/platform/android/java/lib/THIRDPARTY.md b/platform/android/java/THIRDPARTY.md index 2496b59263..7807cc55ff 100644 --- a/platform/android/java/lib/THIRDPARTY.md +++ b/platform/android/java/THIRDPARTY.md @@ -3,14 +3,6 @@ This file list third-party libraries used in the Android source folder, with their provenance and, when relevant, modifications made to those files. -## com.android.vending.billing - -- Upstream: https://github.com/googlesamples/android-play-billing/tree/master/TrivialDrive/app/src/main -- Version: git (7a94c69, 2019) -- License: Apache 2.0 - -Overwrite the file `aidl/com/android/vending/billing/IInAppBillingService.aidl`. - ## com.google.android.vending.expansion.downloader - Upstream: https://github.com/google/play-apk-expansion/tree/master/apkx_library @@ -19,10 +11,10 @@ Overwrite the file `aidl/com/android/vending/billing/IInAppBillingService.aidl`. Overwrite all files under: -- `src/com/google/android/vending/expansion/downloader` +- `lib/src/com/google/android/vending/expansion/downloader` Some files have been modified for yet unclear reasons. -See the `patches/com.google.android.vending.expansion.downloader.patch` file. +See the `lib/patches/com.google.android.vending.expansion.downloader.patch` file. ## com.google.android.vending.licensing @@ -32,8 +24,18 @@ See the `patches/com.google.android.vending.expansion.downloader.patch` file. Overwrite all files under: -- `aidl/com/android/vending/licensing` -- `src/com/google/android/vending/licensing` +- `lib/aidl/com/android/vending/licensing` +- `lib/src/com/google/android/vending/licensing` Some files have been modified to silence linter errors or fix downstream issues. -See the `patches/com.google.android.vending.licensing.patch` file. +See the `lib/patches/com.google.android.vending.licensing.patch` file. + +## com.android.apksig + +- Upstream: https://android.googlesource.com/platform/tools/apksig/+/ac5cbb07d87cc342fcf07715857a812305d69888 +- Version: git (ac5cbb07d87cc342fcf07715857a812305d69888, 2024) +- License: Apache 2.0 + +Overwrite all files under: + +- `editor/src/main/java/com/android/apksig` diff --git a/platform/android/java/app/AndroidManifest.xml b/platform/android/java/app/AndroidManifest.xml index 4abc6548bf..0cc929d226 100644 --- a/platform/android/java/app/AndroidManifest.xml +++ b/platform/android/java/app/AndroidManifest.xml @@ -24,6 +24,10 @@ android:hasFragileUserData="false" android:requestLegacyExternalStorage="false" tools:ignore="GoogleAppIndexingWarning" > + <profileable + android:shell="true" + android:enabled="true" + tools:targetApi="29" /> <!-- Records the version of the Godot editor used for building --> <meta-data diff --git a/platform/android/java/app/build.gradle b/platform/android/java/app/build.gradle index 01d5d9ef92..05b4f379b3 100644 --- a/platform/android/java/app/build.gradle +++ b/platform/android/java/app/build.gradle @@ -211,70 +211,24 @@ android { } } -task copyAndRenameDebugApk(type: Copy) { +task copyAndRenameBinary(type: Copy) { // The 'doNotTrackState' is added to disable gradle's up-to-date checks for output files // and directories. Otherwise this check may cause permissions access failures on Windows // machines. doNotTrackState("No need for up-to-date checks for the copy-and-rename operation") - from "$buildDir/outputs/apk/debug/android_debug.apk" - into getExportPath() - rename "android_debug.apk", getExportFilename() -} + String exportPath = getExportPath() + String exportFilename = getExportFilename() + String exportBuildType = getExportBuildType() + String exportFormat = getExportFormat() -task copyAndRenameDevApk(type: Copy) { - // The 'doNotTrackState' is added to disable gradle's up-to-date checks for output files - // and directories. Otherwise this check may cause permissions access failures on Windows - // machines. - doNotTrackState("No need for up-to-date checks for the copy-and-rename operation") - - from "$buildDir/outputs/apk/dev/android_dev.apk" - into getExportPath() - rename "android_dev.apk", getExportFilename() -} - -task copyAndRenameReleaseApk(type: Copy) { - // The 'doNotTrackState' is added to disable gradle's up-to-date checks for output files - // and directories. Otherwise this check may cause permissions access failures on Windows - // machines. - doNotTrackState("No need for up-to-date checks for the copy-and-rename operation") - - from "$buildDir/outputs/apk/release/android_release.apk" - into getExportPath() - rename "android_release.apk", getExportFilename() -} - -task copyAndRenameDebugAab(type: Copy) { - // The 'doNotTrackState' is added to disable gradle's up-to-date checks for output files - // and directories. Otherwise this check may cause permissions access failures on Windows - // machines. - doNotTrackState("No need for up-to-date checks for the copy-and-rename operation") - - from "$buildDir/outputs/bundle/debug/build-debug.aab" - into getExportPath() - rename "build-debug.aab", getExportFilename() -} - -task copyAndRenameDevAab(type: Copy) { - // The 'doNotTrackState' is added to disable gradle's up-to-date checks for output files - // and directories. Otherwise this check may cause permissions access failures on Windows - // machines. - doNotTrackState("No need for up-to-date checks for the copy-and-rename operation") - - from "$buildDir/outputs/bundle/dev/build-dev.aab" - into getExportPath() - rename "build-dev.aab", getExportFilename() -} - -task copyAndRenameReleaseAab(type: Copy) { - // The 'doNotTrackState' is added to disable gradle's up-to-date checks for output files - // and directories. Otherwise this check may cause permissions access failures on Windows - // machines. - doNotTrackState("No need for up-to-date checks for the copy-and-rename operation") + boolean isAab = exportFormat == "aab" + String sourceFilepath = isAab ? "$buildDir/outputs/bundle/$exportBuildType/build-${exportBuildType}.aab" : "$buildDir/outputs/apk/$exportBuildType/android_${exportBuildType}.apk" + String sourceFilename = isAab ? "build-${exportBuildType}.aab" : "android_${exportBuildType}.apk" - from "$buildDir/outputs/bundle/release/build-release.aab" - into getExportPath() - rename "build-release.aab", getExportFilename() + from sourceFilepath + into exportPath + rename sourceFilename, exportFilename } /** diff --git a/platform/android/java/app/config.gradle b/platform/android/java/app/config.gradle index 01759a1b2f..611a9c4a40 100644 --- a/platform/android/java/app/config.gradle +++ b/platform/android/java/app/config.gradle @@ -7,7 +7,7 @@ ext.versions = [ targetSdk : 34, buildTools : '34.0.0', kotlinVersion : '1.9.20', - fragmentVersion : '1.6.2', + fragmentVersion : '1.7.1', nexusPublishVersion: '1.3.0', javaVersion : JavaVersion.VERSION_17, // Also update 'platform/android/detect.py#get_ndk_version()' when this is updated. @@ -224,6 +224,22 @@ ext.getExportFilename = { return exportFilename } +ext.getExportBuildType = { + String exportBuildType = project.hasProperty("export_build_type") ? project.property("export_build_type") : "" + if (exportBuildType == null || exportBuildType.isEmpty()) { + exportBuildType = "debug" + } + return exportBuildType +} + +ext.getExportFormat = { + String exportFormat = project.hasProperty("export_format") ? project.property("export_format") : "" + if (exportFormat == null || exportFormat.isEmpty()) { + exportFormat = "apk" + } + return exportFormat +} + /** * Parse the project properties for the 'plugins_maven_repos' property and return the list * of maven repos. diff --git a/platform/android/java/build.gradle b/platform/android/java/build.gradle index b91b023ce6..771bda6948 100644 --- a/platform/android/java/build.gradle +++ b/platform/android/java/build.gradle @@ -35,116 +35,17 @@ ext { // `./gradlew generateGodotTemplates` build command instead after running the `scons` command(s). // The {selectedAbis} values must be from the {supportedAbis} values. selectedAbis = ["arm64"] -} -def rootDir = "../../.." -def binDir = "$rootDir/bin/" -def androidEditorBuildsDir = "$binDir/android_editor_builds/" + rootDir = "../../.." + binDir = "$rootDir/bin/" + androidEditorBuildsDir = "$binDir/android_editor_builds/" +} def getSconsTaskName(String flavor, String buildType, String abi) { return "compileGodotNativeLibs" + flavor.capitalize() + buildType.capitalize() + abi.capitalize() } /** - * Copy the generated 'android_debug.apk' binary template into the Godot bin directory. - * Depends on the app build task to ensure the binary is generated prior to copying. - */ -task copyDebugBinaryToBin(type: Copy) { - dependsOn ':app:assembleDebug' - from('app/build/outputs/apk/debug') - into(binDir) - include('android_debug.apk') -} - -/** - * Copy the generated 'android_dev.apk' binary template into the Godot bin directory. - * Depends on the app build task to ensure the binary is generated prior to copying. - */ -task copyDevBinaryToBin(type: Copy) { - dependsOn ':app:assembleDev' - from('app/build/outputs/apk/dev') - into(binDir) - include('android_dev.apk') -} - -/** - * Copy the generated 'android_release.apk' binary template into the Godot bin directory. - * Depends on the app build task to ensure the binary is generated prior to copying. - */ -task copyReleaseBinaryToBin(type: Copy) { - dependsOn ':app:assembleRelease' - from('app/build/outputs/apk/release') - into(binDir) - include('android_release.apk') -} - -/** - * Copy the Godot android library archive debug file into the app module debug libs directory. - * Depends on the library build task to ensure the AAR file is generated prior to copying. - */ -task copyDebugAARToAppModule(type: Copy) { - dependsOn ':lib:assembleTemplateDebug' - from('lib/build/outputs/aar') - into('app/libs/debug') - include('godot-lib.template_debug.aar') -} - -/** - * Copy the Godot android library archive debug file into the root bin directory. - * Depends on the library build task to ensure the AAR file is generated prior to copying. - */ -task copyDebugAARToBin(type: Copy) { - dependsOn ':lib:assembleTemplateDebug' - from('lib/build/outputs/aar') - into(binDir) - include('godot-lib.template_debug.aar') -} - -/** - * Copy the Godot android library archive dev file into the app module dev libs directory. - * Depends on the library build task to ensure the AAR file is generated prior to copying. - */ -task copyDevAARToAppModule(type: Copy) { - dependsOn ':lib:assembleTemplateDev' - from('lib/build/outputs/aar') - into('app/libs/dev') - include('godot-lib.template_debug.dev.aar') -} - -/** - * Copy the Godot android library archive dev file into the root bin directory. - * Depends on the library build task to ensure the AAR file is generated prior to copying. - */ -task copyDevAARToBin(type: Copy) { - dependsOn ':lib:assembleTemplateDev' - from('lib/build/outputs/aar') - into(binDir) - include('godot-lib.template_debug.dev.aar') -} - -/** - * Copy the Godot android library archive release file into the app module release libs directory. - * Depends on the library build task to ensure the AAR file is generated prior to copying. - */ -task copyReleaseAARToAppModule(type: Copy) { - dependsOn ':lib:assembleTemplateRelease' - from('lib/build/outputs/aar') - into('app/libs/release') - include('godot-lib.template_release.aar') -} - -/** - * Copy the Godot android library archive release file into the root bin directory. - * Depends on the library build task to ensure the AAR file is generated prior to copying. - */ -task copyReleaseAARToBin(type: Copy) { - dependsOn ':lib:assembleTemplateRelease' - from('lib/build/outputs/aar') - into(binDir) - include('godot-lib.template_release.aar') -} - -/** * Generate Godot gradle build template by zipping the source files from the app directory, as well * as the AAR files generated by 'copyDebugAAR', 'copyDevAAR' and 'copyReleaseAAR'. * The zip file also includes some gradle tools to enable gradle builds from the Godot Editor. @@ -197,7 +98,7 @@ def generateBuildTasks(String flavor = "template") { throw new GradleException("Invalid build flavor: $flavor") } - def tasks = [] + def buildTasks = [] // Only build the apks and aar files for which we have native shared libraries unless we intend // to run the scons build tasks. @@ -206,72 +107,93 @@ def generateBuildTasks(String flavor = "template") { String libsDir = isTemplate ? "lib/libs/" : "lib/libs/tools/" for (String target : supportedFlavorsBuildTypes[flavor]) { File targetLibs = new File(libsDir + target) + + String targetSuffix = target + if (target == "dev") { + targetSuffix = "debug.dev" + } + if (!excludeSconsBuildTasks || (targetLibs != null && targetLibs.isDirectory() && targetLibs.listFiles() != null && targetLibs.listFiles().length > 0)) { + String capitalizedTarget = target.capitalize() if (isTemplate) { - // Copy the generated aar library files to the build directory. - tasks += "copy${capitalizedTarget}AARToAppModule" - // Copy the generated aar library files to the bin directory. - tasks += "copy${capitalizedTarget}AARToBin" - // Copy the prebuilt binary templates to the bin directory. - tasks += "copy${capitalizedTarget}BinaryToBin" + // Copy the Godot android library archive file into the app module libs directory. + // Depends on the library build task to ensure the AAR file is generated prior to copying. + String copyAARTaskName = "copy${capitalizedTarget}AARToAppModule" + if (tasks.findByName(copyAARTaskName) != null) { + buildTasks += tasks.getByName(copyAARTaskName) + } else { + buildTasks += tasks.create(name: copyAARTaskName, type: Copy) { + dependsOn ":lib:assembleTemplate${capitalizedTarget}" + from('lib/build/outputs/aar') + include("godot-lib.template_${targetSuffix}.aar") + into("app/libs/${target}") + } + } + + // Copy the Godot android library archive file into the root bin directory. + // Depends on the library build task to ensure the AAR file is generated prior to copying. + String copyAARToBinTaskName = "copy${capitalizedTarget}AARToBin" + if (tasks.findByName(copyAARToBinTaskName) != null) { + buildTasks += tasks.getByName(copyAARToBinTaskName) + } else { + buildTasks += tasks.create(name: copyAARToBinTaskName, type: Copy) { + dependsOn ":lib:assembleTemplate${capitalizedTarget}" + from('lib/build/outputs/aar') + include("godot-lib.template_${targetSuffix}.aar") + into(binDir) + } + } + + // Copy the generated binary template into the Godot bin directory. + // Depends on the app build task to ensure the binary is generated prior to copying. + String copyBinaryTaskName = "copy${capitalizedTarget}BinaryToBin" + if (tasks.findByName(copyBinaryTaskName) != null) { + buildTasks += tasks.getByName(copyBinaryTaskName) + } else { + buildTasks += tasks.create(name: copyBinaryTaskName, type: Copy) { + dependsOn ":app:assemble${capitalizedTarget}" + from("app/build/outputs/apk/${target}") + into(binDir) + include("android_${target}.apk") + } + } } else { // Copy the generated editor apk to the bin directory. - tasks += "copyEditor${capitalizedTarget}ApkToBin" + String copyEditorApkTaskName = "copyEditor${capitalizedTarget}ApkToBin" + if (tasks.findByName(copyEditorApkTaskName) != null) { + buildTasks += tasks.getByName(copyEditorApkTaskName) + } else { + buildTasks += tasks.create(name: copyEditorApkTaskName, type: Copy) { + dependsOn ":editor:assemble${capitalizedTarget}" + from("editor/build/outputs/apk/${target}") + into(androidEditorBuildsDir) + include("android_editor-${target}*.apk") + } + } + // Copy the generated editor aab to the bin directory. - tasks += "copyEditor${capitalizedTarget}AabToBin" + String copyEditorAabTaskName = "copyEditor${capitalizedTarget}AabToBin" + if (tasks.findByName(copyEditorAabTaskName) != null) { + buildTasks += tasks.getByName(copyEditorAabTaskName) + } else { + buildTasks += tasks.create(name: copyEditorAabTaskName, type: Copy) { + dependsOn ":editor:bundle${capitalizedTarget}" + from("editor/build/outputs/bundle/${target}") + into(androidEditorBuildsDir) + include("android_editor-${target}*.aab") + } + } } } else { logger.lifecycle("No native shared libs for target $target. Skipping build.") } } - return tasks -} - -task copyEditorReleaseApkToBin(type: Copy) { - dependsOn ':editor:assembleRelease' - from('editor/build/outputs/apk/release') - into(androidEditorBuildsDir) - include('android_editor-release*.apk') -} - -task copyEditorReleaseAabToBin(type: Copy) { - dependsOn ':editor:bundleRelease' - from('editor/build/outputs/bundle/release') - into(androidEditorBuildsDir) - include('android_editor-release*.aab') -} - -task copyEditorDebugApkToBin(type: Copy) { - dependsOn ':editor:assembleDebug' - from('editor/build/outputs/apk/debug') - into(androidEditorBuildsDir) - include('android_editor-debug.apk') -} - -task copyEditorDebugAabToBin(type: Copy) { - dependsOn ':editor:bundleDebug' - from('editor/build/outputs/bundle/debug') - into(androidEditorBuildsDir) - include('android_editor-debug.aab') -} - -task copyEditorDevApkToBin(type: Copy) { - dependsOn ':editor:assembleDev' - from('editor/build/outputs/apk/dev') - into(androidEditorBuildsDir) - include('android_editor-dev.apk') -} - -task copyEditorDevAabToBin(type: Copy) { - dependsOn ':editor:bundleDev' - from('editor/build/outputs/bundle/dev') - into(androidEditorBuildsDir) - include('android_editor-dev.aab') + return buildTasks } /** @@ -301,7 +223,7 @@ task generateGodotTemplates { */ task generateDevTemplate { // add parameter to set symbols to true - gradle.startParameter.projectProperties += [doNotStrip: "true"] + project.ext.doNotStrip = "true" gradle.startParameter.excludedTaskNames += templateExcludedBuildTask() dependsOn = generateBuildTasks("template") diff --git a/platform/android/java/editor/build.gradle b/platform/android/java/editor/build.gradle index 55fe2a22fe..f9a3e10680 100644 --- a/platform/android/java/editor/build.gradle +++ b/platform/android/java/editor/build.gradle @@ -9,9 +9,10 @@ dependencies { implementation "androidx.fragment:fragment:$versions.fragmentVersion" implementation project(":lib") - implementation "androidx.window:window:1.2.0" + implementation "androidx.window:window:1.3.0" implementation "androidx.core:core-splashscreen:$versions.splashscreenVersion" implementation "androidx.constraintlayout:constraintlayout:2.1.4" + implementation "org.bouncycastle:bcprov-jdk15to18:1.77" } ext { diff --git a/platform/android/java/editor/src/main/assets/keystores/debug.keystore b/platform/android/java/editor/src/main/assets/keystores/debug.keystore Binary files differnew file mode 100644 index 0000000000..3b7a97c8ee --- /dev/null +++ b/platform/android/java/editor/src/main/assets/keystores/debug.keystore diff --git a/platform/android/java/editor/src/main/java/com/android/apksig/ApkSigner.java b/platform/android/java/editor/src/main/java/com/android/apksig/ApkSigner.java new file mode 100644 index 0000000000..49796a389e --- /dev/null +++ b/platform/android/java/editor/src/main/java/com/android/apksig/ApkSigner.java @@ -0,0 +1,1801 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.apksig; + +import static com.android.apksig.Constants.LIBRARY_PAGE_ALIGNMENT_BYTES; +import static com.android.apksig.apk.ApkUtils.SOURCE_STAMP_CERTIFICATE_HASH_ZIP_ENTRY_NAME; +import static com.android.apksig.internal.apk.v3.V3SchemeConstants.MIN_SDK_WITH_V31_SUPPORT; +import static com.android.apksig.internal.apk.v3.V3SchemeConstants.MIN_SDK_WITH_V3_SUPPORT; + +import com.android.apksig.apk.ApkFormatException; +import com.android.apksig.apk.ApkSigningBlockNotFoundException; +import com.android.apksig.apk.ApkUtils; +import com.android.apksig.apk.MinSdkVersionException; +import com.android.apksig.internal.apk.v3.V3SchemeConstants; +import com.android.apksig.internal.util.AndroidSdkVersion; +import com.android.apksig.internal.util.ByteBufferDataSource; +import com.android.apksig.internal.zip.CentralDirectoryRecord; +import com.android.apksig.internal.zip.EocdRecord; +import com.android.apksig.internal.zip.LocalFileRecord; +import com.android.apksig.internal.zip.ZipUtils; +import com.android.apksig.util.DataSink; +import com.android.apksig.util.DataSinks; +import com.android.apksig.util.DataSource; +import com.android.apksig.util.DataSources; +import com.android.apksig.util.ReadableDataSink; +import com.android.apksig.zip.ZipFormatException; + +import java.io.Closeable; +import java.io.File; +import java.io.IOException; +import java.io.RandomAccessFile; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.security.InvalidKeyException; +import java.security.NoSuchAlgorithmException; +import java.security.PrivateKey; +import java.security.SignatureException; +import java.security.cert.X509Certificate; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * APK signer. + * + * <p>The signer preserves as much of the input APK as possible. For example, it preserves the order + * of APK entries and preserves their contents, including compressed form and alignment of data. + * + * <p>Use {@link Builder} to obtain instances of this signer. + * + * @see <a href="https://source.android.com/security/apksigning/index.html">Application Signing</a> + */ +public class ApkSigner { + + /** + * Extensible data block/field header ID used for storing information about alignment of + * uncompressed entries as well as for aligning the entries's data. See ZIP appnote.txt section + * 4.5 Extensible data fields. + */ + private static final short ALIGNMENT_ZIP_EXTRA_DATA_FIELD_HEADER_ID = (short) 0xd935; + + /** + * Minimum size (in bytes) of the extensible data block/field used for alignment of uncompressed + * entries. + */ + private static final short ALIGNMENT_ZIP_EXTRA_DATA_FIELD_MIN_SIZE_BYTES = 6; + + private static final short ANDROID_FILE_ALIGNMENT_BYTES = 4096; + + /** Name of the Android manifest ZIP entry in APKs. */ + private static final String ANDROID_MANIFEST_ZIP_ENTRY_NAME = "AndroidManifest.xml"; + + private final List<SignerConfig> mSignerConfigs; + private final SignerConfig mSourceStampSignerConfig; + private final SigningCertificateLineage mSourceStampSigningCertificateLineage; + private final boolean mForceSourceStampOverwrite; + private final boolean mSourceStampTimestampEnabled; + private final Integer mMinSdkVersion; + private final int mRotationMinSdkVersion; + private final boolean mRotationTargetsDevRelease; + private final boolean mV1SigningEnabled; + private final boolean mV2SigningEnabled; + private final boolean mV3SigningEnabled; + private final boolean mV4SigningEnabled; + private final boolean mAlignFileSize; + private final boolean mVerityEnabled; + private final boolean mV4ErrorReportingEnabled; + private final boolean mDebuggableApkPermitted; + private final boolean mOtherSignersSignaturesPreserved; + private final boolean mAlignmentPreserved; + private final int mLibraryPageAlignmentBytes; + private final String mCreatedBy; + + private final ApkSignerEngine mSignerEngine; + + private final File mInputApkFile; + private final DataSource mInputApkDataSource; + + private final File mOutputApkFile; + private final DataSink mOutputApkDataSink; + private final DataSource mOutputApkDataSource; + + private final File mOutputV4File; + + private final SigningCertificateLineage mSigningCertificateLineage; + + private ApkSigner( + List<SignerConfig> signerConfigs, + SignerConfig sourceStampSignerConfig, + SigningCertificateLineage sourceStampSigningCertificateLineage, + boolean forceSourceStampOverwrite, + boolean sourceStampTimestampEnabled, + Integer minSdkVersion, + int rotationMinSdkVersion, + boolean rotationTargetsDevRelease, + boolean v1SigningEnabled, + boolean v2SigningEnabled, + boolean v3SigningEnabled, + boolean v4SigningEnabled, + boolean alignFileSize, + boolean verityEnabled, + boolean v4ErrorReportingEnabled, + boolean debuggableApkPermitted, + boolean otherSignersSignaturesPreserved, + boolean alignmentPreserved, + int libraryPageAlignmentBytes, + String createdBy, + ApkSignerEngine signerEngine, + File inputApkFile, + DataSource inputApkDataSource, + File outputApkFile, + DataSink outputApkDataSink, + DataSource outputApkDataSource, + File outputV4File, + SigningCertificateLineage signingCertificateLineage) { + + mSignerConfigs = signerConfigs; + mSourceStampSignerConfig = sourceStampSignerConfig; + mSourceStampSigningCertificateLineage = sourceStampSigningCertificateLineage; + mForceSourceStampOverwrite = forceSourceStampOverwrite; + mSourceStampTimestampEnabled = sourceStampTimestampEnabled; + mMinSdkVersion = minSdkVersion; + mRotationMinSdkVersion = rotationMinSdkVersion; + mRotationTargetsDevRelease = rotationTargetsDevRelease; + mV1SigningEnabled = v1SigningEnabled; + mV2SigningEnabled = v2SigningEnabled; + mV3SigningEnabled = v3SigningEnabled; + mV4SigningEnabled = v4SigningEnabled; + mAlignFileSize = alignFileSize; + mVerityEnabled = verityEnabled; + mV4ErrorReportingEnabled = v4ErrorReportingEnabled; + mDebuggableApkPermitted = debuggableApkPermitted; + mOtherSignersSignaturesPreserved = otherSignersSignaturesPreserved; + mAlignmentPreserved = alignmentPreserved; + mLibraryPageAlignmentBytes = libraryPageAlignmentBytes; + mCreatedBy = createdBy; + + mSignerEngine = signerEngine; + + mInputApkFile = inputApkFile; + mInputApkDataSource = inputApkDataSource; + + mOutputApkFile = outputApkFile; + mOutputApkDataSink = outputApkDataSink; + mOutputApkDataSource = outputApkDataSource; + + mOutputV4File = outputV4File; + + mSigningCertificateLineage = signingCertificateLineage; + } + + /** + * Signs the input APK and outputs the resulting signed APK. The input APK is not modified. + * + * @throws IOException if an I/O error is encountered while reading or writing the APKs + * @throws ApkFormatException if the input APK is malformed + * @throws NoSuchAlgorithmException if the APK signatures cannot be produced or verified because + * a required cryptographic algorithm implementation is missing + * @throws InvalidKeyException if a signature could not be generated because a signing key is + * not suitable for generating the signature + * @throws SignatureException if an error occurred while generating or verifying a signature + * @throws IllegalStateException if this signer's configuration is missing required information + * or if the signing engine is in an invalid state. + */ + public void sign() + throws IOException, ApkFormatException, NoSuchAlgorithmException, InvalidKeyException, + SignatureException, IllegalStateException { + Closeable in = null; + DataSource inputApk; + try { + if (mInputApkDataSource != null) { + inputApk = mInputApkDataSource; + } else if (mInputApkFile != null) { + RandomAccessFile inputFile = new RandomAccessFile(mInputApkFile, "r"); + in = inputFile; + inputApk = DataSources.asDataSource(inputFile); + } else { + throw new IllegalStateException("Input APK not specified"); + } + + Closeable out = null; + try { + DataSink outputApkOut; + DataSource outputApkIn; + if (mOutputApkDataSink != null) { + outputApkOut = mOutputApkDataSink; + outputApkIn = mOutputApkDataSource; + } else if (mOutputApkFile != null) { + RandomAccessFile outputFile = new RandomAccessFile(mOutputApkFile, "rw"); + out = outputFile; + outputFile.setLength(0); + outputApkOut = DataSinks.asDataSink(outputFile); + outputApkIn = DataSources.asDataSource(outputFile); + } else { + throw new IllegalStateException("Output APK not specified"); + } + + sign(inputApk, outputApkOut, outputApkIn); + } finally { + if (out != null) { + out.close(); + } + } + } finally { + if (in != null) { + in.close(); + } + } + } + + private void sign(DataSource inputApk, DataSink outputApkOut, DataSource outputApkIn) + throws IOException, ApkFormatException, NoSuchAlgorithmException, InvalidKeyException, + SignatureException { + // Step 1. Find input APK's main ZIP sections + ApkUtils.ZipSections inputZipSections; + try { + inputZipSections = ApkUtils.findZipSections(inputApk); + } catch (ZipFormatException e) { + throw new ApkFormatException("Malformed APK: not a ZIP archive", e); + } + long inputApkSigningBlockOffset = -1; + DataSource inputApkSigningBlock = null; + try { + ApkUtils.ApkSigningBlock apkSigningBlockInfo = + ApkUtils.findApkSigningBlock(inputApk, inputZipSections); + inputApkSigningBlockOffset = apkSigningBlockInfo.getStartOffset(); + inputApkSigningBlock = apkSigningBlockInfo.getContents(); + } catch (ApkSigningBlockNotFoundException e) { + // Input APK does not contain an APK Signing Block. That's OK. APKs are not required to + // contain this block. It's only needed if the APK is signed using APK Signature Scheme + // v2 and/or v3. + } + DataSource inputApkLfhSection = + inputApk.slice( + 0, + (inputApkSigningBlockOffset != -1) + ? inputApkSigningBlockOffset + : inputZipSections.getZipCentralDirectoryOffset()); + + // Step 2. Parse the input APK's ZIP Central Directory + ByteBuffer inputCd = getZipCentralDirectory(inputApk, inputZipSections); + List<CentralDirectoryRecord> inputCdRecords = + parseZipCentralDirectory(inputCd, inputZipSections); + + List<Hints.PatternWithRange> pinPatterns = + extractPinPatterns(inputCdRecords, inputApkLfhSection); + List<Hints.ByteRange> pinByteRanges = pinPatterns == null ? null : new ArrayList<>(); + + // Step 3. Obtain a signer engine instance + ApkSignerEngine signerEngine; + if (mSignerEngine != null) { + // Use the provided signer engine + signerEngine = mSignerEngine; + } else { + // Construct a signer engine from the provided parameters + int minSdkVersion; + if (mMinSdkVersion != null) { + // No need to extract minSdkVersion from the APK's AndroidManifest.xml + minSdkVersion = mMinSdkVersion; + } else { + // Need to extract minSdkVersion from the APK's AndroidManifest.xml + minSdkVersion = getMinSdkVersionFromApk(inputCdRecords, inputApkLfhSection); + } + List<DefaultApkSignerEngine.SignerConfig> engineSignerConfigs = + new ArrayList<>(mSignerConfigs.size()); + for (SignerConfig signerConfig : mSignerConfigs) { + DefaultApkSignerEngine.SignerConfig.Builder signerConfigBuilder = + new DefaultApkSignerEngine.SignerConfig.Builder( + signerConfig.getName(), + signerConfig.getPrivateKey(), + signerConfig.getCertificates(), + signerConfig.getDeterministicDsaSigning()); + int signerMinSdkVersion = signerConfig.getMinSdkVersion(); + SigningCertificateLineage signerLineage = + signerConfig.getSigningCertificateLineage(); + if (signerMinSdkVersion > 0) { + signerConfigBuilder.setLineageForMinSdkVersion(signerLineage, + signerMinSdkVersion); + } + engineSignerConfigs.add(signerConfigBuilder.build()); + } + DefaultApkSignerEngine.Builder signerEngineBuilder = + new DefaultApkSignerEngine.Builder(engineSignerConfigs, minSdkVersion) + .setV1SigningEnabled(mV1SigningEnabled) + .setV2SigningEnabled(mV2SigningEnabled) + .setV3SigningEnabled(mV3SigningEnabled) + .setVerityEnabled(mVerityEnabled) + .setDebuggableApkPermitted(mDebuggableApkPermitted) + .setOtherSignersSignaturesPreserved(mOtherSignersSignaturesPreserved) + .setSigningCertificateLineage(mSigningCertificateLineage) + .setMinSdkVersionForRotation(mRotationMinSdkVersion) + .setRotationTargetsDevRelease(mRotationTargetsDevRelease); + if (mCreatedBy != null) { + signerEngineBuilder.setCreatedBy(mCreatedBy); + } + if (mSourceStampSignerConfig != null) { + signerEngineBuilder.setStampSignerConfig( + new DefaultApkSignerEngine.SignerConfig.Builder( + mSourceStampSignerConfig.getName(), + mSourceStampSignerConfig.getPrivateKey(), + mSourceStampSignerConfig.getCertificates(), + mSourceStampSignerConfig.getDeterministicDsaSigning()) + .build()); + signerEngineBuilder.setSourceStampTimestampEnabled(mSourceStampTimestampEnabled); + } + if (mSourceStampSigningCertificateLineage != null) { + signerEngineBuilder.setSourceStampSigningCertificateLineage( + mSourceStampSigningCertificateLineage); + } + signerEngine = signerEngineBuilder.build(); + } + + // Step 4. Provide the signer engine with the input APK's APK Signing Block (if any) + if (inputApkSigningBlock != null) { + signerEngine.inputApkSigningBlock(inputApkSigningBlock); + } + + // Step 5. Iterate over input APK's entries and output the Local File Header + data of those + // entries which need to be output. Entries are iterated in the order in which their Local + // File Header records are stored in the file. This is to achieve better data locality in + // case Central Directory entries are in the wrong order. + List<CentralDirectoryRecord> inputCdRecordsSortedByLfhOffset = + new ArrayList<>(inputCdRecords); + Collections.sort( + inputCdRecordsSortedByLfhOffset, + CentralDirectoryRecord.BY_LOCAL_FILE_HEADER_OFFSET_COMPARATOR); + int lastModifiedDateForNewEntries = -1; + int lastModifiedTimeForNewEntries = -1; + long inputOffset = 0; + long outputOffset = 0; + byte[] sourceStampCertificateDigest = null; + Map<String, CentralDirectoryRecord> outputCdRecordsByName = + new HashMap<>(inputCdRecords.size()); + for (final CentralDirectoryRecord inputCdRecord : inputCdRecordsSortedByLfhOffset) { + String entryName = inputCdRecord.getName(); + if (Hints.PIN_BYTE_RANGE_ZIP_ENTRY_NAME.equals(entryName)) { + continue; // We'll re-add below if needed. + } + if (SOURCE_STAMP_CERTIFICATE_HASH_ZIP_ENTRY_NAME.equals(entryName)) { + try { + sourceStampCertificateDigest = + LocalFileRecord.getUncompressedData( + inputApkLfhSection, inputCdRecord, inputApkLfhSection.size()); + } catch (ZipFormatException ex) { + throw new ApkFormatException("Bad source stamp entry"); + } + continue; // Existing source stamp is handled below as needed. + } + ApkSignerEngine.InputJarEntryInstructions entryInstructions = + signerEngine.inputJarEntry(entryName); + boolean shouldOutput; + switch (entryInstructions.getOutputPolicy()) { + case OUTPUT: + shouldOutput = true; + break; + case OUTPUT_BY_ENGINE: + case SKIP: + shouldOutput = false; + break; + default: + throw new RuntimeException( + "Unknown output policy: " + entryInstructions.getOutputPolicy()); + } + + long inputLocalFileHeaderStartOffset = inputCdRecord.getLocalFileHeaderOffset(); + if (inputLocalFileHeaderStartOffset > inputOffset) { + // Unprocessed data in input starting at inputOffset and ending and the start of + // this record's LFH. We output this data verbatim because this signer is supposed + // to preserve as much of input as possible. + long chunkSize = inputLocalFileHeaderStartOffset - inputOffset; + inputApkLfhSection.feed(inputOffset, chunkSize, outputApkOut); + outputOffset += chunkSize; + inputOffset = inputLocalFileHeaderStartOffset; + } + LocalFileRecord inputLocalFileRecord; + try { + inputLocalFileRecord = + LocalFileRecord.getRecord( + inputApkLfhSection, inputCdRecord, inputApkLfhSection.size()); + } catch (ZipFormatException e) { + throw new ApkFormatException("Malformed ZIP entry: " + inputCdRecord.getName(), e); + } + inputOffset += inputLocalFileRecord.getSize(); + + ApkSignerEngine.InspectJarEntryRequest inspectEntryRequest = + entryInstructions.getInspectJarEntryRequest(); + if (inspectEntryRequest != null) { + fulfillInspectInputJarEntryRequest( + inputApkLfhSection, inputLocalFileRecord, inspectEntryRequest); + } + + if (shouldOutput) { + // Find the max value of last modified, to be used for new entries added by the + // signer. + int lastModifiedDate = inputCdRecord.getLastModificationDate(); + int lastModifiedTime = inputCdRecord.getLastModificationTime(); + if ((lastModifiedDateForNewEntries == -1) + || (lastModifiedDate > lastModifiedDateForNewEntries) + || ((lastModifiedDate == lastModifiedDateForNewEntries) + && (lastModifiedTime > lastModifiedTimeForNewEntries))) { + lastModifiedDateForNewEntries = lastModifiedDate; + lastModifiedTimeForNewEntries = lastModifiedTime; + } + + inspectEntryRequest = signerEngine.outputJarEntry(entryName); + if (inspectEntryRequest != null) { + fulfillInspectInputJarEntryRequest( + inputApkLfhSection, inputLocalFileRecord, inspectEntryRequest); + } + + // Output entry's Local File Header + data + long outputLocalFileHeaderOffset = outputOffset; + OutputSizeAndDataOffset outputLfrResult = + outputInputJarEntryLfhRecord( + inputApkLfhSection, + inputLocalFileRecord, + outputApkOut, + outputLocalFileHeaderOffset); + outputOffset += outputLfrResult.outputBytes; + long outputDataOffset = + outputLocalFileHeaderOffset + outputLfrResult.dataOffsetBytes; + + if (pinPatterns != null) { + boolean pinFileHeader = false; + for (Hints.PatternWithRange pinPattern : pinPatterns) { + if (pinPattern.matcher(inputCdRecord.getName()).matches()) { + Hints.ByteRange dataRange = + new Hints.ByteRange(outputDataOffset, outputOffset); + Hints.ByteRange pinRange = + pinPattern.ClampToAbsoluteByteRange(dataRange); + if (pinRange != null) { + pinFileHeader = true; + pinByteRanges.add(pinRange); + } + } + } + if (pinFileHeader) { + pinByteRanges.add( + new Hints.ByteRange(outputLocalFileHeaderOffset, outputDataOffset)); + } + } + + // Enqueue entry's Central Directory record for output + CentralDirectoryRecord outputCdRecord; + if (outputLocalFileHeaderOffset == inputLocalFileRecord.getStartOffsetInArchive()) { + outputCdRecord = inputCdRecord; + } else { + outputCdRecord = + inputCdRecord.createWithModifiedLocalFileHeaderOffset( + outputLocalFileHeaderOffset); + } + outputCdRecordsByName.put(entryName, outputCdRecord); + } + } + long inputLfhSectionSize = inputApkLfhSection.size(); + if (inputOffset < inputLfhSectionSize) { + // Unprocessed data in input starting at inputOffset and ending and the end of the input + // APK's LFH section. We output this data verbatim because this signer is supposed + // to preserve as much of input as possible. + long chunkSize = inputLfhSectionSize - inputOffset; + inputApkLfhSection.feed(inputOffset, chunkSize, outputApkOut); + outputOffset += chunkSize; + inputOffset = inputLfhSectionSize; + } + + // Step 6. Sort output APK's Central Directory records in the order in which they should + // appear in the output + List<CentralDirectoryRecord> outputCdRecords = new ArrayList<>(inputCdRecords.size() + 10); + for (CentralDirectoryRecord inputCdRecord : inputCdRecords) { + String entryName = inputCdRecord.getName(); + CentralDirectoryRecord outputCdRecord = outputCdRecordsByName.get(entryName); + if (outputCdRecord != null) { + outputCdRecords.add(outputCdRecord); + } + } + + if (lastModifiedDateForNewEntries == -1) { + lastModifiedDateForNewEntries = 0x3a21; // Jan 1 2009 (DOS) + lastModifiedTimeForNewEntries = 0; + } + + // Step 7. Generate and output SourceStamp certificate hash, if necessary. This may output + // more Local File Header + data entries and add to the list of output Central Directory + // records. + if (signerEngine.isEligibleForSourceStamp()) { + byte[] uncompressedData = signerEngine.generateSourceStampCertificateDigest(); + if (mForceSourceStampOverwrite + || sourceStampCertificateDigest == null + || Arrays.equals(uncompressedData, sourceStampCertificateDigest)) { + outputOffset += + outputDataToOutputApk( + SOURCE_STAMP_CERTIFICATE_HASH_ZIP_ENTRY_NAME, + uncompressedData, + outputOffset, + outputCdRecords, + lastModifiedTimeForNewEntries, + lastModifiedDateForNewEntries, + outputApkOut); + } else { + throw new ApkFormatException( + String.format( + "Cannot generate SourceStamp. APK contains an existing entry with" + + " the name: %s, and it is different than the provided source" + + " stamp certificate", + SOURCE_STAMP_CERTIFICATE_HASH_ZIP_ENTRY_NAME)); + } + } + + // Step 7.5. Generate pinlist.meta file if necessary. + // This has to be before the step 8 so that the file is signed. + if (pinByteRanges != null) { + // Covers JAR signature and zip central dir entry. + // The signature files don't have to be pinned, but pinning them isn't that wasteful + // since the total size is small. + pinByteRanges.add(new Hints.ByteRange(outputOffset, Long.MAX_VALUE)); + String entryName = Hints.PIN_BYTE_RANGE_ZIP_ENTRY_NAME; + byte[] uncompressedData = Hints.encodeByteRangeList(pinByteRanges); + + requestOutputEntryInspection(signerEngine, entryName, uncompressedData); + outputOffset += + outputDataToOutputApk( + entryName, + uncompressedData, + outputOffset, + outputCdRecords, + lastModifiedTimeForNewEntries, + lastModifiedDateForNewEntries, + outputApkOut); + } + + // Step 8. Generate and output JAR signatures, if necessary. This may output more Local File + // Header + data entries and add to the list of output Central Directory records. + ApkSignerEngine.OutputJarSignatureRequest outputJarSignatureRequest = + signerEngine.outputJarEntries(); + if (outputJarSignatureRequest != null) { + for (ApkSignerEngine.OutputJarSignatureRequest.JarEntry entry : + outputJarSignatureRequest.getAdditionalJarEntries()) { + String entryName = entry.getName(); + byte[] uncompressedData = entry.getData(); + + requestOutputEntryInspection(signerEngine, entryName, uncompressedData); + outputOffset += + outputDataToOutputApk( + entryName, + uncompressedData, + outputOffset, + outputCdRecords, + lastModifiedTimeForNewEntries, + lastModifiedDateForNewEntries, + outputApkOut); + } + outputJarSignatureRequest.done(); + } + + // Step 9. Construct output ZIP Central Directory in an in-memory buffer + long outputCentralDirSizeBytes = 0; + for (CentralDirectoryRecord record : outputCdRecords) { + outputCentralDirSizeBytes += record.getSize(); + } + if (outputCentralDirSizeBytes > Integer.MAX_VALUE) { + throw new IOException( + "Output ZIP Central Directory too large: " + + outputCentralDirSizeBytes + + " bytes"); + } + ByteBuffer outputCentralDir = ByteBuffer.allocate((int) outputCentralDirSizeBytes); + for (CentralDirectoryRecord record : outputCdRecords) { + record.copyTo(outputCentralDir); + } + outputCentralDir.flip(); + DataSource outputCentralDirDataSource = new ByteBufferDataSource(outputCentralDir); + long outputCentralDirStartOffset = outputOffset; + int outputCentralDirRecordCount = outputCdRecords.size(); + + // Step 10. Construct output ZIP End of Central Directory record in an in-memory buffer + // because it can be adjusted in Step 11 due to signing block. + // - CD offset (it's shifted by signing block) + // - Comments (when the output file needs to be sized 4k-aligned) + ByteBuffer outputEocd = + EocdRecord.createWithModifiedCentralDirectoryInfo( + inputZipSections.getZipEndOfCentralDirectory(), + outputCentralDirRecordCount, + outputCentralDirDataSource.size(), + outputCentralDirStartOffset); + + // Step 11. Generate and output APK Signature Scheme v2 and/or v3 signatures and/or + // SourceStamp signatures, if necessary. + // This may insert an APK Signing Block just before the output's ZIP Central Directory + ApkSignerEngine.OutputApkSigningBlockRequest2 outputApkSigningBlockRequest = + signerEngine.outputZipSections2( + outputApkIn, + outputCentralDirDataSource, + DataSources.asDataSource(outputEocd)); + + if (outputApkSigningBlockRequest != null) { + int padding = outputApkSigningBlockRequest.getPaddingSizeBeforeApkSigningBlock(); + byte[] outputApkSigningBlock = outputApkSigningBlockRequest.getApkSigningBlock(); + outputApkSigningBlockRequest.done(); + + long fileSize = + outputCentralDirStartOffset + + outputCentralDirDataSource.size() + + padding + + outputApkSigningBlock.length + + outputEocd.remaining(); + if (mAlignFileSize && (fileSize % ANDROID_FILE_ALIGNMENT_BYTES != 0)) { + int eocdPadding = + (int) + (ANDROID_FILE_ALIGNMENT_BYTES + - fileSize % ANDROID_FILE_ALIGNMENT_BYTES); + // Replace EOCD with padding one so that output file size can be the multiples of + // alignment. + outputEocd = EocdRecord.createWithPaddedComment(outputEocd, eocdPadding); + + // Since EoCD has changed, we need to regenerate signing block as well. + outputApkSigningBlockRequest = + signerEngine.outputZipSections2( + outputApkIn, + new ByteBufferDataSource(outputCentralDir), + DataSources.asDataSource(outputEocd)); + outputApkSigningBlock = outputApkSigningBlockRequest.getApkSigningBlock(); + outputApkSigningBlockRequest.done(); + } + + outputApkOut.consume(ByteBuffer.allocate(padding)); + outputApkOut.consume(outputApkSigningBlock, 0, outputApkSigningBlock.length); + ZipUtils.setZipEocdCentralDirectoryOffset( + outputEocd, + outputCentralDirStartOffset + padding + outputApkSigningBlock.length); + } + + // Step 12. Output ZIP Central Directory and ZIP End of Central Directory + outputCentralDirDataSource.feed(0, outputCentralDirDataSource.size(), outputApkOut); + outputApkOut.consume(outputEocd); + signerEngine.outputDone(); + + // Step 13. Generate and output APK Signature Scheme v4 signatures, if necessary. + if (mV4SigningEnabled) { + signerEngine.signV4(outputApkIn, mOutputV4File, !mV4ErrorReportingEnabled); + } + } + + private static void requestOutputEntryInspection( + ApkSignerEngine signerEngine, + String entryName, + byte[] uncompressedData) + throws IOException { + ApkSignerEngine.InspectJarEntryRequest inspectEntryRequest = + signerEngine.outputJarEntry(entryName); + if (inspectEntryRequest != null) { + inspectEntryRequest.getDataSink().consume( + uncompressedData, 0, uncompressedData.length); + inspectEntryRequest.done(); + } + } + + private static long outputDataToOutputApk( + String entryName, + byte[] uncompressedData, + long localFileHeaderOffset, + List<CentralDirectoryRecord> outputCdRecords, + int lastModifiedTimeForNewEntries, + int lastModifiedDateForNewEntries, + DataSink outputApkOut) + throws IOException { + ZipUtils.DeflateResult deflateResult = ZipUtils.deflate(ByteBuffer.wrap(uncompressedData)); + byte[] compressedData = deflateResult.output; + long uncompressedDataCrc32 = deflateResult.inputCrc32; + long numOfDataBytes = + LocalFileRecord.outputRecordWithDeflateCompressedData( + entryName, + lastModifiedTimeForNewEntries, + lastModifiedDateForNewEntries, + compressedData, + uncompressedDataCrc32, + uncompressedData.length, + outputApkOut); + outputCdRecords.add( + CentralDirectoryRecord.createWithDeflateCompressedData( + entryName, + lastModifiedTimeForNewEntries, + lastModifiedDateForNewEntries, + uncompressedDataCrc32, + compressedData.length, + uncompressedData.length, + localFileHeaderOffset)); + return numOfDataBytes; + } + + private static void fulfillInspectInputJarEntryRequest( + DataSource lfhSection, + LocalFileRecord localFileRecord, + ApkSignerEngine.InspectJarEntryRequest inspectEntryRequest) + throws IOException, ApkFormatException { + try { + localFileRecord.outputUncompressedData(lfhSection, inspectEntryRequest.getDataSink()); + } catch (ZipFormatException e) { + throw new ApkFormatException("Malformed ZIP entry: " + localFileRecord.getName(), e); + } + inspectEntryRequest.done(); + } + + private static class OutputSizeAndDataOffset { + public long outputBytes; + public long dataOffsetBytes; + + public OutputSizeAndDataOffset(long outputBytes, long dataOffsetBytes) { + this.outputBytes = outputBytes; + this.dataOffsetBytes = dataOffsetBytes; + } + } + + private OutputSizeAndDataOffset outputInputJarEntryLfhRecord( + DataSource inputLfhSection, + LocalFileRecord inputRecord, + DataSink outputLfhSection, + long outputOffset) + throws IOException { + long inputOffset = inputRecord.getStartOffsetInArchive(); + if (inputOffset == outputOffset && mAlignmentPreserved) { + // This record's data will be aligned same as in the input APK. + return new OutputSizeAndDataOffset( + inputRecord.outputRecord(inputLfhSection, outputLfhSection), + inputRecord.getDataStartOffsetInRecord()); + } + int dataAlignmentMultiple = getInputJarEntryDataAlignmentMultiple(inputRecord); + if ((dataAlignmentMultiple <= 1) + || ((inputOffset % dataAlignmentMultiple) == (outputOffset % dataAlignmentMultiple) + && mAlignmentPreserved)) { + // This record's data will be aligned same as in the input APK. + return new OutputSizeAndDataOffset( + inputRecord.outputRecord(inputLfhSection, outputLfhSection), + inputRecord.getDataStartOffsetInRecord()); + } + + long inputDataStartOffset = inputOffset + inputRecord.getDataStartOffsetInRecord(); + if ((inputDataStartOffset % dataAlignmentMultiple) != 0 && mAlignmentPreserved) { + // This record's data is not aligned in the input APK. No need to align it in the + // output. + return new OutputSizeAndDataOffset( + inputRecord.outputRecord(inputLfhSection, outputLfhSection), + inputRecord.getDataStartOffsetInRecord()); + } + + // This record's data needs to be re-aligned in the output. This is achieved using the + // record's extra field. + ByteBuffer aligningExtra = + createExtraFieldToAlignData( + inputRecord.getExtra(), + outputOffset + inputRecord.getExtraFieldStartOffsetInsideRecord(), + dataAlignmentMultiple); + long dataOffset = + (long) inputRecord.getDataStartOffsetInRecord() + + aligningExtra.remaining() + - inputRecord.getExtra().remaining(); + return new OutputSizeAndDataOffset( + inputRecord.outputRecordWithModifiedExtra( + inputLfhSection, aligningExtra, outputLfhSection), + dataOffset); + } + + private int getInputJarEntryDataAlignmentMultiple(LocalFileRecord entry) { + if (entry.isDataCompressed()) { + // Compressed entries don't need to be aligned + return 1; + } + + // Attempt to obtain the alignment multiple from the entry's extra field. + ByteBuffer extra = entry.getExtra(); + if (extra.hasRemaining()) { + extra.order(ByteOrder.LITTLE_ENDIAN); + // FORMAT: sequence of fields. Each field consists of: + // * uint16 ID + // * uint16 size + // * 'size' bytes: payload + while (extra.remaining() >= 4) { + short headerId = extra.getShort(); + int dataSize = ZipUtils.getUnsignedInt16(extra); + if (dataSize > extra.remaining()) { + // Malformed field -- insufficient input remaining + break; + } + if (headerId != ALIGNMENT_ZIP_EXTRA_DATA_FIELD_HEADER_ID) { + // Skip this field + extra.position(extra.position() + dataSize); + continue; + } + // This is APK alignment field. + // FORMAT: + // * uint16 alignment multiple (in bytes) + // * remaining bytes -- padding to achieve alignment of data which starts after + // the extra field + if (dataSize < 2) { + // Malformed + break; + } + return ZipUtils.getUnsignedInt16(extra); + } + } + + // Fall back to filename-based defaults + return (entry.getName().endsWith(".so")) ? mLibraryPageAlignmentBytes : 4; + } + + private static ByteBuffer createExtraFieldToAlignData( + ByteBuffer original, long extraStartOffset, int dataAlignmentMultiple) { + if (dataAlignmentMultiple <= 1) { + return original; + } + + // In the worst case scenario, we'll increase the output size by 6 + dataAlignment - 1. + ByteBuffer result = ByteBuffer.allocate(original.remaining() + 5 + dataAlignmentMultiple); + result.order(ByteOrder.LITTLE_ENDIAN); + + // Step 1. Output all extra fields other than the one which is to do with alignment + // FORMAT: sequence of fields. Each field consists of: + // * uint16 ID + // * uint16 size + // * 'size' bytes: payload + while (original.remaining() >= 4) { + short headerId = original.getShort(); + int dataSize = ZipUtils.getUnsignedInt16(original); + if (dataSize > original.remaining()) { + // Malformed field -- insufficient input remaining + break; + } + if (((headerId == 0) && (dataSize == 0)) + || (headerId == ALIGNMENT_ZIP_EXTRA_DATA_FIELD_HEADER_ID)) { + // Ignore the field if it has to do with the old APK data alignment method (filling + // the extra field with 0x00 bytes) or the new APK data alignment method. + original.position(original.position() + dataSize); + continue; + } + // Copy this field (including header) to the output + original.position(original.position() - 4); + int originalLimit = original.limit(); + original.limit(original.position() + 4 + dataSize); + result.put(original); + original.limit(originalLimit); + } + + // Step 2. Add alignment field + // FORMAT: + // * uint16 extra header ID + // * uint16 extra data size + // Payload ('data size' bytes) + // * uint16 alignment multiple (in bytes) + // * remaining bytes -- padding to achieve alignment of data which starts after the + // extra field + long dataMinStartOffset = + extraStartOffset + + result.position() + + ALIGNMENT_ZIP_EXTRA_DATA_FIELD_MIN_SIZE_BYTES; + int paddingSizeBytes = + (dataAlignmentMultiple - ((int) (dataMinStartOffset % dataAlignmentMultiple))) + % dataAlignmentMultiple; + result.putShort(ALIGNMENT_ZIP_EXTRA_DATA_FIELD_HEADER_ID); + ZipUtils.putUnsignedInt16(result, 2 + paddingSizeBytes); + ZipUtils.putUnsignedInt16(result, dataAlignmentMultiple); + result.position(result.position() + paddingSizeBytes); + result.flip(); + + return result; + } + + private static ByteBuffer getZipCentralDirectory( + DataSource apk, ApkUtils.ZipSections apkSections) + throws IOException, ApkFormatException { + long cdSizeBytes = apkSections.getZipCentralDirectorySizeBytes(); + if (cdSizeBytes > Integer.MAX_VALUE) { + throw new ApkFormatException("ZIP Central Directory too large: " + cdSizeBytes); + } + long cdOffset = apkSections.getZipCentralDirectoryOffset(); + ByteBuffer cd = apk.getByteBuffer(cdOffset, (int) cdSizeBytes); + cd.order(ByteOrder.LITTLE_ENDIAN); + return cd; + } + + private static List<CentralDirectoryRecord> parseZipCentralDirectory( + ByteBuffer cd, ApkUtils.ZipSections apkSections) throws ApkFormatException { + long cdOffset = apkSections.getZipCentralDirectoryOffset(); + int expectedCdRecordCount = apkSections.getZipCentralDirectoryRecordCount(); + List<CentralDirectoryRecord> cdRecords = new ArrayList<>(expectedCdRecordCount); + Set<String> entryNames = new HashSet<>(expectedCdRecordCount); + for (int i = 0; i < expectedCdRecordCount; i++) { + CentralDirectoryRecord cdRecord; + int offsetInsideCd = cd.position(); + try { + cdRecord = CentralDirectoryRecord.getRecord(cd); + } catch (ZipFormatException e) { + throw new ApkFormatException( + "Malformed ZIP Central Directory record #" + + (i + 1) + + " at file offset " + + (cdOffset + offsetInsideCd), + e); + } + String entryName = cdRecord.getName(); + if (!entryNames.add(entryName)) { + throw new ApkFormatException( + "Multiple ZIP entries with the same name: " + entryName); + } + cdRecords.add(cdRecord); + } + if (cd.hasRemaining()) { + throw new ApkFormatException( + "Unused space at the end of ZIP Central Directory: " + + cd.remaining() + + " bytes starting at file offset " + + (cdOffset + cd.position())); + } + + return cdRecords; + } + + private static CentralDirectoryRecord findCdRecord( + List<CentralDirectoryRecord> cdRecords, String name) { + for (CentralDirectoryRecord cdRecord : cdRecords) { + if (name.equals(cdRecord.getName())) { + return cdRecord; + } + } + return null; + } + + /** + * Returns the contents of the APK's {@code AndroidManifest.xml} or {@code null} if this entry + * is not present in the APK. + */ + static ByteBuffer getAndroidManifestFromApk( + List<CentralDirectoryRecord> cdRecords, DataSource lhfSection) + throws IOException, ApkFormatException, ZipFormatException { + CentralDirectoryRecord androidManifestCdRecord = + findCdRecord(cdRecords, ANDROID_MANIFEST_ZIP_ENTRY_NAME); + if (androidManifestCdRecord == null) { + throw new ApkFormatException("Missing " + ANDROID_MANIFEST_ZIP_ENTRY_NAME); + } + + return ByteBuffer.wrap( + LocalFileRecord.getUncompressedData( + lhfSection, androidManifestCdRecord, lhfSection.size())); + } + + /** + * Return list of pin patterns embedded in the pin pattern asset file. If no such file, return + * {@code null}. + */ + private static List<Hints.PatternWithRange> extractPinPatterns( + List<CentralDirectoryRecord> cdRecords, DataSource lhfSection) + throws IOException, ApkFormatException { + CentralDirectoryRecord pinListCdRecord = + findCdRecord(cdRecords, Hints.PIN_HINT_ASSET_ZIP_ENTRY_NAME); + List<Hints.PatternWithRange> pinPatterns = null; + if (pinListCdRecord != null) { + pinPatterns = new ArrayList<>(); + byte[] patternBlob; + try { + patternBlob = + LocalFileRecord.getUncompressedData( + lhfSection, pinListCdRecord, lhfSection.size()); + } catch (ZipFormatException ex) { + throw new ApkFormatException("Bad " + pinListCdRecord); + } + pinPatterns = Hints.parsePinPatterns(patternBlob); + } + return pinPatterns; + } + + /** + * Returns the minimum Android version (API Level) supported by the provided APK. This is based + * on the {@code android:minSdkVersion} attributes of the APK's {@code AndroidManifest.xml}. + */ + private static int getMinSdkVersionFromApk( + List<CentralDirectoryRecord> cdRecords, DataSource lhfSection) + throws IOException, MinSdkVersionException { + ByteBuffer androidManifest; + try { + androidManifest = getAndroidManifestFromApk(cdRecords, lhfSection); + } catch (ZipFormatException | ApkFormatException e) { + throw new MinSdkVersionException( + "Failed to determine APK's minimum supported Android platform version", e); + } + return ApkUtils.getMinSdkVersionFromBinaryAndroidManifest(androidManifest); + } + + /** + * Configuration of a signer. + * + * <p>Use {@link Builder} to obtain configuration instances. + */ + public static class SignerConfig { + private final String mName; + private final PrivateKey mPrivateKey; + private final List<X509Certificate> mCertificates; + private final boolean mDeterministicDsaSigning; + private final int mMinSdkVersion; + private final SigningCertificateLineage mSigningCertificateLineage; + + private SignerConfig(Builder builder) { + mName = builder.mName; + mPrivateKey = builder.mPrivateKey; + mCertificates = Collections.unmodifiableList(new ArrayList<>(builder.mCertificates)); + mDeterministicDsaSigning = builder.mDeterministicDsaSigning; + mMinSdkVersion = builder.mMinSdkVersion; + mSigningCertificateLineage = builder.mSigningCertificateLineage; + } + + /** Returns the name of this signer. */ + public String getName() { + return mName; + } + + /** Returns the signing key of this signer. */ + public PrivateKey getPrivateKey() { + return mPrivateKey; + } + + /** + * Returns the certificate(s) of this signer. The first certificate's public key corresponds + * to this signer's private key. + */ + public List<X509Certificate> getCertificates() { + return mCertificates; + } + + /** + * If this signer is a DSA signer, whether or not the signing is done deterministically. + */ + public boolean getDeterministicDsaSigning() { + return mDeterministicDsaSigning; + } + + /** Returns the minimum SDK version for which this signer should be used. */ + public int getMinSdkVersion() { + return mMinSdkVersion; + } + + /** Returns the {@link SigningCertificateLineage} for this signer. */ + public SigningCertificateLineage getSigningCertificateLineage() { + return mSigningCertificateLineage; + } + + /** Builder of {@link SignerConfig} instances. */ + public static class Builder { + private final String mName; + private final PrivateKey mPrivateKey; + private final List<X509Certificate> mCertificates; + private final boolean mDeterministicDsaSigning; + + private int mMinSdkVersion; + private SigningCertificateLineage mSigningCertificateLineage; + + /** + * Constructs a new {@code Builder}. + * + * @param name signer's name. The name is reflected in the name of files comprising the + * JAR signature of the APK. + * @param privateKey signing key + * @param certificates list of one or more X.509 certificates. The subject public key of + * the first certificate must correspond to the {@code privateKey}. + */ + public Builder( + String name, + PrivateKey privateKey, + List<X509Certificate> certificates) { + this(name, privateKey, certificates, false); + } + + /** + * Constructs a new {@code Builder}. + * + * @param name signer's name. The name is reflected in the name of files comprising the + * JAR signature of the APK. + * @param privateKey signing key + * @param certificates list of one or more X.509 certificates. The subject public key of + * the first certificate must correspond to the {@code privateKey}. + * @param deterministicDsaSigning When signing using DSA, whether or not the + * deterministic variant (RFC6979) should be used. + */ + public Builder( + String name, + PrivateKey privateKey, + List<X509Certificate> certificates, + boolean deterministicDsaSigning) { + if (name.isEmpty()) { + throw new IllegalArgumentException("Empty name"); + } + mName = name; + mPrivateKey = privateKey; + mCertificates = new ArrayList<>(certificates); + mDeterministicDsaSigning = deterministicDsaSigning; + } + + /** @see #setLineageForMinSdkVersion(SigningCertificateLineage, int) */ + public Builder setMinSdkVersion(int minSdkVersion) { + return setLineageForMinSdkVersion(null, minSdkVersion); + } + + /** + * Sets the specified {@code minSdkVersion} as the minimum Android platform version + * (API level) for which the provided {@code lineage} (where applicable) should be used + * to produce the APK's signature. This method is useful if callers want to specify a + * particular rotated signer or lineage with restricted capabilities for later + * platform releases. + * + * <p><em>Note:</em>>The V1 and V2 signature schemes do not support key rotation and + * signing lineages with capabilities; only an app's original signer(s) can be used for + * the V1 and V2 signature blocks. Because of this, only a value of {@code + * minSdkVersion} >= 28 (Android P) where support for the V3 signature scheme was + * introduced can be specified. + * + * <p><em>Note:</em>Due to limitations with platform targeting in the V3.0 signature + * scheme, specifying a {@code minSdkVersion} value <= 32 (Android Sv2) will result in + * the current {@code SignerConfig} being used in the V3.0 signing block and applied to + * Android P through at least Sv2 (and later depending on the {@code minSdkVersion} for + * subsequent {@code SignerConfig} instances). Because of this, only a single {@code + * SignerConfig} can be instantiated with a minimum SDK version <= 32. + * + * @param lineage the {@code SigningCertificateLineage} to target the specified {@code + * minSdkVersion} + * @param minSdkVersion the minimum SDK version for which this {@code SignerConfig} + * should be used + * @return this {@code Builder} instance + * + * @throws IllegalArgumentException if the provided {@code minSdkVersion} < 28 or the + * certificate provided in the constructor is not in the specified {@code lineage}. + */ + public Builder setLineageForMinSdkVersion(SigningCertificateLineage lineage, + int minSdkVersion) { + if (minSdkVersion < AndroidSdkVersion.P) { + throw new IllegalArgumentException( + "SDK targeted signing config is only supported with the V3 signature " + + "scheme on Android P (SDK version " + + AndroidSdkVersion.P + ") and later"); + } + if (minSdkVersion < MIN_SDK_WITH_V31_SUPPORT) { + minSdkVersion = AndroidSdkVersion.P; + } + mMinSdkVersion = minSdkVersion; + // If a lineage is provided, ensure the signing certificate for this signer is in + // the lineage; in the case of multiple signing certificates, the first is always + // used in the lineage. + if (lineage != null && !lineage.isCertificateInLineage(mCertificates.get(0))) { + throw new IllegalArgumentException( + "The provided lineage does not contain the signing certificate, " + + mCertificates.get(0).getSubjectDN() + + ", for this SignerConfig"); + } + mSigningCertificateLineage = lineage; + return this; + } + + /** + * Returns a new {@code SignerConfig} instance configured based on the configuration of + * this builder. + */ + public SignerConfig build() { + return new SignerConfig(this); + } + } + } + + /** + * Builder of {@link ApkSigner} instances. + * + * <p>The builder requires the following information to construct a working {@code ApkSigner}: + * + * <ul> + * <li>Signer configs or {@link ApkSignerEngine} -- provided in the constructor, + * <li>APK to be signed -- see {@link #setInputApk(File) setInputApk} variants, + * <li>where to store the output signed APK -- see {@link #setOutputApk(File) setOutputApk} + * variants. + * </ul> + */ + public static class Builder { + private final List<SignerConfig> mSignerConfigs; + private SignerConfig mSourceStampSignerConfig; + private SigningCertificateLineage mSourceStampSigningCertificateLineage; + private boolean mForceSourceStampOverwrite = false; + private boolean mSourceStampTimestampEnabled = true; + private boolean mV1SigningEnabled = true; + private boolean mV2SigningEnabled = true; + private boolean mV3SigningEnabled = true; + private boolean mV4SigningEnabled = true; + private boolean mAlignFileSize = false; + private boolean mVerityEnabled = false; + private boolean mV4ErrorReportingEnabled = false; + private boolean mDebuggableApkPermitted = true; + private boolean mOtherSignersSignaturesPreserved; + private boolean mAlignmentPreserved = false; + private int mLibraryPageAlignmentBytes = LIBRARY_PAGE_ALIGNMENT_BYTES; + private String mCreatedBy; + private Integer mMinSdkVersion; + private int mRotationMinSdkVersion = V3SchemeConstants.DEFAULT_ROTATION_MIN_SDK_VERSION; + private boolean mRotationTargetsDevRelease = false; + + private final ApkSignerEngine mSignerEngine; + + private File mInputApkFile; + private DataSource mInputApkDataSource; + + private File mOutputApkFile; + private DataSink mOutputApkDataSink; + private DataSource mOutputApkDataSource; + + private File mOutputV4File; + + private SigningCertificateLineage mSigningCertificateLineage; + + // APK Signature Scheme v3 only supports a single signing certificate, so to move to v3 + // signing by default, but not require prior clients to update to explicitly disable v3 + // signing for multiple signers, we modify the mV3SigningEnabled depending on the provided + // inputs (multiple signers and mSigningCertificateLineage in particular). Maintain two + // extra variables to record whether or not mV3SigningEnabled has been set directly by a + // client and so should override the default behavior. + private boolean mV3SigningExplicitlyDisabled = false; + private boolean mV3SigningExplicitlyEnabled = false; + + /** + * Constructs a new {@code Builder} for an {@code ApkSigner} which signs using the provided + * signer configurations. The resulting signer may be further customized through this + * builder's setters, such as {@link #setMinSdkVersion(int)}, {@link + * #setV1SigningEnabled(boolean)}, {@link #setV2SigningEnabled(boolean)}, {@link + * #setOtherSignersSignaturesPreserved(boolean)}, {@link #setCreatedBy(String)}. + * + * <p>{@link #Builder(ApkSignerEngine)} is an alternative for advanced use cases where more + * control over low-level details of signing is desired. + */ + public Builder(List<SignerConfig> signerConfigs) { + if (signerConfigs.isEmpty()) { + throw new IllegalArgumentException("At least one signer config must be provided"); + } + if (signerConfigs.size() > 1) { + // APK Signature Scheme v3 only supports single signer, unless a + // SigningCertificateLineage is provided, in which case this will be reset to true, + // since we don't yet have a v4 scheme about which to worry + mV3SigningEnabled = false; + } + mSignerConfigs = new ArrayList<>(signerConfigs); + mSignerEngine = null; + } + + /** + * Constructs a new {@code Builder} for an {@code ApkSigner} which signs using the provided + * signing engine. This is meant for advanced use cases where more control is needed over + * the lower-level details of signing. For typical use cases, {@link #Builder(List)} is more + * appropriate. + */ + public Builder(ApkSignerEngine signerEngine) { + if (signerEngine == null) { + throw new NullPointerException("signerEngine == null"); + } + mSignerEngine = signerEngine; + mSignerConfigs = null; + } + + /** Sets the signing configuration of the source stamp to be embedded in the APK. */ + public Builder setSourceStampSignerConfig(SignerConfig sourceStampSignerConfig) { + mSourceStampSignerConfig = sourceStampSignerConfig; + return this; + } + + /** + * Sets the source stamp {@link SigningCertificateLineage}. This structure provides proof of + * signing certificate rotation for certificates previously used to sign source stamps. + */ + public Builder setSourceStampSigningCertificateLineage( + SigningCertificateLineage sourceStampSigningCertificateLineage) { + mSourceStampSigningCertificateLineage = sourceStampSigningCertificateLineage; + return this; + } + + /** + * Sets whether the APK should overwrite existing source stamp, if found. + * + * @param force {@code true} to require the APK to be overwrite existing source stamp + */ + public Builder setForceSourceStampOverwrite(boolean force) { + mForceSourceStampOverwrite = force; + return this; + } + + /** + * Sets whether the source stamp should contain the timestamp attribute with the time + * at which the source stamp was signed. + */ + public Builder setSourceStampTimestampEnabled(boolean value) { + mSourceStampTimestampEnabled = value; + return this; + } + + /** + * Sets the APK to be signed. + * + * @see #setInputApk(DataSource) + */ + public Builder setInputApk(File inputApk) { + if (inputApk == null) { + throw new NullPointerException("inputApk == null"); + } + mInputApkFile = inputApk; + mInputApkDataSource = null; + return this; + } + + /** + * Sets the APK to be signed. + * + * @see #setInputApk(File) + */ + public Builder setInputApk(DataSource inputApk) { + if (inputApk == null) { + throw new NullPointerException("inputApk == null"); + } + mInputApkDataSource = inputApk; + mInputApkFile = null; + return this; + } + + /** + * Sets the location of the output (signed) APK. {@code ApkSigner} will create this file if + * it doesn't exist. + * + * @see #setOutputApk(ReadableDataSink) + * @see #setOutputApk(DataSink, DataSource) + */ + public Builder setOutputApk(File outputApk) { + if (outputApk == null) { + throw new NullPointerException("outputApk == null"); + } + mOutputApkFile = outputApk; + mOutputApkDataSink = null; + mOutputApkDataSource = null; + return this; + } + + /** + * Sets the readable data sink which will receive the output (signed) APK. After signing, + * the contents of the output APK will be available via the {@link DataSource} interface of + * the sink. + * + * <p>This variant of {@code setOutputApk} is useful for avoiding writing the output APK to + * a file. For example, an in-memory data sink, such as {@link + * DataSinks#newInMemoryDataSink()}, could be used instead of a file. + * + * @see #setOutputApk(File) + * @see #setOutputApk(DataSink, DataSource) + */ + public Builder setOutputApk(ReadableDataSink outputApk) { + if (outputApk == null) { + throw new NullPointerException("outputApk == null"); + } + return setOutputApk(outputApk, outputApk); + } + + /** + * Sets the sink which will receive the output (signed) APK. Data received by the {@code + * outputApkOut} sink must be visible through the {@code outputApkIn} data source. + * + * <p>This is an advanced variant of {@link #setOutputApk(ReadableDataSink)}, enabling the + * sink and the source to be different objects. + * + * @see #setOutputApk(ReadableDataSink) + * @see #setOutputApk(File) + */ + public Builder setOutputApk(DataSink outputApkOut, DataSource outputApkIn) { + if (outputApkOut == null) { + throw new NullPointerException("outputApkOut == null"); + } + if (outputApkIn == null) { + throw new NullPointerException("outputApkIn == null"); + } + mOutputApkFile = null; + mOutputApkDataSink = outputApkOut; + mOutputApkDataSource = outputApkIn; + return this; + } + + /** + * Sets the location of the V4 output file. {@code ApkSigner} will create this file if it + * doesn't exist. + */ + public Builder setV4SignatureOutputFile(File v4SignatureOutputFile) { + if (v4SignatureOutputFile == null) { + throw new NullPointerException("v4HashRootOutputFile == null"); + } + mOutputV4File = v4SignatureOutputFile; + return this; + } + + /** + * Sets the minimum Android platform version (API Level) on which APK signatures produced by + * the signer being built must verify. This method is useful for overriding the default + * behavior where the minimum API Level is obtained from the {@code android:minSdkVersion} + * attribute of the APK's {@code AndroidManifest.xml}. + * + * <p><em>Note:</em> This method may result in APK signatures which don't verify on some + * Android platform versions supported by the APK. + * + * <p><em>Note:</em> This method may only be invoked when this builder is not initialized + * with an {@link ApkSignerEngine}. + * + * @throws IllegalStateException if this builder was initialized with an {@link + * ApkSignerEngine} + */ + public Builder setMinSdkVersion(int minSdkVersion) { + checkInitializedWithoutEngine(); + mMinSdkVersion = minSdkVersion; + return this; + } + + /** + * Sets the minimum Android platform version (API Level) for which an APK's rotated signing + * key should be used to produce the APK's signature. The original signing key for the APK + * will be used for all previous platform versions. If a rotated key with signing lineage is + * not provided then this method is a noop. This method is useful for overriding the + * default behavior where Android T is set as the minimum API level for rotation. + * + * <p><em>Note:</em>Specifying a {@code minSdkVersion} value <= 32 (Android Sv2) will result + * in the original V3 signing block being used without platform targeting. + * + * <p><em>Note:</em> This method may only be invoked when this builder is not initialized + * with an {@link ApkSignerEngine}. + * + * @throws IllegalStateException if this builder was initialized with an {@link + * ApkSignerEngine} + */ + public Builder setMinSdkVersionForRotation(int minSdkVersion) { + checkInitializedWithoutEngine(); + // If the provided SDK version does not support v3.1, then use the default SDK version + // with rotation support. + if (minSdkVersion < MIN_SDK_WITH_V31_SUPPORT) { + mRotationMinSdkVersion = MIN_SDK_WITH_V3_SUPPORT; + } else { + mRotationMinSdkVersion = minSdkVersion; + } + return this; + } + + /** + * Sets whether the rotation-min-sdk-version is intended to target a development release; + * this is primarily required after the T SDK is finalized, and an APK needs to target U + * during its development cycle for rotation. + * + * <p>This is only required after the T SDK is finalized since S and earlier releases do + * not know about the V3.1 block ID, but once T is released and work begins on U, U will + * use the SDK version of T during development. Specifying a rotation-min-sdk-version of T's + * SDK version along with setting {@code enabled} to true will allow an APK to use the + * rotated key on a device running U while causing this to be bypassed for T. + * + * <p><em>Note:</em>If the rotation-min-sdk-version is less than or equal to 32 (Android + * Sv2), then the rotated signing key will be used in the v3.0 signing block and this call + * will be a noop. + * + * <p><em>Note:</em> This method may only be invoked when this builder is not initialized + * with an {@link ApkSignerEngine}. + */ + public Builder setRotationTargetsDevRelease(boolean enabled) { + checkInitializedWithoutEngine(); + mRotationTargetsDevRelease = enabled; + return this; + } + + /** + * Sets whether the APK should be signed using JAR signing (aka v1 signature scheme). + * + * <p>By default, whether APK is signed using JAR signing is determined by {@code + * ApkSigner}, based on the platform versions supported by the APK or specified using {@link + * #setMinSdkVersion(int)}. Disabling JAR signing will result in APK signatures which don't + * verify on Android Marshmallow (Android 6.0, API Level 23) and lower. + * + * <p><em>Note:</em> This method may only be invoked when this builder is not initialized + * with an {@link ApkSignerEngine}. + * + * @param enabled {@code true} to require the APK to be signed using JAR signing, {@code + * false} to require the APK to not be signed using JAR signing. + * @throws IllegalStateException if this builder was initialized with an {@link + * ApkSignerEngine} + * @see <a + * href="https://docs.oracle.com/javase/8/docs/technotes/guides/jar/jar.html#Signed_JAR_File">JAR + * signing</a> + */ + public Builder setV1SigningEnabled(boolean enabled) { + checkInitializedWithoutEngine(); + mV1SigningEnabled = enabled; + return this; + } + + /** + * Sets whether the APK should be signed using APK Signature Scheme v2 (aka v2 signature + * scheme). + * + * <p>By default, whether APK is signed using APK Signature Scheme v2 is determined by + * {@code ApkSigner} based on the platform versions supported by the APK or specified using + * {@link #setMinSdkVersion(int)}. + * + * <p><em>Note:</em> This method may only be invoked when this builder is not initialized + * with an {@link ApkSignerEngine}. + * + * @param enabled {@code true} to require the APK to be signed using APK Signature Scheme + * v2, {@code false} to require the APK to not be signed using APK Signature Scheme v2. + * @throws IllegalStateException if this builder was initialized with an {@link + * ApkSignerEngine} + * @see <a href="https://source.android.com/security/apksigning/v2.html">APK Signature + * Scheme v2</a> + */ + public Builder setV2SigningEnabled(boolean enabled) { + checkInitializedWithoutEngine(); + mV2SigningEnabled = enabled; + return this; + } + + /** + * Sets whether the APK should be signed using APK Signature Scheme v3 (aka v3 signature + * scheme). + * + * <p>By default, whether APK is signed using APK Signature Scheme v3 is determined by + * {@code ApkSigner} based on the platform versions supported by the APK or specified using + * {@link #setMinSdkVersion(int)}. + * + * <p><em>Note:</em> This method may only be invoked when this builder is not initialized + * with an {@link ApkSignerEngine}. + * + * <p><em>Note:</em> APK Signature Scheme v3 only supports a single signing certificate, but + * may take multiple signers mapping to different targeted platform versions. + * + * @param enabled {@code true} to require the APK to be signed using APK Signature Scheme + * v3, {@code false} to require the APK to not be signed using APK Signature Scheme v3. + * @throws IllegalStateException if this builder was initialized with an {@link + * ApkSignerEngine} + */ + public Builder setV3SigningEnabled(boolean enabled) { + checkInitializedWithoutEngine(); + mV3SigningEnabled = enabled; + if (enabled) { + mV3SigningExplicitlyEnabled = true; + } else { + mV3SigningExplicitlyDisabled = true; + } + return this; + } + + /** + * Sets whether the APK should be signed using APK Signature Scheme v4. + * + * <p>V4 signing requires that the APK be v2 or v3 signed. + * + * @param enabled {@code true} to require the APK to be signed using APK Signature Scheme v2 + * or v3 and generate an v4 signature file + */ + public Builder setV4SigningEnabled(boolean enabled) { + checkInitializedWithoutEngine(); + mV4SigningEnabled = enabled; + mV4ErrorReportingEnabled = enabled; + return this; + } + + /** + * Sets whether errors during v4 signing should be reported and halt the signing process. + * + * <p>Error reporting for v4 signing is disabled by default, but will be enabled if the + * caller invokes {@link #setV4SigningEnabled} with a value of true. This method is useful + * for tools that enable v4 signing by default but don't want to fail the signing process if + * the user did not explicitly request the v4 signing. + * + * @param enabled {@code false} to prevent errors encountered during the V4 signing from + * halting the signing process + */ + public Builder setV4ErrorReportingEnabled(boolean enabled) { + checkInitializedWithoutEngine(); + mV4ErrorReportingEnabled = enabled; + return this; + } + + /** + * Sets whether the output APK files should be sized as multiples of 4K. + * + * <p><em>Note:</em> This method may only be invoked when this builder is not initialized + * with an {@link ApkSignerEngine}. + * + * @throws IllegalStateException if this builder was initialized with an {@link + * ApkSignerEngine} + */ + public Builder setAlignFileSize(boolean alignFileSize) { + checkInitializedWithoutEngine(); + mAlignFileSize = alignFileSize; + return this; + } + + /** + * Sets whether to enable the verity signature algorithm for the v2 and v3 signature + * schemes. + * + * @param enabled {@code true} to enable the verity signature algorithm for inclusion in the + * v2 and v3 signature blocks. + */ + public Builder setVerityEnabled(boolean enabled) { + checkInitializedWithoutEngine(); + mVerityEnabled = enabled; + return this; + } + + /** + * Sets whether the APK should be signed even if it is marked as debuggable ({@code + * android:debuggable="true"} in its {@code AndroidManifest.xml}). For backward + * compatibility reasons, the default value of this setting is {@code true}. + * + * <p>It is dangerous to sign debuggable APKs with production/release keys because Android + * platform loosens security checks for such APKs. For example, arbitrary unauthorized code + * may be executed in the context of such an app by anybody with ADB shell access. + * + * <p><em>Note:</em> This method may only be invoked when this builder is not initialized + * with an {@link ApkSignerEngine}. + */ + public Builder setDebuggableApkPermitted(boolean permitted) { + checkInitializedWithoutEngine(); + mDebuggableApkPermitted = permitted; + return this; + } + + /** + * Sets whether signatures produced by signers other than the ones configured in this engine + * should be copied from the input APK to the output APK. + * + * <p>By default, signatures of other signers are omitted from the output APK. + * + * <p><em>Note:</em> This method may only be invoked when this builder is not initialized + * with an {@link ApkSignerEngine}. + * + * @throws IllegalStateException if this builder was initialized with an {@link + * ApkSignerEngine} + */ + public Builder setOtherSignersSignaturesPreserved(boolean preserved) { + checkInitializedWithoutEngine(); + mOtherSignersSignaturesPreserved = preserved; + return this; + } + + /** + * Sets the value of the {@code Created-By} field in JAR signature files. + * + * <p><em>Note:</em> This method may only be invoked when this builder is not initialized + * with an {@link ApkSignerEngine}. + * + * @throws IllegalStateException if this builder was initialized with an {@link + * ApkSignerEngine} + */ + public Builder setCreatedBy(String createdBy) { + checkInitializedWithoutEngine(); + if (createdBy == null) { + throw new NullPointerException(); + } + mCreatedBy = createdBy; + return this; + } + + private void checkInitializedWithoutEngine() { + if (mSignerEngine != null) { + throw new IllegalStateException( + "Operation is not available when builder initialized with an engine"); + } + } + + /** + * Sets the {@link SigningCertificateLineage} to use with the v3 signature scheme. This + * structure provides proof of signing certificate rotation linking {@link SignerConfig} + * objects to previous ones. + */ + public Builder setSigningCertificateLineage( + SigningCertificateLineage signingCertificateLineage) { + if (signingCertificateLineage != null) { + mV3SigningEnabled = true; + mSigningCertificateLineage = signingCertificateLineage; + } + return this; + } + + /** + * Sets whether the existing alignment within the APK should be preserved; the + * default for this setting is false. When this value is false, the value provided to + * {@link #setLibraryPageAlignmentBytes(int)} will be used to page align native library + * files and 4 bytes will be used to align all other uncompressed files. + */ + public Builder setAlignmentPreserved(boolean alignmentPreserved) { + mAlignmentPreserved = alignmentPreserved; + return this; + } + + /** + * Sets the number of bytes to be used to page align native library files in the APK; the + * default for this setting is {@link Constants#LIBRARY_PAGE_ALIGNMENT_BYTES}. + */ + public Builder setLibraryPageAlignmentBytes(int libraryPageAlignmentBytes) { + mLibraryPageAlignmentBytes = libraryPageAlignmentBytes; + return this; + } + + /** + * Returns a new {@code ApkSigner} instance initialized according to the configuration of + * this builder. + */ + public ApkSigner build() { + if (mV3SigningExplicitlyDisabled && mV3SigningExplicitlyEnabled) { + throw new IllegalStateException( + "Builder configured to both enable and disable APK " + + "Signature Scheme v3 signing"); + } + + if (mV3SigningExplicitlyDisabled) { + mV3SigningEnabled = false; + } + + if (mV3SigningExplicitlyEnabled) { + mV3SigningEnabled = true; + } + + // If V4 signing is not explicitly set, and V2/V3 signing is disabled, then V4 signing + // must be disabled as well as it is dependent on V2/V3. + if (mV4SigningEnabled && !mV2SigningEnabled && !mV3SigningEnabled) { + if (!mV4ErrorReportingEnabled) { + mV4SigningEnabled = false; + } else { + throw new IllegalStateException( + "APK Signature Scheme v4 signing requires at least " + + "v2 or v3 signing to be enabled"); + } + } + + // TODO - if v3 signing is enabled, check provided signers and history to see if valid + + return new ApkSigner( + mSignerConfigs, + mSourceStampSignerConfig, + mSourceStampSigningCertificateLineage, + mForceSourceStampOverwrite, + mSourceStampTimestampEnabled, + mMinSdkVersion, + mRotationMinSdkVersion, + mRotationTargetsDevRelease, + mV1SigningEnabled, + mV2SigningEnabled, + mV3SigningEnabled, + mV4SigningEnabled, + mAlignFileSize, + mVerityEnabled, + mV4ErrorReportingEnabled, + mDebuggableApkPermitted, + mOtherSignersSignaturesPreserved, + mAlignmentPreserved, + mLibraryPageAlignmentBytes, + mCreatedBy, + mSignerEngine, + mInputApkFile, + mInputApkDataSource, + mOutputApkFile, + mOutputApkDataSink, + mOutputApkDataSource, + mOutputV4File, + mSigningCertificateLineage); + } + } +} diff --git a/platform/android/java/editor/src/main/java/com/android/apksig/ApkSignerEngine.java b/platform/android/java/editor/src/main/java/com/android/apksig/ApkSignerEngine.java new file mode 100644 index 0000000000..c79f232707 --- /dev/null +++ b/platform/android/java/editor/src/main/java/com/android/apksig/ApkSignerEngine.java @@ -0,0 +1,550 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.apksig; + +import com.android.apksig.apk.ApkFormatException; +import com.android.apksig.util.DataSink; +import com.android.apksig.util.DataSource; +import com.android.apksig.util.RunnablesExecutor; + +import java.io.Closeable; +import java.io.File; +import java.io.IOException; +import java.security.InvalidKeyException; +import java.security.NoSuchAlgorithmException; +import java.security.SignatureException; +import java.util.List; +import java.util.Set; + +/** + * APK signing logic which is independent of how input and output APKs are stored, parsed, and + * generated. + * + * <p><h3>Operating Model</h3> + * + * The abstract operating model is that there is an input APK which is being signed, thus producing + * an output APK. In reality, there may be just an output APK being built from scratch, or the input + * APK and the output APK may be the same file. Because this engine does not deal with reading and + * writing files, it can handle all of these scenarios. + * + * <p>The engine is stateful and thus cannot be used for signing multiple APKs. However, once + * the engine signed an APK, the engine can be used to re-sign the APK after it has been modified. + * This may be more efficient than signing the APK using a new instance of the engine. See + * <a href="#incremental">Incremental Operation</a>. + * + * <p>In the engine's operating model, a signed APK is produced as follows. + * <ol> + * <li>JAR entries to be signed are output,</li> + * <li>JAR archive is signed using JAR signing, thus adding the so-called v1 signature to the + * output,</li> + * <li>JAR archive is signed using APK Signature Scheme v2, thus adding the so-called v2 signature + * to the output.</li> + * </ol> + * + * <p>The input APK may contain JAR entries which, depending on the engine's configuration, may or + * may not be output (e.g., existing signatures may need to be preserved or stripped) or which the + * engine will overwrite as part of signing. The engine thus offers {@link #inputJarEntry(String)} + * which tells the client whether the input JAR entry needs to be output. This avoids the need for + * the client to hard-code the aspects of APK signing which determine which parts of input must be + * ignored. Similarly, the engine offers {@link #inputApkSigningBlock(DataSource)} to help the + * client avoid dealing with preserving or stripping APK Signature Scheme v2 signature of the input + * APK. + * + * <p>To use the engine to sign an input APK (or a collection of JAR entries), follow these + * steps: + * <ol> + * <li>Obtain a new instance of the engine -- engine instances are stateful and thus cannot be used + * for signing multiple APKs.</li> + * <li>Locate the input APK's APK Signing Block and provide it to + * {@link #inputApkSigningBlock(DataSource)}.</li> + * <li>For each JAR entry in the input APK, invoke {@link #inputJarEntry(String)} to determine + * whether this entry should be output. The engine may request to inspect the entry.</li> + * <li>For each output JAR entry, invoke {@link #outputJarEntry(String)} which may request to + * inspect the entry.</li> + * <li>Once all JAR entries have been output, invoke {@link #outputJarEntries()} which may request + * that additional JAR entries are output. These entries comprise the output APK's JAR + * signature.</li> + * <li>Locate the ZIP Central Directory and ZIP End of Central Directory sections in the output and + * invoke {@link #outputZipSections2(DataSource, DataSource, DataSource)} which may request that + * an APK Signature Block is inserted before the ZIP Central Directory. The block contains the + * output APK's APK Signature Scheme v2 signature.</li> + * <li>Invoke {@link #outputDone()} to signal that the APK was output in full. The engine will + * confirm that the output APK is signed.</li> + * <li>Invoke {@link #close()} to signal that the engine will no longer be used. This lets the + * engine free any resources it no longer needs. + * </ol> + * + * <p>Some invocations of the engine may provide the client with a task to perform. The client is + * expected to perform all requested tasks before proceeding to the next stage of signing. See + * documentation of each method about the deadlines for performing the tasks requested by the + * method. + * + * <p><h3 id="incremental">Incremental Operation</h3></a> + * + * The engine supports incremental operation where a signed APK is produced, then modified and + * re-signed. This may be useful for IDEs, where an app is frequently re-signed after small changes + * by the developer. Re-signing may be more efficient than signing from scratch. + * + * <p>To use the engine in incremental mode, keep notifying the engine of changes to the APK through + * {@link #inputApkSigningBlock(DataSource)}, {@link #inputJarEntry(String)}, + * {@link #inputJarEntryRemoved(String)}, {@link #outputJarEntry(String)}, + * and {@link #outputJarEntryRemoved(String)}, perform the tasks requested by the engine through + * these methods, and, when a new signed APK is desired, run through steps 5 onwards to re-sign the + * APK. + * + * <p><h3>Output-only Operation</h3> + * + * The engine's abstract operating model consists of an input APK and an output APK. However, it is + * possible to use the engine in output-only mode where the engine's {@code input...} methods are + * not invoked. In this mode, the engine has less control over output because it cannot request that + * some JAR entries are not output. Nevertheless, the engine will attempt to make the output APK + * signed and will report an error if cannot do so. + * + * @see <a href="https://source.android.com/security/apksigning/index.html">Application Signing</a> + */ +public interface ApkSignerEngine extends Closeable { + + default void setExecutor(RunnablesExecutor executor) { + throw new UnsupportedOperationException("setExecutor method is not implemented"); + } + + /** + * Initializes the signer engine with the data already present in the apk (if any). There + * might already be data that can be reused if the entries has not been changed. + * + * @param manifestBytes + * @param entryNames + * @return set of entry names which were processed by the engine during the initialization, a + * subset of entryNames + */ + default Set<String> initWith(byte[] manifestBytes, Set<String> entryNames) { + throw new UnsupportedOperationException("initWith method is not implemented"); + } + + /** + * Indicates to this engine that the input APK contains the provided APK Signing Block. The + * block may contain signatures of the input APK, such as APK Signature Scheme v2 signatures. + * + * @param apkSigningBlock APK signing block of the input APK. The provided data source is + * guaranteed to not be used by the engine after this method terminates. + * + * @throws IOException if an I/O error occurs while reading the APK Signing Block + * @throws ApkFormatException if the APK Signing Block is malformed + * @throws IllegalStateException if this engine is closed + */ + void inputApkSigningBlock(DataSource apkSigningBlock) + throws IOException, ApkFormatException, IllegalStateException; + + /** + * Indicates to this engine that the specified JAR entry was encountered in the input APK. + * + * <p>When an input entry is updated/changed, it's OK to not invoke + * {@link #inputJarEntryRemoved(String)} before invoking this method. + * + * @return instructions about how to proceed with this entry + * + * @throws IllegalStateException if this engine is closed + */ + InputJarEntryInstructions inputJarEntry(String entryName) throws IllegalStateException; + + /** + * Indicates to this engine that the specified JAR entry was output. + * + * <p>It is unnecessary to invoke this method for entries added to output by this engine (e.g., + * requested by {@link #outputJarEntries()}) provided the entries were output with exactly the + * data requested by the engine. + * + * <p>When an already output entry is updated/changed, it's OK to not invoke + * {@link #outputJarEntryRemoved(String)} before invoking this method. + * + * @return request to inspect the entry or {@code null} if the engine does not need to inspect + * the entry. The request must be fulfilled before {@link #outputJarEntries()} is + * invoked. + * + * @throws IllegalStateException if this engine is closed + */ + InspectJarEntryRequest outputJarEntry(String entryName) throws IllegalStateException; + + /** + * Indicates to this engine that the specified JAR entry was removed from the input. It's safe + * to invoke this for entries for which {@link #inputJarEntry(String)} hasn't been invoked. + * + * @return output policy of this JAR entry. The policy indicates how this input entry affects + * the output APK. The client of this engine should use this information to determine + * how the removal of this input APK's JAR entry affects the output APK. + * + * @throws IllegalStateException if this engine is closed + */ + InputJarEntryInstructions.OutputPolicy inputJarEntryRemoved(String entryName) + throws IllegalStateException; + + /** + * Indicates to this engine that the specified JAR entry was removed from the output. It's safe + * to invoke this for entries for which {@link #outputJarEntry(String)} hasn't been invoked. + * + * @throws IllegalStateException if this engine is closed + */ + void outputJarEntryRemoved(String entryName) throws IllegalStateException; + + /** + * Indicates to this engine that all JAR entries have been output. + * + * @return request to add JAR signature to the output or {@code null} if there is no need to add + * a JAR signature. The request will contain additional JAR entries to be output. The + * request must be fulfilled before + * {@link #outputZipSections2(DataSource, DataSource, DataSource)} is invoked. + * + * @throws ApkFormatException if the APK is malformed in a way which is preventing this engine + * from producing a valid signature. For example, if the engine uses the provided + * {@code META-INF/MANIFEST.MF} as a template and the file is malformed. + * @throws NoSuchAlgorithmException if a signature could not be generated because a required + * cryptographic algorithm implementation is missing + * @throws InvalidKeyException if a signature could not be generated because a signing key is + * not suitable for generating the signature + * @throws SignatureException if an error occurred while generating a signature + * @throws IllegalStateException if there are unfulfilled requests, such as to inspect some JAR + * entries, or if the engine is closed + */ + OutputJarSignatureRequest outputJarEntries() + throws ApkFormatException, NoSuchAlgorithmException, InvalidKeyException, + SignatureException, IllegalStateException; + + /** + * Indicates to this engine that the ZIP sections comprising the output APK have been output. + * + * <p>The provided data sources are guaranteed to not be used by the engine after this method + * terminates. + * + * @deprecated This is now superseded by {@link #outputZipSections2(DataSource, DataSource, + * DataSource)}. + * + * @param zipEntries the section of ZIP archive containing Local File Header records and data of + * the ZIP entries. In a well-formed archive, this section starts at the start of the + * archive and extends all the way to the ZIP Central Directory. + * @param zipCentralDirectory ZIP Central Directory section + * @param zipEocd ZIP End of Central Directory (EoCD) record + * + * @return request to add an APK Signing Block to the output or {@code null} if the output must + * not contain an APK Signing Block. The request must be fulfilled before + * {@link #outputDone()} is invoked. + * + * @throws IOException if an I/O error occurs while reading the provided ZIP sections + * @throws ApkFormatException if the provided APK is malformed in a way which prevents this + * engine from producing a valid signature. For example, if the APK Signing Block + * provided to the engine is malformed. + * @throws NoSuchAlgorithmException if a signature could not be generated because a required + * cryptographic algorithm implementation is missing + * @throws InvalidKeyException if a signature could not be generated because a signing key is + * not suitable for generating the signature + * @throws SignatureException if an error occurred while generating a signature + * @throws IllegalStateException if there are unfulfilled requests, such as to inspect some JAR + * entries or to output JAR signature, or if the engine is closed + */ + @Deprecated + OutputApkSigningBlockRequest outputZipSections( + DataSource zipEntries, + DataSource zipCentralDirectory, + DataSource zipEocd) + throws IOException, ApkFormatException, NoSuchAlgorithmException, + InvalidKeyException, SignatureException, IllegalStateException; + + /** + * Indicates to this engine that the ZIP sections comprising the output APK have been output. + * + * <p>The provided data sources are guaranteed to not be used by the engine after this method + * terminates. + * + * @param zipEntries the section of ZIP archive containing Local File Header records and data of + * the ZIP entries. In a well-formed archive, this section starts at the start of the + * archive and extends all the way to the ZIP Central Directory. + * @param zipCentralDirectory ZIP Central Directory section + * @param zipEocd ZIP End of Central Directory (EoCD) record + * + * @return request to add an APK Signing Block to the output or {@code null} if the output must + * not contain an APK Signing Block. The request must be fulfilled before + * {@link #outputDone()} is invoked. + * + * @throws IOException if an I/O error occurs while reading the provided ZIP sections + * @throws ApkFormatException if the provided APK is malformed in a way which prevents this + * engine from producing a valid signature. For example, if the APK Signing Block + * provided to the engine is malformed. + * @throws NoSuchAlgorithmException if a signature could not be generated because a required + * cryptographic algorithm implementation is missing + * @throws InvalidKeyException if a signature could not be generated because a signing key is + * not suitable for generating the signature + * @throws SignatureException if an error occurred while generating a signature + * @throws IllegalStateException if there are unfulfilled requests, such as to inspect some JAR + * entries or to output JAR signature, or if the engine is closed + */ + OutputApkSigningBlockRequest2 outputZipSections2( + DataSource zipEntries, + DataSource zipCentralDirectory, + DataSource zipEocd) + throws IOException, ApkFormatException, NoSuchAlgorithmException, + InvalidKeyException, SignatureException, IllegalStateException; + + /** + * Indicates to this engine that the signed APK was output. + * + * <p>This does not change the output APK. The method helps the client confirm that the current + * output is signed. + * + * @throws IllegalStateException if there are unfulfilled requests, such as to inspect some JAR + * entries or to output signatures, or if the engine is closed + */ + void outputDone() throws IllegalStateException; + + /** + * Generates a V4 signature proto and write to output file. + * + * @param data Input data to calculate a verity hash tree and hash root + * @param outputFile To store the serialized V4 Signature. + * @param ignoreFailures Whether any failures will be silently ignored. + * @throws InvalidKeyException if a signature could not be generated because a signing key is + * not suitable for generating the signature + * @throws NoSuchAlgorithmException if a signature could not be generated because a required + * cryptographic algorithm implementation is missing + * @throws SignatureException if an error occurred while generating a signature + * @throws IOException if protobuf fails to be serialized and written to file + */ + void signV4(DataSource data, File outputFile, boolean ignoreFailures) + throws InvalidKeyException, NoSuchAlgorithmException, SignatureException, IOException; + + /** + * Checks if the signing configuration provided to the engine is capable of creating a + * SourceStamp. + */ + default boolean isEligibleForSourceStamp() { + return false; + } + + /** Generates the digest of the certificate used to sign the source stamp. */ + default byte[] generateSourceStampCertificateDigest() throws SignatureException { + return new byte[0]; + } + + /** + * Indicates to this engine that it will no longer be used. Invoking this on an already closed + * engine is OK. + * + * <p>This does not change the output APK. For example, if the output APK is not yet fully + * signed, it will remain so after this method terminates. + */ + @Override + void close(); + + /** + * Instructions about how to handle an input APK's JAR entry. + * + * <p>The instructions indicate whether to output the entry (see {@link #getOutputPolicy()}) and + * may contain a request to inspect the entry (see {@link #getInspectJarEntryRequest()}), in + * which case the request must be fulfilled before {@link ApkSignerEngine#outputJarEntries()} is + * invoked. + */ + public static class InputJarEntryInstructions { + private final OutputPolicy mOutputPolicy; + private final InspectJarEntryRequest mInspectJarEntryRequest; + + /** + * Constructs a new {@code InputJarEntryInstructions} instance with the provided entry + * output policy and without a request to inspect the entry. + */ + public InputJarEntryInstructions(OutputPolicy outputPolicy) { + this(outputPolicy, null); + } + + /** + * Constructs a new {@code InputJarEntryInstructions} instance with the provided entry + * output mode and with the provided request to inspect the entry. + * + * @param inspectJarEntryRequest request to inspect the entry or {@code null} if there's no + * need to inspect the entry. + */ + public InputJarEntryInstructions( + OutputPolicy outputPolicy, + InspectJarEntryRequest inspectJarEntryRequest) { + mOutputPolicy = outputPolicy; + mInspectJarEntryRequest = inspectJarEntryRequest; + } + + /** + * Returns the output policy for this entry. + */ + public OutputPolicy getOutputPolicy() { + return mOutputPolicy; + } + + /** + * Returns the request to inspect the JAR entry or {@code null} if there is no need to + * inspect the entry. + */ + public InspectJarEntryRequest getInspectJarEntryRequest() { + return mInspectJarEntryRequest; + } + + /** + * Output policy for an input APK's JAR entry. + */ + public static enum OutputPolicy { + /** Entry must not be output. */ + SKIP, + + /** Entry should be output. */ + OUTPUT, + + /** Entry will be output by the engine. The client can thus ignore this input entry. */ + OUTPUT_BY_ENGINE, + } + } + + /** + * Request to inspect the specified JAR entry. + * + * <p>The entry's uncompressed data must be provided to the data sink returned by + * {@link #getDataSink()}. Once the entry's data has been provided to the sink, {@link #done()} + * must be invoked. + */ + interface InspectJarEntryRequest { + + /** + * Returns the data sink into which the entry's uncompressed data should be sent. + */ + DataSink getDataSink(); + + /** + * Indicates that entry's data has been provided in full. + */ + void done(); + + /** + * Returns the name of the JAR entry. + */ + String getEntryName(); + } + + /** + * Request to add JAR signature (aka v1 signature) to the output APK. + * + * <p>Entries listed in {@link #getAdditionalJarEntries()} must be added to the output APK after + * which {@link #done()} must be invoked. + */ + interface OutputJarSignatureRequest { + + /** + * Returns JAR entries that must be added to the output APK. + */ + List<JarEntry> getAdditionalJarEntries(); + + /** + * Indicates that the JAR entries contained in this request were added to the output APK. + */ + void done(); + + /** + * JAR entry. + */ + public static class JarEntry { + private final String mName; + private final byte[] mData; + + /** + * Constructs a new {@code JarEntry} with the provided name and data. + * + * @param data uncompressed data of the entry. Changes to this array will not be + * reflected in {@link #getData()}. + */ + public JarEntry(String name, byte[] data) { + mName = name; + mData = data.clone(); + } + + /** + * Returns the name of this ZIP entry. + */ + public String getName() { + return mName; + } + + /** + * Returns the uncompressed data of this JAR entry. + */ + public byte[] getData() { + return mData.clone(); + } + } + } + + /** + * Request to add the specified APK Signing Block to the output APK. APK Signature Scheme v2 + * signature(s) of the APK are contained in this block. + * + * <p>The APK Signing Block returned by {@link #getApkSigningBlock()} must be placed into the + * output APK such that the block is immediately before the ZIP Central Directory, the offset of + * ZIP Central Directory in the ZIP End of Central Directory record must be adjusted + * accordingly, and then {@link #done()} must be invoked. + * + * <p>If the output contains an APK Signing Block, that block must be replaced by the block + * contained in this request. + * + * @deprecated This is now superseded by {@link OutputApkSigningBlockRequest2}. + */ + @Deprecated + interface OutputApkSigningBlockRequest { + + /** + * Returns the APK Signing Block. + */ + byte[] getApkSigningBlock(); + + /** + * Indicates that the APK Signing Block was output as requested. + */ + void done(); + } + + /** + * Request to add the specified APK Signing Block to the output APK. APK Signature Scheme v2 + * signature(s) of the APK are contained in this block. + * + * <p>The APK Signing Block returned by {@link #getApkSigningBlock()} must be placed into the + * output APK such that the block is immediately before the ZIP Central Directory. Immediately + * before the APK Signing Block must be padding consists of the number of 0x00 bytes returned by + * {@link #getPaddingSizeBeforeApkSigningBlock()}. The offset of ZIP Central Directory in the + * ZIP End of Central Directory record must be adjusted accordingly, and then {@link #done()} + * must be invoked. + * + * <p>If the output contains an APK Signing Block, that block must be replaced by the block + * contained in this request. + */ + interface OutputApkSigningBlockRequest2 { + /** + * Returns the APK Signing Block. + */ + byte[] getApkSigningBlock(); + + /** + * Indicates that the APK Signing Block was output as requested. + */ + void done(); + + /** + * Returns the number of 0x00 bytes the caller must place immediately before APK Signing + * Block. + */ + int getPaddingSizeBeforeApkSigningBlock(); + } +} diff --git a/platform/android/java/editor/src/main/java/com/android/apksig/ApkVerificationIssue.java b/platform/android/java/editor/src/main/java/com/android/apksig/ApkVerificationIssue.java new file mode 100644 index 0000000000..fa2b7aa58c --- /dev/null +++ b/platform/android/java/editor/src/main/java/com/android/apksig/ApkVerificationIssue.java @@ -0,0 +1,173 @@ +/* + * Copyright (C) 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.apksig; + +/** + * This class is intended as a lightweight representation of an APK signature verification issue + * where the client does not require the additional textual details provided by a subclass. + */ +public class ApkVerificationIssue { + /* The V2 signer(s) could not be read from the V2 signature block */ + public static final int V2_SIG_MALFORMED_SIGNERS = 1; + /* A V2 signature block exists without any V2 signers */ + public static final int V2_SIG_NO_SIGNERS = 2; + /* Failed to parse a signer's block in the V2 signature block */ + public static final int V2_SIG_MALFORMED_SIGNER = 3; + /* Failed to parse the signer's signature record in the V2 signature block */ + public static final int V2_SIG_MALFORMED_SIGNATURE = 4; + /* The V2 signer contained no signatures */ + public static final int V2_SIG_NO_SIGNATURES = 5; + /* The V2 signer's certificate could not be parsed */ + public static final int V2_SIG_MALFORMED_CERTIFICATE = 6; + /* No signing certificates exist for the V2 signer */ + public static final int V2_SIG_NO_CERTIFICATES = 7; + /* Failed to parse the V2 signer's digest record */ + public static final int V2_SIG_MALFORMED_DIGEST = 8; + /* The V3 signer(s) could not be read from the V3 signature block */ + public static final int V3_SIG_MALFORMED_SIGNERS = 9; + /* A V3 signature block exists without any V3 signers */ + public static final int V3_SIG_NO_SIGNERS = 10; + /* Failed to parse a signer's block in the V3 signature block */ + public static final int V3_SIG_MALFORMED_SIGNER = 11; + /* Failed to parse the signer's signature record in the V3 signature block */ + public static final int V3_SIG_MALFORMED_SIGNATURE = 12; + /* The V3 signer contained no signatures */ + public static final int V3_SIG_NO_SIGNATURES = 13; + /* The V3 signer's certificate could not be parsed */ + public static final int V3_SIG_MALFORMED_CERTIFICATE = 14; + /* No signing certificates exist for the V3 signer */ + public static final int V3_SIG_NO_CERTIFICATES = 15; + /* Failed to parse the V3 signer's digest record */ + public static final int V3_SIG_MALFORMED_DIGEST = 16; + /* The source stamp signer contained no signatures */ + public static final int SOURCE_STAMP_NO_SIGNATURE = 17; + /* The source stamp signer's certificate could not be parsed */ + public static final int SOURCE_STAMP_MALFORMED_CERTIFICATE = 18; + /* The source stamp contains a signature produced using an unknown algorithm */ + public static final int SOURCE_STAMP_UNKNOWN_SIG_ALGORITHM = 19; + /* Failed to parse the signer's signature in the source stamp signature block */ + public static final int SOURCE_STAMP_MALFORMED_SIGNATURE = 20; + /* The source stamp's signature block failed verification */ + public static final int SOURCE_STAMP_DID_NOT_VERIFY = 21; + /* An exception was encountered when verifying the source stamp */ + public static final int SOURCE_STAMP_VERIFY_EXCEPTION = 22; + /* The certificate digest in the APK does not match the expected digest */ + public static final int SOURCE_STAMP_EXPECTED_DIGEST_MISMATCH = 23; + /* + * The APK contains a source stamp signature block without a corresponding stamp certificate + * digest in the APK contents. + */ + public static final int SOURCE_STAMP_SIGNATURE_BLOCK_WITHOUT_CERT_DIGEST = 24; + /* + * The APK does not contain the source stamp certificate digest file nor the source stamp + * signature block. + */ + public static final int SOURCE_STAMP_CERT_DIGEST_AND_SIG_BLOCK_MISSING = 25; + /* + * None of the signatures provided by the source stamp were produced with a known signature + * algorithm. + */ + public static final int SOURCE_STAMP_NO_SUPPORTED_SIGNATURE = 26; + /* + * The source stamp signer's certificate in the signing block does not match the certificate in + * the APK. + */ + public static final int SOURCE_STAMP_CERTIFICATE_MISMATCH_BETWEEN_SIGNATURE_BLOCK_AND_APK = 27; + /* The APK could not be properly parsed due to a ZIP or APK format exception */ + public static final int MALFORMED_APK = 28; + /* An unexpected exception was caught when attempting to verify the APK's signatures */ + public static final int UNEXPECTED_EXCEPTION = 29; + /* The APK contains the certificate digest file but does not contain a stamp signature block */ + public static final int SOURCE_STAMP_SIG_MISSING = 30; + /* Source stamp block contains a malformed attribute. */ + public static final int SOURCE_STAMP_MALFORMED_ATTRIBUTE = 31; + /* Source stamp block contains an unknown attribute. */ + public static final int SOURCE_STAMP_UNKNOWN_ATTRIBUTE = 32; + /** + * Failed to parse the SigningCertificateLineage structure in the source stamp + * attributes section. + */ + public static final int SOURCE_STAMP_MALFORMED_LINEAGE = 33; + /** + * The source stamp certificate does not match the terminal node in the provided + * proof-of-rotation structure describing the stamp certificate history. + */ + public static final int SOURCE_STAMP_POR_CERT_MISMATCH = 34; + /** + * The source stamp SigningCertificateLineage attribute contains a proof-of-rotation record + * with signature(s) that did not verify. + */ + public static final int SOURCE_STAMP_POR_DID_NOT_VERIFY = 35; + /** No V1 / jar signing signature blocks were found in the APK. */ + public static final int JAR_SIG_NO_SIGNATURES = 36; + /** An exception was encountered when parsing the V1 / jar signer in the signature block. */ + public static final int JAR_SIG_PARSE_EXCEPTION = 37; + /** The source stamp timestamp attribute has an invalid value. */ + public static final int SOURCE_STAMP_INVALID_TIMESTAMP = 38; + + private final int mIssueId; + private final String mFormat; + private final Object[] mParams; + + /** + * Constructs a new {@code ApkVerificationIssue} using the provided {@code format} string and + * {@code params}. + */ + public ApkVerificationIssue(String format, Object... params) { + mIssueId = -1; + mFormat = format; + mParams = params; + } + + /** + * Constructs a new {@code ApkVerificationIssue} using the provided {@code issueId} and {@code + * params}. + */ + public ApkVerificationIssue(int issueId, Object... params) { + mIssueId = issueId; + mFormat = null; + mParams = params; + } + + /** + * Returns the numeric ID for this issue. + */ + public int getIssueId() { + return mIssueId; + } + + /** + * Returns the optional parameters for this issue. + */ + public Object[] getParams() { + return mParams; + } + + @Override + public String toString() { + // If this instance was created by a subclass with a format string then return the same + // formatted String as the subclass. + if (mFormat != null) { + return String.format(mFormat, mParams); + } + StringBuilder result = new StringBuilder("mIssueId: ").append(mIssueId); + for (Object param : mParams) { + result.append(", ").append(param.toString()); + } + return result.toString(); + } +} diff --git a/platform/android/java/editor/src/main/java/com/android/apksig/ApkVerifier.java b/platform/android/java/editor/src/main/java/com/android/apksig/ApkVerifier.java new file mode 100644 index 0000000000..50b3d9f5e2 --- /dev/null +++ b/platform/android/java/editor/src/main/java/com/android/apksig/ApkVerifier.java @@ -0,0 +1,3657 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.apksig; + +import static com.android.apksig.apk.ApkUtils.SOURCE_STAMP_CERTIFICATE_HASH_ZIP_ENTRY_NAME; +import static com.android.apksig.apk.ApkUtils.computeSha256DigestBytes; +import static com.android.apksig.apk.ApkUtils.getTargetSandboxVersionFromBinaryAndroidManifest; +import static com.android.apksig.apk.ApkUtils.getTargetSdkVersionFromBinaryAndroidManifest; +import static com.android.apksig.internal.apk.ApkSigningBlockUtils.VERSION_APK_SIGNATURE_SCHEME_V2; +import static com.android.apksig.internal.apk.ApkSigningBlockUtils.VERSION_APK_SIGNATURE_SCHEME_V3; +import static com.android.apksig.internal.apk.ApkSigningBlockUtils.VERSION_APK_SIGNATURE_SCHEME_V31; +import static com.android.apksig.internal.apk.ApkSigningBlockUtils.VERSION_APK_SIGNATURE_SCHEME_V31; +import static com.android.apksig.internal.apk.ApkSigningBlockUtils.VERSION_APK_SIGNATURE_SCHEME_V4; +import static com.android.apksig.internal.apk.ApkSigningBlockUtils.VERSION_JAR_SIGNATURE_SCHEME; +import static com.android.apksig.internal.apk.ApkSigningBlockUtils.VERSION_SOURCE_STAMP; +import static com.android.apksig.internal.apk.ApkSigningBlockUtils.toHex; +import static com.android.apksig.internal.apk.v1.V1SchemeConstants.MANIFEST_ENTRY_NAME; +import static com.android.apksig.internal.apk.v3.V3SchemeConstants.MIN_SDK_WITH_V31_SUPPORT; + +import com.android.apksig.ApkVerifier.Result.V2SchemeSignerInfo; +import com.android.apksig.ApkVerifier.Result.V3SchemeSignerInfo; +import com.android.apksig.SigningCertificateLineage.SignerConfig; +import com.android.apksig.apk.ApkFormatException; +import com.android.apksig.apk.ApkUtils; +import com.android.apksig.internal.apk.ApkSigResult; +import com.android.apksig.internal.apk.ApkSignerInfo; +import com.android.apksig.internal.apk.ApkSigningBlockUtils; +import com.android.apksig.internal.apk.ApkSigningBlockUtils.Result.SignerInfo.ContentDigest; +import com.android.apksig.internal.apk.ContentDigestAlgorithm; +import com.android.apksig.internal.apk.SignatureAlgorithm; +import com.android.apksig.internal.apk.SignatureInfo; +import com.android.apksig.internal.apk.SignatureNotFoundException; +import com.android.apksig.internal.apk.stamp.SourceStampConstants; +import com.android.apksig.internal.apk.stamp.V2SourceStampVerifier; +import com.android.apksig.internal.apk.v1.V1SchemeVerifier; +import com.android.apksig.internal.apk.v2.V2SchemeConstants; +import com.android.apksig.internal.apk.v2.V2SchemeVerifier; +import com.android.apksig.internal.apk.v3.V3SchemeConstants; +import com.android.apksig.internal.apk.v3.V3SchemeVerifier; +import com.android.apksig.internal.apk.v4.V4SchemeVerifier; +import com.android.apksig.internal.util.AndroidSdkVersion; +import com.android.apksig.internal.zip.CentralDirectoryRecord; +import com.android.apksig.internal.zip.LocalFileRecord; +import com.android.apksig.util.DataSource; +import com.android.apksig.util.DataSources; +import com.android.apksig.util.RunnablesExecutor; +import com.android.apksig.zip.ZipFormatException; + +import java.io.Closeable; +import java.io.File; +import java.io.IOException; +import java.io.RandomAccessFile; +import java.nio.ByteBuffer; +import java.security.InvalidKeyException; +import java.security.NoSuchAlgorithmException; +import java.security.SignatureException; +import java.security.cert.CertificateEncodingException; +import java.security.cert.X509Certificate; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.EnumMap; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * APK signature verifier which mimics the behavior of the Android platform. + * + * <p>The verifier is designed to closely mimic the behavior of Android platforms. This is to enable + * the verifier to be used for checking whether an APK's signatures are expected to verify on + * Android. + * + * <p>Use {@link Builder} to obtain instances of this verifier. + * + * @see <a href="https://source.android.com/security/apksigning/index.html">Application Signing</a> + */ +public class ApkVerifier { + + private static final Set<Issue> LINEAGE_RELATED_ISSUES = new HashSet<>(Arrays.asList( + Issue.V3_SIG_MALFORMED_LINEAGE, Issue.V3_INCONSISTENT_LINEAGES, + Issue.V3_SIG_POR_DID_NOT_VERIFY, Issue.V3_SIG_POR_CERT_MISMATCH)); + + private static final Map<Integer, String> SUPPORTED_APK_SIG_SCHEME_NAMES = + loadSupportedApkSigSchemeNames(); + + private static Map<Integer, String> loadSupportedApkSigSchemeNames() { + Map<Integer, String> supportedMap = new HashMap<>(2); + supportedMap.put( + ApkSigningBlockUtils.VERSION_APK_SIGNATURE_SCHEME_V2, "APK Signature Scheme v2"); + supportedMap.put( + ApkSigningBlockUtils.VERSION_APK_SIGNATURE_SCHEME_V3, "APK Signature Scheme v3"); + return supportedMap; + } + + private final File mApkFile; + private final DataSource mApkDataSource; + private final File mV4SignatureFile; + + private final Integer mMinSdkVersion; + private final int mMaxSdkVersion; + + private ApkVerifier( + File apkFile, + DataSource apkDataSource, + File v4SignatureFile, + Integer minSdkVersion, + int maxSdkVersion) { + mApkFile = apkFile; + mApkDataSource = apkDataSource; + mV4SignatureFile = v4SignatureFile; + mMinSdkVersion = minSdkVersion; + mMaxSdkVersion = maxSdkVersion; + } + + /** + * Verifies the APK's signatures and returns the result of verification. The APK can be + * considered verified iff the result's {@link Result#isVerified()} returns {@code true}. + * The verification result also includes errors, warnings, and information about signers such + * as their signing certificates. + * + * <p>Verification succeeds iff the APK's signature is expected to verify on all Android + * platform versions specified via the {@link Builder}. If the APK's signature is expected to + * not verify on any of the specified platform versions, this method returns a result with one + * or more errors and whose {@link Result#isVerified()} returns {@code false}, or this method + * throws an exception. + * + * @throws IOException if an I/O error is encountered while reading the APK + * @throws ApkFormatException if the APK is malformed + * @throws NoSuchAlgorithmException if the APK's signatures cannot be verified because a + * required cryptographic algorithm implementation is missing + * @throws IllegalStateException if this verifier's configuration is missing required + * information. + */ + public Result verify() throws IOException, ApkFormatException, NoSuchAlgorithmException, + IllegalStateException { + Closeable in = null; + try { + DataSource apk; + if (mApkDataSource != null) { + apk = mApkDataSource; + } else if (mApkFile != null) { + RandomAccessFile f = new RandomAccessFile(mApkFile, "r"); + in = f; + apk = DataSources.asDataSource(f, 0, f.length()); + } else { + throw new IllegalStateException("APK not provided"); + } + return verify(apk); + } finally { + if (in != null) { + in.close(); + } + } + } + + /** + * Verifies the APK's signatures and returns the result of verification. The APK can be + * considered verified iff the result's {@link Result#isVerified()} returns {@code true}. + * The verification result also includes errors, warnings, and information about signers. + * + * @param apk APK file contents + * @throws IOException if an I/O error is encountered while reading the APK + * @throws ApkFormatException if the APK is malformed + * @throws NoSuchAlgorithmException if the APK's signatures cannot be verified because a + * required cryptographic algorithm implementation is missing + */ + private Result verify(DataSource apk) + throws IOException, ApkFormatException, NoSuchAlgorithmException { + int maxSdkVersion = mMaxSdkVersion; + + ApkUtils.ZipSections zipSections; + try { + zipSections = ApkUtils.findZipSections(apk); + } catch (ZipFormatException e) { + throw new ApkFormatException("Malformed APK: not a ZIP archive", e); + } + + ByteBuffer androidManifest = null; + + int minSdkVersion = verifyAndGetMinSdkVersion(apk, zipSections); + + Result result = new Result(); + Map<Integer, Map<ContentDigestAlgorithm, byte[]>> signatureSchemeApkContentDigests = + new HashMap<>(); + + // The SUPPORTED_APK_SIG_SCHEME_NAMES contains the mapping from version number to scheme + // name, but the verifiers use this parameter as the schemes supported by the target SDK + // range. Since the code below skips signature verification based on max SDK the mapping of + // supported schemes needs to be modified to ensure the verifiers do not report a stripped + // signature for an SDK range that does not support that signature version. For instance an + // APK with V1, V2, and V3 signatures and a max SDK of O would skip the V3 signature + // verification, but the SUPPORTED_APK_SIG_SCHEME_NAMES contains version 3, so when the V2 + // verification is performed it would see the stripping protection attribute, see that V3 + // is in the list of supported signatures, and report a stripped signature. + Map<Integer, String> supportedSchemeNames = getSupportedSchemeNames(maxSdkVersion); + + // Android N and newer attempts to verify APKs using the APK Signing Block, which can + // include v2 and/or v3 signatures. If none is found, it falls back to JAR signature + // verification. If the signature is found but does not verify, the APK is rejected. + Set<Integer> foundApkSigSchemeIds = new HashSet<>(2); + if (maxSdkVersion >= AndroidSdkVersion.N) { + RunnablesExecutor executor = RunnablesExecutor.SINGLE_THREADED; + // Android T and newer attempts to verify APKs using APK Signature Scheme V3.1. v3.0 + // also includes stripping protection for the minimum SDK version on which the rotated + // signing key should be used. + int rotationMinSdkVersion = 0; + if (maxSdkVersion >= MIN_SDK_WITH_V31_SUPPORT) { + try { + ApkSigningBlockUtils.Result v31Result = new V3SchemeVerifier.Builder(apk, + zipSections, Math.max(minSdkVersion, MIN_SDK_WITH_V31_SUPPORT), + maxSdkVersion) + .setRunnablesExecutor(executor) + .setBlockId(V3SchemeConstants.APK_SIGNATURE_SCHEME_V31_BLOCK_ID) + .build() + .verify(); + foundApkSigSchemeIds.add(VERSION_APK_SIGNATURE_SCHEME_V31); + rotationMinSdkVersion = v31Result.signers.stream().mapToInt( + signer -> signer.minSdkVersion).min().orElse(0); + result.mergeFrom(v31Result); + signatureSchemeApkContentDigests.put( + VERSION_APK_SIGNATURE_SCHEME_V31, + getApkContentDigestsFromSigningSchemeResult(v31Result)); + } catch (ApkSigningBlockUtils.SignatureNotFoundException ignored) { + // v3.1 signature not required + } + if (result.containsErrors()) { + return result; + } + } + // Android P and newer attempts to verify APKs using APK Signature Scheme v3; since a + // V3.1 block should only be written with a V3.0 block, always perform the V3.0 check + // if the minSdkVersion supports V3.0. + if (maxSdkVersion >= AndroidSdkVersion.P) { + try { + V3SchemeVerifier.Builder builder = new V3SchemeVerifier.Builder(apk, + zipSections, Math.max(minSdkVersion, AndroidSdkVersion.P), + maxSdkVersion) + .setRunnablesExecutor(executor) + .setBlockId(V3SchemeConstants.APK_SIGNATURE_SCHEME_V3_BLOCK_ID); + if (rotationMinSdkVersion > 0) { + builder.setRotationMinSdkVersion(rotationMinSdkVersion); + } + ApkSigningBlockUtils.Result v3Result = builder.build().verify(); + foundApkSigSchemeIds.add(ApkSigningBlockUtils.VERSION_APK_SIGNATURE_SCHEME_V3); + result.mergeFrom(v3Result); + signatureSchemeApkContentDigests.put( + ApkSigningBlockUtils.VERSION_APK_SIGNATURE_SCHEME_V3, + getApkContentDigestsFromSigningSchemeResult(v3Result)); + } catch (ApkSigningBlockUtils.SignatureNotFoundException ignored) { + // v3 signature not required unless a v3.1 signature was found as a v3.1 + // signature is intended to support key rotation on T+ with the v3 signature + // containing the original signing key. + if (foundApkSigSchemeIds.contains( + VERSION_APK_SIGNATURE_SCHEME_V31)) { + result.addError(Issue.V31_BLOCK_FOUND_WITHOUT_V3_BLOCK); + } + } + if (result.containsErrors()) { + return result; + } + } + + // Attempt to verify the APK using v2 signing if necessary. Platforms prior to Android P + // ignore APK Signature Scheme v3 signatures and always attempt to verify either JAR or + // APK Signature Scheme v2 signatures. Android P onwards verifies v2 signatures only if + // no APK Signature Scheme v3 (or newer scheme) signatures were found. + if (minSdkVersion < AndroidSdkVersion.P || foundApkSigSchemeIds.isEmpty()) { + try { + ApkSigningBlockUtils.Result v2Result = + V2SchemeVerifier.verify( + executor, + apk, + zipSections, + supportedSchemeNames, + foundApkSigSchemeIds, + Math.max(minSdkVersion, AndroidSdkVersion.N), + maxSdkVersion); + foundApkSigSchemeIds.add(ApkSigningBlockUtils.VERSION_APK_SIGNATURE_SCHEME_V2); + result.mergeFrom(v2Result); + signatureSchemeApkContentDigests.put( + ApkSigningBlockUtils.VERSION_APK_SIGNATURE_SCHEME_V2, + getApkContentDigestsFromSigningSchemeResult(v2Result)); + } catch (ApkSigningBlockUtils.SignatureNotFoundException ignored) { + // v2 signature not required + } + if (result.containsErrors()) { + return result; + } + } + + // If v4 file is specified, use additional verification on it + if (mV4SignatureFile != null) { + final ApkSigningBlockUtils.Result v4Result = + V4SchemeVerifier.verify(apk, mV4SignatureFile); + foundApkSigSchemeIds.add( + ApkSigningBlockUtils.VERSION_APK_SIGNATURE_SCHEME_V4); + result.mergeFrom(v4Result); + if (result.containsErrors()) { + return result; + } + } + } + + // Android O and newer requires that APKs targeting security sandbox version 2 and higher + // are signed using APK Signature Scheme v2 or newer. + if (maxSdkVersion >= AndroidSdkVersion.O) { + if (androidManifest == null) { + androidManifest = getAndroidManifestFromApk(apk, zipSections); + } + int targetSandboxVersion = + getTargetSandboxVersionFromBinaryAndroidManifest(androidManifest.slice()); + if (targetSandboxVersion > 1) { + if (foundApkSigSchemeIds.isEmpty()) { + result.addError( + Issue.NO_SIG_FOR_TARGET_SANDBOX_VERSION, + targetSandboxVersion); + } + } + } + + List<CentralDirectoryRecord> cdRecords = + V1SchemeVerifier.parseZipCentralDirectory(apk, zipSections); + + // Attempt to verify the APK using JAR signing if necessary. Platforms prior to Android N + // ignore APK Signature Scheme v2 signatures and always attempt to verify JAR signatures. + // Android N onwards verifies JAR signatures only if no APK Signature Scheme v2 (or newer + // scheme) signatures were found. + if ((minSdkVersion < AndroidSdkVersion.N) || (foundApkSigSchemeIds.isEmpty())) { + V1SchemeVerifier.Result v1Result = + V1SchemeVerifier.verify( + apk, + zipSections, + supportedSchemeNames, + foundApkSigSchemeIds, + minSdkVersion, + maxSdkVersion); + result.mergeFrom(v1Result); + signatureSchemeApkContentDigests.put( + ApkSigningBlockUtils.VERSION_JAR_SIGNATURE_SCHEME, + getApkContentDigestFromV1SigningScheme(cdRecords, apk, zipSections)); + } + if (result.containsErrors()) { + return result; + } + + // Verify the SourceStamp, if found in the APK. + try { + CentralDirectoryRecord sourceStampCdRecord = null; + for (CentralDirectoryRecord cdRecord : cdRecords) { + if (SOURCE_STAMP_CERTIFICATE_HASH_ZIP_ENTRY_NAME.equals( + cdRecord.getName())) { + sourceStampCdRecord = cdRecord; + break; + } + } + // If SourceStamp file is found inside the APK, there must be a SourceStamp + // block in the APK signing block as well. + if (sourceStampCdRecord != null) { + byte[] sourceStampCertificateDigest = + LocalFileRecord.getUncompressedData( + apk, + sourceStampCdRecord, + zipSections.getZipCentralDirectoryOffset()); + ApkSigResult sourceStampResult = + V2SourceStampVerifier.verify( + apk, + zipSections, + sourceStampCertificateDigest, + signatureSchemeApkContentDigests, + Math.max(minSdkVersion, AndroidSdkVersion.R), + maxSdkVersion); + result.mergeFrom(sourceStampResult); + } + } catch (SignatureNotFoundException ignored) { + result.addWarning(Issue.SOURCE_STAMP_SIG_MISSING); + } catch (ZipFormatException e) { + throw new ApkFormatException("Failed to read APK", e); + } + if (result.containsErrors()) { + return result; + } + + // Check whether v1 and v2 scheme signer identifies match, provided both v1 and v2 + // signatures verified. + if ((result.isVerifiedUsingV1Scheme()) && (result.isVerifiedUsingV2Scheme())) { + ArrayList<Result.V1SchemeSignerInfo> v1Signers = + new ArrayList<>(result.getV1SchemeSigners()); + ArrayList<Result.V2SchemeSignerInfo> v2Signers = + new ArrayList<>(result.getV2SchemeSigners()); + ArrayList<ByteArray> v1SignerCerts = new ArrayList<>(); + ArrayList<ByteArray> v2SignerCerts = new ArrayList<>(); + for (Result.V1SchemeSignerInfo signer : v1Signers) { + try { + v1SignerCerts.add(new ByteArray(signer.getCertificate().getEncoded())); + } catch (CertificateEncodingException e) { + throw new IllegalStateException( + "Failed to encode JAR signer " + signer.getName() + " certs", e); + } + } + for (Result.V2SchemeSignerInfo signer : v2Signers) { + try { + v2SignerCerts.add(new ByteArray(signer.getCertificate().getEncoded())); + } catch (CertificateEncodingException e) { + throw new IllegalStateException( + "Failed to encode APK Signature Scheme v2 signer (index: " + + signer.getIndex() + ") certs", + e); + } + } + + for (int i = 0; i < v1SignerCerts.size(); i++) { + ByteArray v1Cert = v1SignerCerts.get(i); + if (!v2SignerCerts.contains(v1Cert)) { + Result.V1SchemeSignerInfo v1Signer = v1Signers.get(i); + v1Signer.addError(Issue.V2_SIG_MISSING); + break; + } + } + for (int i = 0; i < v2SignerCerts.size(); i++) { + ByteArray v2Cert = v2SignerCerts.get(i); + if (!v1SignerCerts.contains(v2Cert)) { + Result.V2SchemeSignerInfo v2Signer = v2Signers.get(i); + v2Signer.addError(Issue.JAR_SIG_MISSING); + break; + } + } + } + + // If there is a v3 scheme signer and an earlier scheme signer, make sure that there is a + // match, or in the event of signing certificate rotation, that the v1/v2 scheme signer + // matches the oldest signing certificate in the provided SigningCertificateLineage + if (result.isVerifiedUsingV3Scheme() + && (result.isVerifiedUsingV1Scheme() || result.isVerifiedUsingV2Scheme())) { + SigningCertificateLineage lineage = result.getSigningCertificateLineage(); + X509Certificate oldSignerCert; + if (result.isVerifiedUsingV1Scheme()) { + List<Result.V1SchemeSignerInfo> v1Signers = result.getV1SchemeSigners(); + if (v1Signers.size() != 1) { + // APK Signature Scheme v3 only supports single-signers, error to sign with + // multiple and then only one + result.addError(Issue.V3_SIG_MULTIPLE_PAST_SIGNERS); + } + oldSignerCert = v1Signers.get(0).mCertChain.get(0); + } else { + List<Result.V2SchemeSignerInfo> v2Signers = result.getV2SchemeSigners(); + if (v2Signers.size() != 1) { + // APK Signature Scheme v3 only supports single-signers, error to sign with + // multiple and then only one + result.addError(Issue.V3_SIG_MULTIPLE_PAST_SIGNERS); + } + oldSignerCert = v2Signers.get(0).mCerts.get(0); + } + if (lineage == null) { + // no signing certificate history with which to contend, just make sure that v3 + // matches previous versions + List<Result.V3SchemeSignerInfo> v3Signers = result.getV3SchemeSigners(); + if (v3Signers.size() != 1) { + // multiple v3 signers should never exist without rotation history, since + // multiple signers implies a different signer for different platform versions + result.addError(Issue.V3_SIG_MULTIPLE_SIGNERS); + } + try { + if (!Arrays.equals(oldSignerCert.getEncoded(), + v3Signers.get(0).mCerts.get(0).getEncoded())) { + result.addError(Issue.V3_SIG_PAST_SIGNERS_MISMATCH); + } + } catch (CertificateEncodingException e) { + // we just go the encoding for the v1/v2 certs above, so must be v3 + throw new RuntimeException( + "Failed to encode APK Signature Scheme v3 signer cert", e); + } + } else { + // we have some signing history, make sure that the root of the history is the same + // as our v1/v2 signer + try { + lineage = lineage.getSubLineage(oldSignerCert); + if (lineage.size() != 1) { + // the v1/v2 signer was found, but not at the root of the lineage + result.addError(Issue.V3_SIG_PAST_SIGNERS_MISMATCH); + } + } catch (IllegalArgumentException e) { + // the v1/v2 signer was not found in the lineage + result.addError(Issue.V3_SIG_PAST_SIGNERS_MISMATCH); + } + } + } + + + // If there is a v4 scheme signer, make sure that their certificates match. + // The apkDigest field in the v4 signature should match the selected v2/v3. + if (result.isVerifiedUsingV4Scheme()) { + List<Result.V4SchemeSignerInfo> v4Signers = result.getV4SchemeSigners(); + + List<ApkSigningBlockUtils.Result.SignerInfo.ContentDigest> digestsFromV4 = + v4Signers.get(0).getContentDigests(); + if (digestsFromV4.size() != 1) { + result.addError(Issue.V4_SIG_UNEXPECTED_DIGESTS, digestsFromV4.size()); + if (digestsFromV4.isEmpty()) { + return result; + } + } + final byte[] digestFromV4 = digestsFromV4.get(0).getValue(); + + if (result.isVerifiedUsingV3Scheme()) { + final boolean isV31 = result.isVerifiedUsingV31Scheme(); + final int expectedSize = isV31 ? 2 : 1; + if (v4Signers.size() != expectedSize) { + result.addError(isV31 ? Issue.V41_SIG_NEEDS_TWO_SIGNERS + : Issue.V4_SIG_MULTIPLE_SIGNERS); + return result; + } + + checkV4Signer(result.getV3SchemeSigners(), v4Signers.get(0).mCerts, digestFromV4, + result); + if (isV31) { + List<ApkSigningBlockUtils.Result.SignerInfo.ContentDigest> digestsFromV41 = + v4Signers.get(1).getContentDigests(); + if (digestsFromV41.size() != 1) { + result.addError(Issue.V4_SIG_UNEXPECTED_DIGESTS, digestsFromV41.size()); + if (digestsFromV41.isEmpty()) { + return result; + } + } + final byte[] digestFromV41 = digestsFromV41.get(0).getValue(); + checkV4Signer(result.getV31SchemeSigners(), v4Signers.get(1).mCerts, + digestFromV41, result); + } + } else if (result.isVerifiedUsingV2Scheme()) { + if (v4Signers.size() != 1) { + result.addError(Issue.V4_SIG_MULTIPLE_SIGNERS); + } + + List<Result.V2SchemeSignerInfo> v2Signers = result.getV2SchemeSigners(); + if (v2Signers.size() != 1) { + result.addError(Issue.V4_SIG_MULTIPLE_SIGNERS); + } + + // Compare certificates. + checkV4Certificate(v4Signers.get(0).mCerts, v2Signers.get(0).mCerts, result); + + // Compare digests. + final byte[] digestFromV2 = pickBestDigestForV4( + v2Signers.get(0).getContentDigests()); + if (!Arrays.equals(digestFromV4, digestFromV2)) { + result.addError(Issue.V4_SIG_V2_V3_DIGESTS_MISMATCH, 2, toHex(digestFromV2), + toHex(digestFromV4)); + } + } else { + throw new RuntimeException("V4 signature must be also verified with V2/V3"); + } + } + + // If the targetSdkVersion has a minimum required signature scheme version then verify + // that the APK was signed with at least that version. + try { + if (androidManifest == null) { + androidManifest = getAndroidManifestFromApk(apk, zipSections); + } + } catch (ApkFormatException e) { + // If the manifest is not available then skip the minimum signature scheme requirement + // to support bundle verification. + } + if (androidManifest != null) { + int targetSdkVersion = getTargetSdkVersionFromBinaryAndroidManifest( + androidManifest.slice()); + int minSchemeVersion = getMinimumSignatureSchemeVersionForTargetSdk(targetSdkVersion); + // The platform currently only enforces a single minimum signature scheme version, but + // when later platform versions support another minimum version this will need to be + // expanded to verify the minimum based on the target and maximum SDK version. + if (minSchemeVersion > VERSION_JAR_SIGNATURE_SCHEME + && maxSdkVersion >= targetSdkVersion) { + switch (minSchemeVersion) { + case VERSION_APK_SIGNATURE_SCHEME_V2: + if (result.isVerifiedUsingV2Scheme()) { + break; + } + // Allow this case to fall through to the next as a signature satisfying a + // later scheme version will also satisfy this requirement. + case VERSION_APK_SIGNATURE_SCHEME_V3: + if (result.isVerifiedUsingV3Scheme() || result.isVerifiedUsingV31Scheme()) { + break; + } + result.addError(Issue.MIN_SIG_SCHEME_FOR_TARGET_SDK_NOT_MET, + targetSdkVersion, + minSchemeVersion); + } + } + } + + if (result.containsErrors()) { + return result; + } + + // Verified + result.setVerified(); + if (result.isVerifiedUsingV31Scheme()) { + List<Result.V3SchemeSignerInfo> v31Signers = result.getV31SchemeSigners(); + result.addSignerCertificate(v31Signers.get(v31Signers.size() - 1).getCertificate()); + } else if (result.isVerifiedUsingV3Scheme()) { + List<Result.V3SchemeSignerInfo> v3Signers = result.getV3SchemeSigners(); + result.addSignerCertificate(v3Signers.get(v3Signers.size() - 1).getCertificate()); + } else if (result.isVerifiedUsingV2Scheme()) { + for (Result.V2SchemeSignerInfo signerInfo : result.getV2SchemeSigners()) { + result.addSignerCertificate(signerInfo.getCertificate()); + } + } else if (result.isVerifiedUsingV1Scheme()) { + for (Result.V1SchemeSignerInfo signerInfo : result.getV1SchemeSigners()) { + result.addSignerCertificate(signerInfo.getCertificate()); + } + } else { + throw new RuntimeException( + "APK verified, but has not verified using any of v1, v2 or v3 schemes"); + } + + return result; + } + + /** + * Verifies and returns the minimum SDK version, either as provided to the builder or as read + * from the {@code apk}'s AndroidManifest.xml. + */ + private int verifyAndGetMinSdkVersion(DataSource apk, ApkUtils.ZipSections zipSections) + throws ApkFormatException, IOException { + if (mMinSdkVersion != null) { + if (mMinSdkVersion < 0) { + throw new IllegalArgumentException( + "minSdkVersion must not be negative: " + mMinSdkVersion); + } + if ((mMinSdkVersion != null) && (mMinSdkVersion > mMaxSdkVersion)) { + throw new IllegalArgumentException( + "minSdkVersion (" + mMinSdkVersion + ") > maxSdkVersion (" + mMaxSdkVersion + + ")"); + } + return mMinSdkVersion; + } + + ByteBuffer androidManifest = null; + // Need to obtain minSdkVersion from the APK's AndroidManifest.xml + if (androidManifest == null) { + androidManifest = getAndroidManifestFromApk(apk, zipSections); + } + int minSdkVersion = + ApkUtils.getMinSdkVersionFromBinaryAndroidManifest(androidManifest.slice()); + if (minSdkVersion > mMaxSdkVersion) { + throw new IllegalArgumentException( + "minSdkVersion from APK (" + minSdkVersion + ") > maxSdkVersion (" + + mMaxSdkVersion + ")"); + } + return minSdkVersion; + } + + /** + * Returns the mapping of signature scheme version to signature scheme name for all signature + * schemes starting from V2 supported by the {@code maxSdkVersion}. + */ + private static Map<Integer, String> getSupportedSchemeNames(int maxSdkVersion) { + Map<Integer, String> supportedSchemeNames; + if (maxSdkVersion >= AndroidSdkVersion.P) { + supportedSchemeNames = SUPPORTED_APK_SIG_SCHEME_NAMES; + } else if (maxSdkVersion >= AndroidSdkVersion.N) { + supportedSchemeNames = new HashMap<>(1); + supportedSchemeNames.put(ApkSigningBlockUtils.VERSION_APK_SIGNATURE_SCHEME_V2, + SUPPORTED_APK_SIG_SCHEME_NAMES.get( + ApkSigningBlockUtils.VERSION_APK_SIGNATURE_SCHEME_V2)); + } else { + supportedSchemeNames = Collections.emptyMap(); + } + return supportedSchemeNames; + } + + /** + * Verifies the APK's source stamp signature and returns the result of the verification. + * + * <p>The APK's source stamp can be considered verified if the result's {@link + * Result#isVerified} returns {@code true}. The details of the source stamp verification can + * be obtained from the result's {@link Result#getSourceStampInfo()}} including the success or + * failure cause from {@link Result.SourceStampInfo#getSourceStampVerificationStatus()}. If the + * verification fails additional details regarding the failure can be obtained from {@link + * Result#getAllErrors()}}. + */ + public Result verifySourceStamp() { + return verifySourceStamp(null); + } + + /** + * Verifies the APK's source stamp signature, including verification that the SHA-256 digest of + * the stamp signing certificate matches the {@code expectedCertDigest}, and returns the result + * of the verification. + * + * <p>A value of {@code null} for the {@code expectedCertDigest} will verify the source stamp, + * if present, without verifying the actual source stamp certificate used to sign the source + * stamp. This can be used to verify an APK contains a properly signed source stamp without + * verifying a particular signer. + * + * @see #verifySourceStamp() + */ + public Result verifySourceStamp(String expectedCertDigest) { + Closeable in = null; + try { + DataSource apk; + if (mApkDataSource != null) { + apk = mApkDataSource; + } else if (mApkFile != null) { + RandomAccessFile f = new RandomAccessFile(mApkFile, "r"); + in = f; + apk = DataSources.asDataSource(f, 0, f.length()); + } else { + throw new IllegalStateException("APK not provided"); + } + return verifySourceStamp(apk, expectedCertDigest); + } catch (IOException e) { + return createSourceStampResultWithError( + Result.SourceStampInfo.SourceStampVerificationStatus.VERIFICATION_ERROR, + Issue.UNEXPECTED_EXCEPTION, e); + } finally { + if (in != null) { + try { + in.close(); + } catch (IOException ignored) { + } + } + } + } + + /** + * Compares the digests coming from signature blocks. Returns {@code true} if at least one + * digest algorithm is present in both digests and actual digests for all common algorithms + * are the same. + */ + public static boolean compareDigests( + Map<ContentDigestAlgorithm, byte[]> firstDigests, + Map<ContentDigestAlgorithm, byte[]> secondDigests) throws NoSuchAlgorithmException { + + Set<ContentDigestAlgorithm> intersectKeys = new HashSet<>(firstDigests.keySet()); + intersectKeys.retainAll(secondDigests.keySet()); + if (intersectKeys.isEmpty()) { + return false; + } + + for (ContentDigestAlgorithm algorithm : intersectKeys) { + if (!Arrays.equals(firstDigests.get(algorithm), + secondDigests.get(algorithm))) { + return false; + } + } + return true; + } + + + /** + * Verifies the provided {@code apk}'s source stamp signature, including verification of the + * SHA-256 digest of the stamp signing certificate matches the {@code expectedCertDigest}, and + * returns the result of the verification. + * + * @see #verifySourceStamp(String) + */ + private Result verifySourceStamp(DataSource apk, String expectedCertDigest) { + try { + ApkUtils.ZipSections zipSections = ApkUtils.findZipSections(apk); + int minSdkVersion = verifyAndGetMinSdkVersion(apk, zipSections); + + // Attempt to obtain the source stamp's certificate digest from the APK. + List<CentralDirectoryRecord> cdRecords = + V1SchemeVerifier.parseZipCentralDirectory(apk, zipSections); + CentralDirectoryRecord sourceStampCdRecord = null; + for (CentralDirectoryRecord cdRecord : cdRecords) { + if (SOURCE_STAMP_CERTIFICATE_HASH_ZIP_ENTRY_NAME.equals(cdRecord.getName())) { + sourceStampCdRecord = cdRecord; + break; + } + } + + // If the source stamp's certificate digest is not available within the APK then the + // source stamp cannot be verified; check if a source stamp signing block is in the + // APK's signature block to determine the appropriate status to return. + if (sourceStampCdRecord == null) { + boolean stampSigningBlockFound; + try { + ApkSigningBlockUtils.Result result = new ApkSigningBlockUtils.Result( + VERSION_SOURCE_STAMP); + ApkSigningBlockUtils.findSignature(apk, zipSections, + SourceStampConstants.V2_SOURCE_STAMP_BLOCK_ID, result); + stampSigningBlockFound = true; + } catch (ApkSigningBlockUtils.SignatureNotFoundException e) { + stampSigningBlockFound = false; + } + if (stampSigningBlockFound) { + return createSourceStampResultWithError( + Result.SourceStampInfo.SourceStampVerificationStatus.STAMP_NOT_VERIFIED, + Issue.SOURCE_STAMP_SIGNATURE_BLOCK_WITHOUT_CERT_DIGEST); + } else { + return createSourceStampResultWithError( + Result.SourceStampInfo.SourceStampVerificationStatus.STAMP_MISSING, + Issue.SOURCE_STAMP_CERT_DIGEST_AND_SIG_BLOCK_MISSING); + } + } + + // Verify that the contents of the source stamp certificate digest match the expected + // value, if provided. + byte[] sourceStampCertificateDigest = + LocalFileRecord.getUncompressedData( + apk, + sourceStampCdRecord, + zipSections.getZipCentralDirectoryOffset()); + if (expectedCertDigest != null) { + String actualCertDigest = ApkSigningBlockUtils.toHex(sourceStampCertificateDigest); + if (!expectedCertDigest.equalsIgnoreCase(actualCertDigest)) { + return createSourceStampResultWithError( + Result.SourceStampInfo.SourceStampVerificationStatus + .CERT_DIGEST_MISMATCH, + Issue.SOURCE_STAMP_EXPECTED_DIGEST_MISMATCH, actualCertDigest, + expectedCertDigest); + } + } + + Map<Integer, Map<ContentDigestAlgorithm, byte[]>> signatureSchemeApkContentDigests = + new HashMap<>(); + Map<Integer, String> supportedSchemeNames = getSupportedSchemeNames(mMaxSdkVersion); + Set<Integer> foundApkSigSchemeIds = new HashSet<>(2); + + Result result = new Result(); + ApkSigningBlockUtils.Result v3Result = null; + if (mMaxSdkVersion >= AndroidSdkVersion.P) { + v3Result = getApkContentDigests(apk, zipSections, foundApkSigSchemeIds, + supportedSchemeNames, signatureSchemeApkContentDigests, + VERSION_APK_SIGNATURE_SCHEME_V3, + Math.max(minSdkVersion, AndroidSdkVersion.P)); + if (v3Result != null && v3Result.containsErrors()) { + result.mergeFrom(v3Result); + return mergeSourceStampResult( + Result.SourceStampInfo.SourceStampVerificationStatus.VERIFICATION_ERROR, + result); + } + } + + ApkSigningBlockUtils.Result v2Result = null; + if (mMaxSdkVersion >= AndroidSdkVersion.N && (minSdkVersion < AndroidSdkVersion.P + || foundApkSigSchemeIds.isEmpty())) { + v2Result = getApkContentDigests(apk, zipSections, foundApkSigSchemeIds, + supportedSchemeNames, signatureSchemeApkContentDigests, + VERSION_APK_SIGNATURE_SCHEME_V2, + Math.max(minSdkVersion, AndroidSdkVersion.N)); + if (v2Result != null && v2Result.containsErrors()) { + result.mergeFrom(v2Result); + return mergeSourceStampResult( + Result.SourceStampInfo.SourceStampVerificationStatus.VERIFICATION_ERROR, + result); + } + } + + if (minSdkVersion < AndroidSdkVersion.N || foundApkSigSchemeIds.isEmpty()) { + signatureSchemeApkContentDigests.put(VERSION_JAR_SIGNATURE_SCHEME, + getApkContentDigestFromV1SigningScheme(cdRecords, apk, zipSections)); + } + + ApkSigResult sourceStampResult = + V2SourceStampVerifier.verify( + apk, + zipSections, + sourceStampCertificateDigest, + signatureSchemeApkContentDigests, + minSdkVersion, + mMaxSdkVersion); + result.mergeFrom(sourceStampResult); + // Since the caller is only seeking to verify the source stamp the Result can be marked + // as verified if the source stamp verification was successful. + if (sourceStampResult.verified) { + result.setVerified(); + } else { + // To prevent APK signature verification with a failed / missing source stamp the + // source stamp verification will only log warnings; to allow the caller to capture + // the failure reason treat all warnings as errors. + result.setWarningsAsErrors(true); + } + return result; + } catch (ApkFormatException | IOException | ZipFormatException e) { + return createSourceStampResultWithError( + Result.SourceStampInfo.SourceStampVerificationStatus.VERIFICATION_ERROR, + Issue.MALFORMED_APK, e); + } catch (NoSuchAlgorithmException e) { + return createSourceStampResultWithError( + Result.SourceStampInfo.SourceStampVerificationStatus.VERIFICATION_ERROR, + Issue.UNEXPECTED_EXCEPTION, e); + } catch (SignatureNotFoundException e) { + return createSourceStampResultWithError( + Result.SourceStampInfo.SourceStampVerificationStatus.STAMP_NOT_VERIFIED, + Issue.SOURCE_STAMP_SIG_MISSING); + } + } + + /** + * Creates and returns a {@code Result} that can be returned for source stamp verification + * with the provided source stamp {@code verificationStatus}, and logs an error for the + * specified {@code issue} and {@code params}. + */ + private static Result createSourceStampResultWithError( + Result.SourceStampInfo.SourceStampVerificationStatus verificationStatus, Issue issue, + Object... params) { + Result result = new Result(); + result.addError(issue, params); + return mergeSourceStampResult(verificationStatus, result); + } + + /** + * Creates a new {@link Result.SourceStampInfo} under the provided {@code result} and sets the + * source stamp status to the provided {@code verificationStatus}. + */ + private static Result mergeSourceStampResult( + Result.SourceStampInfo.SourceStampVerificationStatus verificationStatus, + Result result) { + result.mSourceStampInfo = new Result.SourceStampInfo(verificationStatus); + return result; + } + + /** + * Gets content digests, signing lineage and certificates from the given {@code schemeId} block + * alongside encountered errors info and creates a new {@code Result} containing all this + * information. + */ + public static Result getSigningBlockResult( + DataSource apk, ApkUtils.ZipSections zipSections, int sdkVersion, int schemeId) + throws IOException, NoSuchAlgorithmException{ + Map<Integer, Map<ContentDigestAlgorithm, byte[]>> sigSchemeApkContentDigests = + new HashMap<>(); + Map<Integer, String> supportedSchemeNames = getSupportedSchemeNames(sdkVersion); + Set<Integer> foundApkSigSchemeIds = new HashSet<>(2); + + Result result = new Result(); + result.mergeFrom(getApkContentDigests(apk, zipSections, + foundApkSigSchemeIds, supportedSchemeNames, sigSchemeApkContentDigests, + schemeId, sdkVersion, sdkVersion)); + return result; + } + + /** + * Gets the content digest from the {@code result}'s signers. Ignores {@code ContentDigest}s + * for which {@code SignatureAlgorithm} is {@code null}. + */ + public static Map<ContentDigestAlgorithm, byte[]> getContentDigestsFromResult( + Result result, int schemeId) { + Map<ContentDigestAlgorithm, byte[]> apkContentDigests = new HashMap<>(); + if (!(schemeId == VERSION_APK_SIGNATURE_SCHEME_V2 + || schemeId == VERSION_APK_SIGNATURE_SCHEME_V3 + || schemeId == VERSION_APK_SIGNATURE_SCHEME_V31)) { + return apkContentDigests; + } + switch (schemeId) { + case VERSION_APK_SIGNATURE_SCHEME_V2: + for (V2SchemeSignerInfo signerInfo : result.getV2SchemeSigners()) { + getContentDigests(signerInfo.getContentDigests(), apkContentDigests); + } + break; + case VERSION_APK_SIGNATURE_SCHEME_V3: + for (Result.V3SchemeSignerInfo signerInfo : result.getV3SchemeSigners()) { + getContentDigests(signerInfo.getContentDigests(), apkContentDigests); + } + break; + case VERSION_APK_SIGNATURE_SCHEME_V31: + for (Result.V3SchemeSignerInfo signerInfo : result.getV31SchemeSigners()) { + getContentDigests(signerInfo.getContentDigests(), apkContentDigests); + } + break; + } + return apkContentDigests; + } + + private static void getContentDigests( + List<ContentDigest> digests, Map<ContentDigestAlgorithm, byte[]> contentDigestsMap) { + for (ApkSigningBlockUtils.Result.SignerInfo.ContentDigest contentDigest : + digests) { + SignatureAlgorithm signatureAlgorithm = SignatureAlgorithm.findById( + contentDigest.getSignatureAlgorithmId()); + if (signatureAlgorithm == null) { + continue; + } + contentDigestsMap.put(signatureAlgorithm.getContentDigestAlgorithm(), + contentDigest.getValue()); + } + } + + /** + * Checks whether a given {@code result} contains errors indicating that a signing certificate + * lineage is incorrect. + */ + public static boolean containsLineageErrors( + Result result) { + if (!result.containsErrors()) { + return false; + } + + return (result.getAllErrors().stream().map(i -> i.getIssue()) + .anyMatch(error -> LINEAGE_RELATED_ISSUES.contains(error))); + } + + + /** + * Gets a lineage from the first signer from a given {@code result}. + * If the {@code result} contains errors related to the lineage incorrectness or there are no + * signers or certificates, it returns {@code null}. + * If the lineage is empty but there is a signer, it returns a 1-element lineage containing + * the signing key. + */ + public static SigningCertificateLineage getLineageFromResult( + Result result, int sdkVersion, int schemeId) + throws CertificateEncodingException, InvalidKeyException, NoSuchAlgorithmException, + SignatureException { + if (!(schemeId == VERSION_APK_SIGNATURE_SCHEME_V3 + || schemeId == VERSION_APK_SIGNATURE_SCHEME_V31) + || containsLineageErrors(result)) { + return null; + } + List<V3SchemeSignerInfo> signersInfo = + schemeId == VERSION_APK_SIGNATURE_SCHEME_V3 ? + result.getV3SchemeSigners() : result.getV31SchemeSigners(); + if (signersInfo.isEmpty()) { + return null; + } + V3SchemeSignerInfo firstSignerInfo = signersInfo.get(0); + SigningCertificateLineage lineage = firstSignerInfo.mSigningCertificateLineage; + if (lineage == null && firstSignerInfo.getCertificate() != null) { + try { + lineage = new SigningCertificateLineage.Builder( + new SignerConfig.Builder( + /* privateKey= */ null, firstSignerInfo.getCertificate()) + .build()).build(); + } catch (Exception e) { + return null; + } + } + return lineage; + } + + /** + * Obtains the APK content digest(s) and adds them to the provided {@code + * sigSchemeApkContentDigests}, returning an {@code ApkSigningBlockUtils.Result} that can be + * merged with a {@code Result} to notify the client of any errors. + * + * <p>Note, this method currently only supports signature scheme V2 and V3; to obtain the + * content digests for V1 signatures use {@link + * #getApkContentDigestFromV1SigningScheme(List, DataSource, ApkUtils.ZipSections)}. If a + * signature scheme version other than V2 or V3 is provided a {@code null} value will be + * returned. + */ + private ApkSigningBlockUtils.Result getApkContentDigests(DataSource apk, + ApkUtils.ZipSections zipSections, Set<Integer> foundApkSigSchemeIds, + Map<Integer, String> supportedSchemeNames, + Map<Integer, Map<ContentDigestAlgorithm, byte[]>> sigSchemeApkContentDigests, + int apkSigSchemeVersion, int minSdkVersion) + throws IOException, NoSuchAlgorithmException { + return getApkContentDigests(apk, zipSections, foundApkSigSchemeIds, supportedSchemeNames, + sigSchemeApkContentDigests, apkSigSchemeVersion, minSdkVersion, mMaxSdkVersion); + } + + + /** + * Obtains the APK content digest(s) and adds them to the provided {@code + * sigSchemeApkContentDigests}, returning an {@code ApkSigningBlockUtils.Result} that can be + * merged with a {@code Result} to notify the client of any errors. + * + * <p>Note, this method currently only supports signature scheme V2 and V3; to obtain the + * content digests for V1 signatures use {@link + * #getApkContentDigestFromV1SigningScheme(List, DataSource, ApkUtils.ZipSections)}. If a + * signature scheme version other than V2 or V3 is provided a {@code null} value will be + * returned. + */ + private static ApkSigningBlockUtils.Result getApkContentDigests(DataSource apk, + ApkUtils.ZipSections zipSections, Set<Integer> foundApkSigSchemeIds, + Map<Integer, String> supportedSchemeNames, + Map<Integer, Map<ContentDigestAlgorithm, byte[]>> sigSchemeApkContentDigests, + int apkSigSchemeVersion, int minSdkVersion, int maxSdkVersion) + throws IOException, NoSuchAlgorithmException { + if (!(apkSigSchemeVersion == VERSION_APK_SIGNATURE_SCHEME_V2 + || apkSigSchemeVersion == VERSION_APK_SIGNATURE_SCHEME_V3 + || apkSigSchemeVersion == VERSION_APK_SIGNATURE_SCHEME_V31)) { + return null; + } + ApkSigningBlockUtils.Result result = new ApkSigningBlockUtils.Result(apkSigSchemeVersion); + SignatureInfo signatureInfo; + try { + int sigSchemeBlockId; + switch (apkSigSchemeVersion) { + case VERSION_APK_SIGNATURE_SCHEME_V31: + sigSchemeBlockId = V3SchemeConstants.APK_SIGNATURE_SCHEME_V31_BLOCK_ID; + break; + case VERSION_APK_SIGNATURE_SCHEME_V3: + sigSchemeBlockId = V3SchemeConstants.APK_SIGNATURE_SCHEME_V3_BLOCK_ID; + break; + default: + sigSchemeBlockId = + V2SchemeConstants.APK_SIGNATURE_SCHEME_V2_BLOCK_ID; + } + signatureInfo = ApkSigningBlockUtils.findSignature(apk, zipSections, + sigSchemeBlockId, result); + } catch (ApkSigningBlockUtils.SignatureNotFoundException e) { + return null; + } + foundApkSigSchemeIds.add(apkSigSchemeVersion); + + Set<ContentDigestAlgorithm> contentDigestsToVerify = new HashSet<>(1); + if (apkSigSchemeVersion == VERSION_APK_SIGNATURE_SCHEME_V2) { + V2SchemeVerifier.parseSigners(signatureInfo.signatureBlock, + contentDigestsToVerify, supportedSchemeNames, + foundApkSigSchemeIds, minSdkVersion, maxSdkVersion, result); + } else { + V3SchemeVerifier.parseSigners(signatureInfo.signatureBlock, + contentDigestsToVerify, result); + } + Map<ContentDigestAlgorithm, byte[]> apkContentDigests = new EnumMap<>( + ContentDigestAlgorithm.class); + for (ApkSigningBlockUtils.Result.SignerInfo signerInfo : result.signers) { + for (ApkSigningBlockUtils.Result.SignerInfo.ContentDigest contentDigest : + signerInfo.contentDigests) { + SignatureAlgorithm signatureAlgorithm = SignatureAlgorithm.findById( + contentDigest.getSignatureAlgorithmId()); + if (signatureAlgorithm == null) { + continue; + } + apkContentDigests.put(signatureAlgorithm.getContentDigestAlgorithm(), + contentDigest.getValue()); + } + } + sigSchemeApkContentDigests.put(apkSigSchemeVersion, apkContentDigests); + return result; + } + + private static void checkV4Signer(List<Result.V3SchemeSignerInfo> v3Signers, + List<X509Certificate> v4Certs, byte[] digestFromV4, Result result) { + if (v3Signers.size() != 1) { + result.addError(Issue.V4_SIG_MULTIPLE_SIGNERS); + } + + // Compare certificates. + checkV4Certificate(v4Certs, v3Signers.get(0).mCerts, result); + + // Compare digests. + final byte[] digestFromV3 = pickBestDigestForV4(v3Signers.get(0).getContentDigests()); + if (!Arrays.equals(digestFromV4, digestFromV3)) { + result.addError(Issue.V4_SIG_V2_V3_DIGESTS_MISMATCH, 3, toHex(digestFromV3), + toHex(digestFromV4)); + } + } + + private static void checkV4Certificate(List<X509Certificate> v4Certs, + List<X509Certificate> v2v3Certs, Result result) { + try { + byte[] v4Cert = v4Certs.get(0).getEncoded(); + byte[] cert = v2v3Certs.get(0).getEncoded(); + if (!Arrays.equals(cert, v4Cert)) { + result.addError(Issue.V4_SIG_V2_V3_SIGNERS_MISMATCH); + } + } catch (CertificateEncodingException e) { + throw new RuntimeException("Failed to encode APK signer cert", e); + } + } + + private static byte[] pickBestDigestForV4( + List<ApkSigningBlockUtils.Result.SignerInfo.ContentDigest> contentDigests) { + Map<ContentDigestAlgorithm, byte[]> apkContentDigests = new HashMap<>(); + collectApkContentDigests(contentDigests, apkContentDigests); + return ApkSigningBlockUtils.pickBestDigestForV4(apkContentDigests); + } + + private static Map<ContentDigestAlgorithm, byte[]> getApkContentDigestsFromSigningSchemeResult( + ApkSigningBlockUtils.Result apkSigningSchemeResult) { + Map<ContentDigestAlgorithm, byte[]> apkContentDigests = new HashMap<>(); + for (ApkSigningBlockUtils.Result.SignerInfo signerInfo : apkSigningSchemeResult.signers) { + collectApkContentDigests(signerInfo.contentDigests, apkContentDigests); + } + return apkContentDigests; + } + + private static Map<ContentDigestAlgorithm, byte[]> getApkContentDigestFromV1SigningScheme( + List<CentralDirectoryRecord> cdRecords, + DataSource apk, + ApkUtils.ZipSections zipSections) + throws IOException, ApkFormatException { + CentralDirectoryRecord manifestCdRecord = null; + Map<ContentDigestAlgorithm, byte[]> v1ContentDigest = new EnumMap<>( + ContentDigestAlgorithm.class); + for (CentralDirectoryRecord cdRecord : cdRecords) { + if (MANIFEST_ENTRY_NAME.equals(cdRecord.getName())) { + manifestCdRecord = cdRecord; + break; + } + } + if (manifestCdRecord == null) { + // No JAR signing manifest file found. For SourceStamp verification, returning an empty + // digest is enough since this would affect the final digest signed by the stamp, and + // thus an empty digest will invalidate that signature. + return v1ContentDigest; + } + try { + byte[] manifestBytes = + LocalFileRecord.getUncompressedData( + apk, manifestCdRecord, zipSections.getZipCentralDirectoryOffset()); + v1ContentDigest.put( + ContentDigestAlgorithm.SHA256, computeSha256DigestBytes(manifestBytes)); + return v1ContentDigest; + } catch (ZipFormatException e) { + throw new ApkFormatException("Failed to read APK", e); + } + } + + private static void collectApkContentDigests( + List<ApkSigningBlockUtils.Result.SignerInfo.ContentDigest> contentDigests, + Map<ContentDigestAlgorithm, byte[]> apkContentDigests) { + for (ApkSigningBlockUtils.Result.SignerInfo.ContentDigest contentDigest : contentDigests) { + SignatureAlgorithm signatureAlgorithm = + SignatureAlgorithm.findById(contentDigest.getSignatureAlgorithmId()); + if (signatureAlgorithm == null) { + continue; + } + ContentDigestAlgorithm contentDigestAlgorithm = + signatureAlgorithm.getContentDigestAlgorithm(); + apkContentDigests.put(contentDigestAlgorithm, contentDigest.getValue()); + } + + } + + private static ByteBuffer getAndroidManifestFromApk( + DataSource apk, ApkUtils.ZipSections zipSections) + throws IOException, ApkFormatException { + List<CentralDirectoryRecord> cdRecords = + V1SchemeVerifier.parseZipCentralDirectory(apk, zipSections); + try { + return ApkSigner.getAndroidManifestFromApk( + cdRecords, + apk.slice(0, zipSections.getZipCentralDirectoryOffset())); + } catch (ZipFormatException e) { + throw new ApkFormatException("Failed to read AndroidManifest.xml", e); + } + } + + private static int getMinimumSignatureSchemeVersionForTargetSdk(int targetSdkVersion) { + if (targetSdkVersion >= AndroidSdkVersion.R) { + return VERSION_APK_SIGNATURE_SCHEME_V2; + } + return VERSION_JAR_SIGNATURE_SCHEME; + } + + /** + * Result of verifying an APKs signatures. The APK can be considered verified iff + * {@link #isVerified()} returns {@code true}. + */ + public static class Result { + private final List<IssueWithParams> mErrors = new ArrayList<>(); + private final List<IssueWithParams> mWarnings = new ArrayList<>(); + private final List<X509Certificate> mSignerCerts = new ArrayList<>(); + private final List<V1SchemeSignerInfo> mV1SchemeSigners = new ArrayList<>(); + private final List<V1SchemeSignerInfo> mV1SchemeIgnoredSigners = new ArrayList<>(); + private final List<V2SchemeSignerInfo> mV2SchemeSigners = new ArrayList<>(); + private final List<V3SchemeSignerInfo> mV3SchemeSigners = new ArrayList<>(); + private final List<V3SchemeSignerInfo> mV31SchemeSigners = new ArrayList<>(); + private final List<V4SchemeSignerInfo> mV4SchemeSigners = new ArrayList<>(); + private SourceStampInfo mSourceStampInfo; + + private boolean mVerified; + private boolean mVerifiedUsingV1Scheme; + private boolean mVerifiedUsingV2Scheme; + private boolean mVerifiedUsingV3Scheme; + private boolean mVerifiedUsingV31Scheme; + private boolean mVerifiedUsingV4Scheme; + private boolean mSourceStampVerified; + private boolean mWarningsAsErrors; + private SigningCertificateLineage mSigningCertificateLineage; + + /** + * Returns {@code true} if the APK's signatures verified. + */ + public boolean isVerified() { + return mVerified; + } + + private void setVerified() { + mVerified = true; + } + + /** + * Returns {@code true} if the APK's JAR signatures verified. + */ + public boolean isVerifiedUsingV1Scheme() { + return mVerifiedUsingV1Scheme; + } + + /** + * Returns {@code true} if the APK's APK Signature Scheme v2 signatures verified. + */ + public boolean isVerifiedUsingV2Scheme() { + return mVerifiedUsingV2Scheme; + } + + /** + * Returns {@code true} if the APK's APK Signature Scheme v3 signature verified. + */ + public boolean isVerifiedUsingV3Scheme() { + return mVerifiedUsingV3Scheme; + } + + /** + * Returns {@code true} if the APK's APK Signature Scheme v3.1 signature verified. + */ + public boolean isVerifiedUsingV31Scheme() { + return mVerifiedUsingV31Scheme; + } + + /** + * Returns {@code true} if the APK's APK Signature Scheme v4 signature verified. + */ + public boolean isVerifiedUsingV4Scheme() { + return mVerifiedUsingV4Scheme; + } + + /** + * Returns {@code true} if the APK's SourceStamp signature verified. + */ + public boolean isSourceStampVerified() { + return mSourceStampVerified; + } + + /** + * Returns the verified signers' certificates, one per signer. + */ + public List<X509Certificate> getSignerCertificates() { + return mSignerCerts; + } + + private void addSignerCertificate(X509Certificate cert) { + mSignerCerts.add(cert); + } + + /** + * Returns information about JAR signers associated with the APK's signature. These are the + * signers used by Android. + * + * @see #getV1SchemeIgnoredSigners() + */ + public List<V1SchemeSignerInfo> getV1SchemeSigners() { + return mV1SchemeSigners; + } + + /** + * Returns information about JAR signers ignored by the APK's signature verification + * process. These signers are ignored by Android. However, each signer's errors or warnings + * will contain information about why they are ignored. + * + * @see #getV1SchemeSigners() + */ + public List<V1SchemeSignerInfo> getV1SchemeIgnoredSigners() { + return mV1SchemeIgnoredSigners; + } + + /** + * Returns information about APK Signature Scheme v2 signers associated with the APK's + * signature. + */ + public List<V2SchemeSignerInfo> getV2SchemeSigners() { + return mV2SchemeSigners; + } + + /** + * Returns information about APK Signature Scheme v3 signers associated with the APK's + * signature. + * + * <note> Multiple signers represent different targeted platform versions, not + * a signing identity of multiple signers. APK Signature Scheme v3 only supports single + * signer identities.</note> + */ + public List<V3SchemeSignerInfo> getV3SchemeSigners() { + return mV3SchemeSigners; + } + + /** + * Returns information about APK Signature Scheme v3.1 signers associated with the APK's + * signature. + * + * <note> Multiple signers represent different targeted platform versions, not + * a signing identity of multiple signers. APK Signature Scheme v3.1 only supports single + * signer identities.</note> + */ + public List<V3SchemeSignerInfo> getV31SchemeSigners() { + return mV31SchemeSigners; + } + + /** + * Returns information about APK Signature Scheme v4 signers associated with the APK's + * signature. + */ + public List<V4SchemeSignerInfo> getV4SchemeSigners() { + return mV4SchemeSigners; + } + + /** + * Returns information about SourceStamp associated with the APK's signature. + */ + public SourceStampInfo getSourceStampInfo() { + return mSourceStampInfo; + } + + /** + * Returns the combined SigningCertificateLineage associated with this APK's APK Signature + * Scheme v3 signing block. + */ + public SigningCertificateLineage getSigningCertificateLineage() { + return mSigningCertificateLineage; + } + + void addError(Issue msg, Object... parameters) { + mErrors.add(new IssueWithParams(msg, parameters)); + } + + void addWarning(Issue msg, Object... parameters) { + mWarnings.add(new IssueWithParams(msg, parameters)); + } + + /** + * Sets whether warnings should be treated as errors. + */ + void setWarningsAsErrors(boolean value) { + mWarningsAsErrors = value; + } + + /** + * Returns errors encountered while verifying the APK's signatures. + */ + public List<IssueWithParams> getErrors() { + if (!mWarningsAsErrors) { + return mErrors; + } else { + List<IssueWithParams> allErrors = new ArrayList<>(); + allErrors.addAll(mErrors); + allErrors.addAll(mWarnings); + return allErrors; + } + } + + /** + * Returns warnings encountered while verifying the APK's signatures. + */ + public List<IssueWithParams> getWarnings() { + return mWarnings; + } + + private void mergeFrom(V1SchemeVerifier.Result source) { + mVerifiedUsingV1Scheme = source.verified; + mErrors.addAll(source.getErrors()); + mWarnings.addAll(source.getWarnings()); + for (V1SchemeVerifier.Result.SignerInfo signer : source.signers) { + mV1SchemeSigners.add(new V1SchemeSignerInfo(signer)); + } + for (V1SchemeVerifier.Result.SignerInfo signer : source.ignoredSigners) { + mV1SchemeIgnoredSigners.add(new V1SchemeSignerInfo(signer)); + } + } + + private void mergeFrom(ApkSigResult source) { + switch (source.signatureSchemeVersion) { + case VERSION_SOURCE_STAMP: + mSourceStampVerified = source.verified; + if (!source.mSigners.isEmpty()) { + mSourceStampInfo = new SourceStampInfo(source.mSigners.get(0)); + } + break; + default: + throw new IllegalArgumentException( + "Unknown ApkSigResult Signing Block Scheme Id " + + source.signatureSchemeVersion); + } + } + + private void mergeFrom(ApkSigningBlockUtils.Result source) { + if (source == null) { + return; + } + if (source.containsErrors()) { + mErrors.addAll(source.getErrors()); + } + if (source.containsWarnings()) { + mWarnings.addAll(source.getWarnings()); + } + switch (source.signatureSchemeVersion) { + case VERSION_APK_SIGNATURE_SCHEME_V2: + mVerifiedUsingV2Scheme = source.verified; + for (ApkSigningBlockUtils.Result.SignerInfo signer : source.signers) { + mV2SchemeSigners.add(new V2SchemeSignerInfo(signer)); + } + break; + case VERSION_APK_SIGNATURE_SCHEME_V3: + mVerifiedUsingV3Scheme = source.verified; + for (ApkSigningBlockUtils.Result.SignerInfo signer : source.signers) { + mV3SchemeSigners.add(new V3SchemeSignerInfo(signer)); + } + // Do not overwrite a previously set lineage from a v3.1 signing block. + if (mSigningCertificateLineage == null) { + mSigningCertificateLineage = source.signingCertificateLineage; + } + break; + case VERSION_APK_SIGNATURE_SCHEME_V31: + mVerifiedUsingV31Scheme = source.verified; + for (ApkSigningBlockUtils.Result.SignerInfo signer : source.signers) { + mV31SchemeSigners.add(new V3SchemeSignerInfo(signer)); + } + mSigningCertificateLineage = source.signingCertificateLineage; + break; + case VERSION_APK_SIGNATURE_SCHEME_V4: + mVerifiedUsingV4Scheme = source.verified; + for (ApkSigningBlockUtils.Result.SignerInfo signer : source.signers) { + mV4SchemeSigners.add(new V4SchemeSignerInfo(signer)); + } + break; + case VERSION_SOURCE_STAMP: + mSourceStampVerified = source.verified; + if (!source.signers.isEmpty()) { + mSourceStampInfo = new SourceStampInfo(source.signers.get(0)); + } + break; + default: + throw new IllegalArgumentException("Unknown Signing Block Scheme Id"); + } + } + + /** + * Returns {@code true} if an error was encountered while verifying the APK. Any error + * prevents the APK from being considered verified. + */ + public boolean containsErrors() { + if (!mErrors.isEmpty()) { + return true; + } + if (mWarningsAsErrors && !mWarnings.isEmpty()) { + return true; + } + if (!mV1SchemeSigners.isEmpty()) { + for (V1SchemeSignerInfo signer : mV1SchemeSigners) { + if (signer.containsErrors()) { + return true; + } + if (mWarningsAsErrors && !signer.getWarnings().isEmpty()) { + return true; + } + } + } + if (!mV2SchemeSigners.isEmpty()) { + for (V2SchemeSignerInfo signer : mV2SchemeSigners) { + if (signer.containsErrors()) { + return true; + } + if (mWarningsAsErrors && !signer.getWarnings().isEmpty()) { + return true; + } + } + } + if (!mV3SchemeSigners.isEmpty()) { + for (V3SchemeSignerInfo signer : mV3SchemeSigners) { + if (signer.containsErrors()) { + return true; + } + if (mWarningsAsErrors && !signer.getWarnings().isEmpty()) { + return true; + } + } + } + if (!mV31SchemeSigners.isEmpty()) { + for (V3SchemeSignerInfo signer : mV31SchemeSigners) { + if (signer.containsErrors()) { + return true; + } + if (mWarningsAsErrors && !signer.getWarnings().isEmpty()) { + return true; + } + } + } + if (mSourceStampInfo != null) { + if (mSourceStampInfo.containsErrors()) { + return true; + } + if (mWarningsAsErrors && !mSourceStampInfo.getWarnings().isEmpty()) { + return true; + } + } + + return false; + } + + /** + * Returns all errors for this result, including any errors from signature scheme signers + * and the source stamp. + */ + public List<IssueWithParams> getAllErrors() { + List<IssueWithParams> errors = new ArrayList<>(); + errors.addAll(mErrors); + if (mWarningsAsErrors) { + errors.addAll(mWarnings); + } + if (!mV1SchemeSigners.isEmpty()) { + for (V1SchemeSignerInfo signer : mV1SchemeSigners) { + errors.addAll(signer.mErrors); + if (mWarningsAsErrors) { + errors.addAll(signer.getWarnings()); + } + } + } + if (!mV2SchemeSigners.isEmpty()) { + for (V2SchemeSignerInfo signer : mV2SchemeSigners) { + errors.addAll(signer.mErrors); + if (mWarningsAsErrors) { + errors.addAll(signer.getWarnings()); + } + } + } + if (!mV3SchemeSigners.isEmpty()) { + for (V3SchemeSignerInfo signer : mV3SchemeSigners) { + errors.addAll(signer.mErrors); + if (mWarningsAsErrors) { + errors.addAll(signer.getWarnings()); + } + } + } + if (!mV31SchemeSigners.isEmpty()) { + for (V3SchemeSignerInfo signer : mV31SchemeSigners) { + errors.addAll(signer.mErrors); + if (mWarningsAsErrors) { + errors.addAll(signer.getWarnings()); + } + } + } + if (mSourceStampInfo != null) { + errors.addAll(mSourceStampInfo.getErrors()); + if (mWarningsAsErrors) { + errors.addAll(mSourceStampInfo.getWarnings()); + } + } + return errors; + } + + /** + * Information about a JAR signer associated with the APK's signature. + */ + public static class V1SchemeSignerInfo { + private final String mName; + private final List<X509Certificate> mCertChain; + private final String mSignatureBlockFileName; + private final String mSignatureFileName; + + private final List<IssueWithParams> mErrors; + private final List<IssueWithParams> mWarnings; + + private V1SchemeSignerInfo(V1SchemeVerifier.Result.SignerInfo result) { + mName = result.name; + mCertChain = result.certChain; + mSignatureBlockFileName = result.signatureBlockFileName; + mSignatureFileName = result.signatureFileName; + mErrors = result.getErrors(); + mWarnings = result.getWarnings(); + } + + /** + * Returns a user-friendly name of the signer. + */ + public String getName() { + return mName; + } + + /** + * Returns the name of the JAR entry containing this signer's JAR signature block file. + */ + public String getSignatureBlockFileName() { + return mSignatureBlockFileName; + } + + /** + * Returns the name of the JAR entry containing this signer's JAR signature file. + */ + public String getSignatureFileName() { + return mSignatureFileName; + } + + /** + * Returns this signer's signing certificate or {@code null} if not available. The + * certificate is guaranteed to be available if no errors were encountered during + * verification (see {@link #containsErrors()}. + * + * <p>This certificate contains the signer's public key. + */ + public X509Certificate getCertificate() { + return mCertChain.isEmpty() ? null : mCertChain.get(0); + } + + /** + * Returns the certificate chain for the signer's public key. The certificate containing + * the public key is first, followed by the certificate (if any) which issued the + * signing certificate, and so forth. An empty list may be returned if an error was + * encountered during verification (see {@link #containsErrors()}). + */ + public List<X509Certificate> getCertificateChain() { + return mCertChain; + } + + /** + * Returns {@code true} if an error was encountered while verifying this signer's JAR + * signature. Any error prevents the signer's signature from being considered verified. + */ + public boolean containsErrors() { + return !mErrors.isEmpty(); + } + + /** + * Returns errors encountered while verifying this signer's JAR signature. Any error + * prevents the signer's signature from being considered verified. + */ + public List<IssueWithParams> getErrors() { + return mErrors; + } + + /** + * Returns warnings encountered while verifying this signer's JAR signature. Warnings + * do not prevent the signer's signature from being considered verified. + */ + public List<IssueWithParams> getWarnings() { + return mWarnings; + } + + private void addError(Issue msg, Object... parameters) { + mErrors.add(new IssueWithParams(msg, parameters)); + } + } + + /** + * Information about an APK Signature Scheme v2 signer associated with the APK's signature. + */ + public static class V2SchemeSignerInfo { + private final int mIndex; + private final List<X509Certificate> mCerts; + + private final List<IssueWithParams> mErrors; + private final List<IssueWithParams> mWarnings; + private final List<ApkSigningBlockUtils.Result.SignerInfo.ContentDigest> + mContentDigests; + + private V2SchemeSignerInfo(ApkSigningBlockUtils.Result.SignerInfo result) { + mIndex = result.index; + mCerts = result.certs; + mErrors = result.getErrors(); + mWarnings = result.getWarnings(); + mContentDigests = result.contentDigests; + } + + /** + * Returns this signer's {@code 0}-based index in the list of signers contained in the + * APK's APK Signature Scheme v2 signature. + */ + public int getIndex() { + return mIndex; + } + + /** + * Returns this signer's signing certificate or {@code null} if not available. The + * certificate is guaranteed to be available if no errors were encountered during + * verification (see {@link #containsErrors()}. + * + * <p>This certificate contains the signer's public key. + */ + public X509Certificate getCertificate() { + return mCerts.isEmpty() ? null : mCerts.get(0); + } + + /** + * Returns this signer's certificates. The first certificate is for the signer's public + * key. An empty list may be returned if an error was encountered during verification + * (see {@link #containsErrors()}). + */ + public List<X509Certificate> getCertificates() { + return mCerts; + } + + private void addError(Issue msg, Object... parameters) { + mErrors.add(new IssueWithParams(msg, parameters)); + } + + public boolean containsErrors() { + return !mErrors.isEmpty(); + } + + public List<IssueWithParams> getErrors() { + return mErrors; + } + + public List<IssueWithParams> getWarnings() { + return mWarnings; + } + + public List<ApkSigningBlockUtils.Result.SignerInfo.ContentDigest> getContentDigests() { + return mContentDigests; + } + } + + /** + * Information about an APK Signature Scheme v3 signer associated with the APK's signature. + */ + public static class V3SchemeSignerInfo { + private final int mIndex; + private final List<X509Certificate> mCerts; + + private final List<IssueWithParams> mErrors; + private final List<IssueWithParams> mWarnings; + private final List<ApkSigningBlockUtils.Result.SignerInfo.ContentDigest> + mContentDigests; + private final int mMinSdkVersion; + private final int mMaxSdkVersion; + private final boolean mRotationTargetsDevRelease; + private final SigningCertificateLineage mSigningCertificateLineage; + + private V3SchemeSignerInfo(ApkSigningBlockUtils.Result.SignerInfo result) { + mIndex = result.index; + mCerts = result.certs; + mErrors = result.getErrors(); + mWarnings = result.getWarnings(); + mContentDigests = result.contentDigests; + mMinSdkVersion = result.minSdkVersion; + mMaxSdkVersion = result.maxSdkVersion; + mSigningCertificateLineage = result.signingCertificateLineage; + mRotationTargetsDevRelease = result.additionalAttributes.stream().mapToInt( + attribute -> attribute.getId()).anyMatch( + attrId -> attrId == V3SchemeConstants.ROTATION_ON_DEV_RELEASE_ATTR_ID); + } + + /** + * Returns this signer's {@code 0}-based index in the list of signers contained in the + * APK's APK Signature Scheme v3 signature. + */ + public int getIndex() { + return mIndex; + } + + /** + * Returns this signer's signing certificate or {@code null} if not available. The + * certificate is guaranteed to be available if no errors were encountered during + * verification (see {@link #containsErrors()}. + * + * <p>This certificate contains the signer's public key. + */ + public X509Certificate getCertificate() { + return mCerts.isEmpty() ? null : mCerts.get(0); + } + + /** + * Returns this signer's certificates. The first certificate is for the signer's public + * key. An empty list may be returned if an error was encountered during verification + * (see {@link #containsErrors()}). + */ + public List<X509Certificate> getCertificates() { + return mCerts; + } + + public boolean containsErrors() { + return !mErrors.isEmpty(); + } + + public List<IssueWithParams> getErrors() { + return mErrors; + } + + public List<IssueWithParams> getWarnings() { + return mWarnings; + } + + public List<ApkSigningBlockUtils.Result.SignerInfo.ContentDigest> getContentDigests() { + return mContentDigests; + } + + /** + * Returns the minimum SDK version on which this signer should be verified. + */ + public int getMinSdkVersion() { + return mMinSdkVersion; + } + + /** + * Returns the maximum SDK version on which this signer should be verified. + */ + public int getMaxSdkVersion() { + return mMaxSdkVersion; + } + + /** + * Returns whether rotation is targeting a development release. + * + * <p>A development release uses the SDK version of the previously released platform + * until the SDK of the development release is finalized. To allow rotation to target + * a development release after T, this attribute must be set to ensure rotation is + * used on the development release but ignored on the released platform with the same + * API level. + */ + public boolean getRotationTargetsDevRelease() { + return mRotationTargetsDevRelease; + } + + /** + * Returns the {@link SigningCertificateLineage} for this signer; when an APK has + * SDK targeted signing configs, the lineage of each signer could potentially contain + * a subset of the full signing lineage and / or different capabilities for each signer + * in the lineage. + */ + public SigningCertificateLineage getSigningCertificateLineage() { + return mSigningCertificateLineage; + } + } + + /** + * Information about an APK Signature Scheme V4 signer associated with the APK's + * signature. + */ + public static class V4SchemeSignerInfo { + private final int mIndex; + private final List<X509Certificate> mCerts; + + private final List<IssueWithParams> mErrors; + private final List<IssueWithParams> mWarnings; + private final List<ApkSigningBlockUtils.Result.SignerInfo.ContentDigest> + mContentDigests; + + private V4SchemeSignerInfo(ApkSigningBlockUtils.Result.SignerInfo result) { + mIndex = result.index; + mCerts = result.certs; + mErrors = result.getErrors(); + mWarnings = result.getWarnings(); + mContentDigests = result.contentDigests; + } + + /** + * Returns this signer's {@code 0}-based index in the list of signers contained in the + * APK's APK Signature Scheme v3 signature. + */ + public int getIndex() { + return mIndex; + } + + /** + * Returns this signer's signing certificate or {@code null} if not available. The + * certificate is guaranteed to be available if no errors were encountered during + * verification (see {@link #containsErrors()}. + * + * <p>This certificate contains the signer's public key. + */ + public X509Certificate getCertificate() { + return mCerts.isEmpty() ? null : mCerts.get(0); + } + + /** + * Returns this signer's certificates. The first certificate is for the signer's public + * key. An empty list may be returned if an error was encountered during verification + * (see {@link #containsErrors()}). + */ + public List<X509Certificate> getCertificates() { + return mCerts; + } + + public boolean containsErrors() { + return !mErrors.isEmpty(); + } + + public List<IssueWithParams> getErrors() { + return mErrors; + } + + public List<IssueWithParams> getWarnings() { + return mWarnings; + } + + public List<ApkSigningBlockUtils.Result.SignerInfo.ContentDigest> getContentDigests() { + return mContentDigests; + } + } + + /** + * Information about SourceStamp associated with the APK's signature. + */ + public static class SourceStampInfo { + public enum SourceStampVerificationStatus { + /** The stamp is present and was successfully verified. */ + STAMP_VERIFIED, + /** The stamp is present but failed verification. */ + STAMP_VERIFICATION_FAILED, + /** The expected cert digest did not match the digest in the APK. */ + CERT_DIGEST_MISMATCH, + /** The stamp is not present at all. */ + STAMP_MISSING, + /** The stamp is at least partially present, but was not able to be verified. */ + STAMP_NOT_VERIFIED, + /** The stamp was not able to be verified due to an unexpected error. */ + VERIFICATION_ERROR + } + + private final List<X509Certificate> mCertificates; + private final List<X509Certificate> mCertificateLineage; + + private final List<IssueWithParams> mErrors; + private final List<IssueWithParams> mWarnings; + private final List<IssueWithParams> mInfoMessages; + + private final SourceStampVerificationStatus mSourceStampVerificationStatus; + + private final long mTimestamp; + + private SourceStampInfo(ApkSignerInfo result) { + mCertificates = result.certs; + mCertificateLineage = result.certificateLineage; + mErrors = ApkVerificationIssueAdapter.getIssuesFromVerificationIssues( + result.getErrors()); + mWarnings = ApkVerificationIssueAdapter.getIssuesFromVerificationIssues( + result.getWarnings()); + mInfoMessages = ApkVerificationIssueAdapter.getIssuesFromVerificationIssues( + result.getInfoMessages()); + if (mErrors.isEmpty() && mWarnings.isEmpty()) { + mSourceStampVerificationStatus = SourceStampVerificationStatus.STAMP_VERIFIED; + } else { + mSourceStampVerificationStatus = + SourceStampVerificationStatus.STAMP_VERIFICATION_FAILED; + } + mTimestamp = result.timestamp; + } + + SourceStampInfo(SourceStampVerificationStatus sourceStampVerificationStatus) { + mCertificates = Collections.emptyList(); + mCertificateLineage = Collections.emptyList(); + mErrors = Collections.emptyList(); + mWarnings = Collections.emptyList(); + mInfoMessages = Collections.emptyList(); + mSourceStampVerificationStatus = sourceStampVerificationStatus; + mTimestamp = 0; + } + + /** + * Returns the SourceStamp's signing certificate or {@code null} if not available. The + * certificate is guaranteed to be available if no errors were encountered during + * verification (see {@link #containsErrors()}. + * + * <p>This certificate contains the SourceStamp's public key. + */ + public X509Certificate getCertificate() { + return mCertificates.isEmpty() ? null : mCertificates.get(0); + } + + /** + * Returns a list containing all of the certificates in the stamp certificate lineage. + */ + public List<X509Certificate> getCertificatesInLineage() { + return mCertificateLineage; + } + + public boolean containsErrors() { + return !mErrors.isEmpty(); + } + + /** + * Returns {@code true} if any info messages were encountered during verification of + * this source stamp. + */ + public boolean containsInfoMessages() { + return !mInfoMessages.isEmpty(); + } + + public List<IssueWithParams> getErrors() { + return mErrors; + } + + public List<IssueWithParams> getWarnings() { + return mWarnings; + } + + /** + * Returns a {@code List} of {@link IssueWithParams} representing info messages + * that were encountered during verification of the source stamp. + */ + public List<IssueWithParams> getInfoMessages() { + return mInfoMessages; + } + + /** + * Returns the reason for any source stamp verification failures, or {@code + * STAMP_VERIFIED} if the source stamp was successfully verified. + */ + public SourceStampVerificationStatus getSourceStampVerificationStatus() { + return mSourceStampVerificationStatus; + } + + /** + * Returns the epoch timestamp in seconds representing the time this source stamp block + * was signed, or 0 if the timestamp is not available. + */ + public long getTimestampEpochSeconds() { + return mTimestamp; + } + } + } + + /** + * Error or warning encountered while verifying an APK's signatures. + */ + public enum Issue { + + /** + * APK is not JAR-signed. + */ + JAR_SIG_NO_SIGNATURES("No JAR signatures"), + + /** + * APK signature scheme v1 has exceeded the maximum number of jar signers. + * <ul> + * <li>Parameter 1: maximum allowed signers ({@code Integer})</li> + * <li>Parameter 2: total number of signers ({@code Integer})</li> + * </ul> + */ + JAR_SIG_MAX_SIGNATURES_EXCEEDED( + "APK Signature Scheme v1 only supports a maximum of %1$d signers, found %2$d"), + + /** + * APK does not contain any entries covered by JAR signatures. + */ + JAR_SIG_NO_SIGNED_ZIP_ENTRIES("No JAR entries covered by JAR signatures"), + + /** + * APK contains multiple entries with the same name. + * + * <ul> + * <li>Parameter 1: name ({@code String})</li> + * </ul> + */ + JAR_SIG_DUPLICATE_ZIP_ENTRY("Duplicate entry: %1$s"), + + /** + * JAR manifest contains a section with a duplicate name. + * + * <ul> + * <li>Parameter 1: section name ({@code String})</li> + * </ul> + */ + JAR_SIG_DUPLICATE_MANIFEST_SECTION("Duplicate section in META-INF/MANIFEST.MF: %1$s"), + + /** + * JAR manifest contains a section without a name. + * + * <ul> + * <li>Parameter 1: section index (1-based) ({@code Integer})</li> + * </ul> + */ + JAR_SIG_UNNNAMED_MANIFEST_SECTION( + "Malformed META-INF/MANIFEST.MF: invidual section #%1$d does not have a name"), + + /** + * JAR signature file contains a section without a name. + * + * <ul> + * <li>Parameter 1: signature file name ({@code String})</li> + * <li>Parameter 2: section index (1-based) ({@code Integer})</li> + * </ul> + */ + JAR_SIG_UNNNAMED_SIG_FILE_SECTION( + "Malformed %1$s: invidual section #%2$d does not have a name"), + + /** APK is missing the JAR manifest entry (META-INF/MANIFEST.MF). */ + JAR_SIG_NO_MANIFEST("Missing META-INF/MANIFEST.MF"), + + /** + * JAR manifest references an entry which is not there in the APK. + * + * <ul> + * <li>Parameter 1: entry name ({@code String})</li> + * </ul> + */ + JAR_SIG_MISSING_ZIP_ENTRY_REFERENCED_IN_MANIFEST( + "%1$s entry referenced by META-INF/MANIFEST.MF not found in the APK"), + + /** + * JAR manifest does not list a digest for the specified entry. + * + * <ul> + * <li>Parameter 1: entry name ({@code String})</li> + * </ul> + */ + JAR_SIG_NO_ZIP_ENTRY_DIGEST_IN_MANIFEST("No digest for %1$s in META-INF/MANIFEST.MF"), + + /** + * JAR signature does not list a digest for the specified entry. + * + * <ul> + * <li>Parameter 1: entry name ({@code String})</li> + * <li>Parameter 2: signature file name ({@code String})</li> + * </ul> + */ + JAR_SIG_NO_ZIP_ENTRY_DIGEST_IN_SIG_FILE("No digest for %1$s in %2$s"), + + /** + * The specified JAR entry is not covered by JAR signature. + * + * <ul> + * <li>Parameter 1: entry name ({@code String})</li> + * </ul> + */ + JAR_SIG_ZIP_ENTRY_NOT_SIGNED("%1$s entry not signed"), + + /** + * JAR signature uses different set of signers to protect the two specified ZIP entries. + * + * <ul> + * <li>Parameter 1: first entry name ({@code String})</li> + * <li>Parameter 2: first entry signer names ({@code List<String>})</li> + * <li>Parameter 3: second entry name ({@code String})</li> + * <li>Parameter 4: second entry signer names ({@code List<String>})</li> + * </ul> + */ + JAR_SIG_ZIP_ENTRY_SIGNERS_MISMATCH( + "Entries %1$s and %3$s are signed with different sets of signers" + + " : <%2$s> vs <%4$s>"), + + /** + * Digest of the specified ZIP entry's data does not match the digest expected by the JAR + * signature. + * + * <ul> + * <li>Parameter 1: entry name ({@code String})</li> + * <li>Parameter 2: digest algorithm (e.g., SHA-256) ({@code String})</li> + * <li>Parameter 3: name of the entry in which the expected digest is specified + * ({@code String})</li> + * <li>Parameter 4: base64-encoded actual digest ({@code String})</li> + * <li>Parameter 5: base64-encoded expected digest ({@code String})</li> + * </ul> + */ + JAR_SIG_ZIP_ENTRY_DIGEST_DID_NOT_VERIFY( + "%2$s digest of %1$s does not match the digest specified in %3$s" + + ". Expected: <%5$s>, actual: <%4$s>"), + + /** + * Digest of the JAR manifest main section did not verify. + * + * <ul> + * <li>Parameter 1: digest algorithm (e.g., SHA-256) ({@code String})</li> + * <li>Parameter 2: name of the entry in which the expected digest is specified + * ({@code String})</li> + * <li>Parameter 3: base64-encoded actual digest ({@code String})</li> + * <li>Parameter 4: base64-encoded expected digest ({@code String})</li> + * </ul> + */ + JAR_SIG_MANIFEST_MAIN_SECTION_DIGEST_DID_NOT_VERIFY( + "%1$s digest of META-INF/MANIFEST.MF main section does not match the digest" + + " specified in %2$s. Expected: <%4$s>, actual: <%3$s>"), + + /** + * Digest of the specified JAR manifest section does not match the digest expected by the + * JAR signature. + * + * <ul> + * <li>Parameter 1: section name ({@code String})</li> + * <li>Parameter 2: digest algorithm (e.g., SHA-256) ({@code String})</li> + * <li>Parameter 3: name of the signature file in which the expected digest is specified + * ({@code String})</li> + * <li>Parameter 4: base64-encoded actual digest ({@code String})</li> + * <li>Parameter 5: base64-encoded expected digest ({@code String})</li> + * </ul> + */ + JAR_SIG_MANIFEST_SECTION_DIGEST_DID_NOT_VERIFY( + "%2$s digest of META-INF/MANIFEST.MF section for %1$s does not match the digest" + + " specified in %3$s. Expected: <%5$s>, actual: <%4$s>"), + + /** + * JAR signature file does not contain the whole-file digest of the JAR manifest file. The + * digest speeds up verification of JAR signature. + * + * <ul> + * <li>Parameter 1: name of the signature file ({@code String})</li> + * </ul> + */ + JAR_SIG_NO_MANIFEST_DIGEST_IN_SIG_FILE( + "%1$s does not specify digest of META-INF/MANIFEST.MF" + + ". This slows down verification."), + + /** + * APK is signed using APK Signature Scheme v2 or newer, but JAR signature file does not + * contain protections against stripping of these newer scheme signatures. + * + * <ul> + * <li>Parameter 1: name of the signature file ({@code String})</li> + * </ul> + */ + JAR_SIG_NO_APK_SIG_STRIP_PROTECTION( + "APK is signed using APK Signature Scheme v2 but these signatures may be stripped" + + " without being detected because %1$s does not contain anti-stripping" + + " protections."), + + /** + * JAR signature of the signer is missing a file/entry. + * + * <ul> + * <li>Parameter 1: name of the encountered file ({@code String})</li> + * <li>Parameter 2: name of the missing file ({@code String})</li> + * </ul> + */ + JAR_SIG_MISSING_FILE("Partial JAR signature. Found: %1$s, missing: %2$s"), + + /** + * An exception was encountered while verifying JAR signature contained in a signature block + * against the signature file. + * + * <ul> + * <li>Parameter 1: name of the signature block file ({@code String})</li> + * <li>Parameter 2: name of the signature file ({@code String})</li> + * <li>Parameter 3: exception ({@code Throwable})</li> + * </ul> + */ + JAR_SIG_VERIFY_EXCEPTION("Failed to verify JAR signature %1$s against %2$s: %3$s"), + + /** + * JAR signature contains unsupported digest algorithm. + * + * <ul> + * <li>Parameter 1: name of the signature block file ({@code String})</li> + * <li>Parameter 2: digest algorithm OID ({@code String})</li> + * <li>Parameter 3: signature algorithm OID ({@code String})</li> + * <li>Parameter 4: API Levels on which this combination of algorithms is not supported + * ({@code String})</li> + * <li>Parameter 5: user-friendly variant of digest algorithm ({@code String})</li> + * <li>Parameter 6: user-friendly variant of signature algorithm ({@code String})</li> + * </ul> + */ + JAR_SIG_UNSUPPORTED_SIG_ALG( + "JAR signature %1$s uses digest algorithm %5$s and signature algorithm %6$s which" + + " is not supported on API Level(s) %4$s for which this APK is being" + + " verified"), + + /** + * An exception was encountered while parsing JAR signature contained in a signature block. + * + * <ul> + * <li>Parameter 1: name of the signature block file ({@code String})</li> + * <li>Parameter 2: exception ({@code Throwable})</li> + * </ul> + */ + JAR_SIG_PARSE_EXCEPTION("Failed to parse JAR signature %1$s: %2$s"), + + /** + * An exception was encountered while parsing a certificate contained in the JAR signature + * block. + * + * <ul> + * <li>Parameter 1: name of the signature block file ({@code String})</li> + * <li>Parameter 2: exception ({@code Throwable})</li> + * </ul> + */ + JAR_SIG_MALFORMED_CERTIFICATE("Malformed certificate in JAR signature %1$s: %2$s"), + + /** + * JAR signature contained in a signature block file did not verify against the signature + * file. + * + * <ul> + * <li>Parameter 1: name of the signature block file ({@code String})</li> + * <li>Parameter 2: name of the signature file ({@code String})</li> + * </ul> + */ + JAR_SIG_DID_NOT_VERIFY("JAR signature %1$s did not verify against %2$s"), + + /** + * JAR signature contains no verified signers. + * + * <ul> + * <li>Parameter 1: name of the signature block file ({@code String})</li> + * </ul> + */ + JAR_SIG_NO_SIGNERS("JAR signature %1$s contains no signers"), + + /** + * JAR signature file contains a section with a duplicate name. + * + * <ul> + * <li>Parameter 1: signature file name ({@code String})</li> + * <li>Parameter 1: section name ({@code String})</li> + * </ul> + */ + JAR_SIG_DUPLICATE_SIG_FILE_SECTION("Duplicate section in %1$s: %2$s"), + + /** + * JAR signature file's main section doesn't contain the mandatory Signature-Version + * attribute. + * + * <ul> + * <li>Parameter 1: signature file name ({@code String})</li> + * </ul> + */ + JAR_SIG_MISSING_VERSION_ATTR_IN_SIG_FILE( + "Malformed %1$s: missing Signature-Version attribute"), + + /** + * JAR signature file references an unknown APK signature scheme ID. + * + * <ul> + * <li>Parameter 1: name of the signature file ({@code String})</li> + * <li>Parameter 2: unknown APK signature scheme ID ({@code} Integer)</li> + * </ul> + */ + JAR_SIG_UNKNOWN_APK_SIG_SCHEME_ID( + "JAR signature %1$s references unknown APK signature scheme ID: %2$d"), + + /** + * JAR signature file indicates that the APK is supposed to be signed with a supported APK + * signature scheme (in addition to the JAR signature) but no such signature was found in + * the APK. + * + * <ul> + * <li>Parameter 1: name of the signature file ({@code String})</li> + * <li>Parameter 2: APK signature scheme ID ({@code} Integer)</li> + * <li>Parameter 3: APK signature scheme English name ({@code} String)</li> + * </ul> + */ + JAR_SIG_MISSING_APK_SIG_REFERENCED( + "JAR signature %1$s indicates the APK is signed using %3$s but no such signature" + + " was found. Signature stripped?"), + + /** + * JAR entry is not covered by signature and thus unauthorized modifications to its contents + * will not be detected. + * + * <ul> + * <li>Parameter 1: entry name ({@code String})</li> + * </ul> + */ + JAR_SIG_UNPROTECTED_ZIP_ENTRY( + "%1$s not protected by signature. Unauthorized modifications to this JAR entry" + + " will not be detected. Delete or move the entry outside of META-INF/."), + + /** + * APK which is both JAR-signed and signed using APK Signature Scheme v2 contains an APK + * Signature Scheme v2 signature from this signer, but does not contain a JAR signature + * from this signer. + */ + JAR_SIG_MISSING("No JAR signature from this signer"), + + /** + * APK is targeting a sandbox version which requires APK Signature Scheme v2 signature but + * no such signature was found. + * + * <ul> + * <li>Parameter 1: target sandbox version ({@code Integer})</li> + * </ul> + */ + NO_SIG_FOR_TARGET_SANDBOX_VERSION( + "Missing APK Signature Scheme v2 signature required for target sandbox version" + + " %1$d"), + + /** + * APK is targeting an SDK version that requires a minimum signature scheme version, but the + * APK is not signed with that version or later. + * + * <ul> + * <li>Parameter 1: target SDK Version (@code Integer})</li> + * <li>Parameter 2: minimum signature scheme version ((@code Integer})</li> + * </ul> + */ + MIN_SIG_SCHEME_FOR_TARGET_SDK_NOT_MET( + "Target SDK version %1$d requires a minimum of signature scheme v%2$d; the APK is" + + " not signed with this or a later signature scheme"), + + /** + * APK which is both JAR-signed and signed using APK Signature Scheme v2 contains a JAR + * signature from this signer, but does not contain an APK Signature Scheme v2 signature + * from this signer. + */ + V2_SIG_MISSING("No APK Signature Scheme v2 signature from this signer"), + + /** + * Failed to parse the list of signers contained in the APK Signature Scheme v2 signature. + */ + V2_SIG_MALFORMED_SIGNERS("Malformed list of signers"), + + /** + * Failed to parse this signer's signer block contained in the APK Signature Scheme v2 + * signature. + */ + V2_SIG_MALFORMED_SIGNER("Malformed signer block"), + + /** + * Public key embedded in the APK Signature Scheme v2 signature of this signer could not be + * parsed. + * + * <ul> + * <li>Parameter 1: error details ({@code Throwable})</li> + * </ul> + */ + V2_SIG_MALFORMED_PUBLIC_KEY("Malformed public key: %1$s"), + + /** + * This APK Signature Scheme v2 signer's certificate could not be parsed. + * + * <ul> + * <li>Parameter 1: index ({@code 0}-based) of the certificate in the signer's list of + * certificates ({@code Integer})</li> + * <li>Parameter 2: sequence number ({@code 1}-based) of the certificate in the signer's + * list of certificates ({@code Integer})</li> + * <li>Parameter 3: error details ({@code Throwable})</li> + * </ul> + */ + V2_SIG_MALFORMED_CERTIFICATE("Malformed certificate #%2$d: %3$s"), + + /** + * Failed to parse this signer's signature record contained in the APK Signature Scheme v2 + * signature. + * + * <ul> + * <li>Parameter 1: record number (first record is {@code 1}) ({@code Integer})</li> + * </ul> + */ + V2_SIG_MALFORMED_SIGNATURE("Malformed APK Signature Scheme v2 signature record #%1$d"), + + /** + * Failed to parse this signer's digest record contained in the APK Signature Scheme v2 + * signature. + * + * <ul> + * <li>Parameter 1: record number (first record is {@code 1}) ({@code Integer})</li> + * </ul> + */ + V2_SIG_MALFORMED_DIGEST("Malformed APK Signature Scheme v2 digest record #%1$d"), + + /** + * This APK Signature Scheme v2 signer contains a malformed additional attribute. + * + * <ul> + * <li>Parameter 1: attribute number (first attribute is {@code 1}) {@code Integer})</li> + * </ul> + */ + V2_SIG_MALFORMED_ADDITIONAL_ATTRIBUTE("Malformed additional attribute #%1$d"), + + /** + * APK Signature Scheme v2 signature references an unknown APK signature scheme ID. + * + * <ul> + * <li>Parameter 1: signer index ({@code Integer})</li> + * <li>Parameter 2: unknown APK signature scheme ID ({@code} Integer)</li> + * </ul> + */ + V2_SIG_UNKNOWN_APK_SIG_SCHEME_ID( + "APK Signature Scheme v2 signer: %1$s references unknown APK signature scheme ID: " + + "%2$d"), + + /** + * APK Signature Scheme v2 signature indicates that the APK is supposed to be signed with a + * supported APK signature scheme (in addition to the v2 signature) but no such signature + * was found in the APK. + * + * <ul> + * <li>Parameter 1: signer index ({@code Integer})</li> + * <li>Parameter 2: APK signature scheme English name ({@code} String)</li> + * </ul> + */ + V2_SIG_MISSING_APK_SIG_REFERENCED( + "APK Signature Scheme v2 signature %1$s indicates the APK is signed using %2$s but " + + "no such signature was found. Signature stripped?"), + + /** + * APK signature scheme v2 has exceeded the maximum number of signers. + * <ul> + * <li>Parameter 1: maximum allowed signers ({@code Integer})</li> + * <li>Parameter 2: total number of signers ({@code Integer})</li> + * </ul> + */ + V2_SIG_MAX_SIGNATURES_EXCEEDED( + "APK Signature Scheme V2 only supports a maximum of %1$d signers, found %2$d"), + + /** + * APK Signature Scheme v2 signature contains no signers. + */ + V2_SIG_NO_SIGNERS("No signers in APK Signature Scheme v2 signature"), + + /** + * This APK Signature Scheme v2 signer contains a signature produced using an unknown + * algorithm. + * + * <ul> + * <li>Parameter 1: algorithm ID ({@code Integer})</li> + * </ul> + */ + V2_SIG_UNKNOWN_SIG_ALGORITHM("Unknown signature algorithm: %1$#x"), + + /** + * This APK Signature Scheme v2 signer contains an unknown additional attribute. + * + * <ul> + * <li>Parameter 1: attribute ID ({@code Integer})</li> + * </ul> + */ + V2_SIG_UNKNOWN_ADDITIONAL_ATTRIBUTE("Unknown additional attribute: ID %1$#x"), + + /** + * An exception was encountered while verifying APK Signature Scheme v2 signature of this + * signer. + * + * <ul> + * <li>Parameter 1: signature algorithm ({@link SignatureAlgorithm})</li> + * <li>Parameter 2: exception ({@code Throwable})</li> + * </ul> + */ + V2_SIG_VERIFY_EXCEPTION("Failed to verify %1$s signature: %2$s"), + + /** + * APK Signature Scheme v2 signature over this signer's signed-data block did not verify. + * + * <ul> + * <li>Parameter 1: signature algorithm ({@link SignatureAlgorithm})</li> + * </ul> + */ + V2_SIG_DID_NOT_VERIFY("%1$s signature over signed-data did not verify"), + + /** + * This APK Signature Scheme v2 signer offers no signatures. + */ + V2_SIG_NO_SIGNATURES("No signatures"), + + /** + * This APK Signature Scheme v2 signer offers signatures but none of them are supported. + */ + V2_SIG_NO_SUPPORTED_SIGNATURES("No supported signatures: %1$s"), + + /** + * This APK Signature Scheme v2 signer offers no certificates. + */ + V2_SIG_NO_CERTIFICATES("No certificates"), + + /** + * This APK Signature Scheme v2 signer's public key listed in the signer's certificate does + * not match the public key listed in the signatures record. + * + * <ul> + * <li>Parameter 1: hex-encoded public key from certificate ({@code String})</li> + * <li>Parameter 2: hex-encoded public key from signatures record ({@code String})</li> + * </ul> + */ + V2_SIG_PUBLIC_KEY_MISMATCH_BETWEEN_CERTIFICATE_AND_SIGNATURES_RECORD( + "Public key mismatch between certificate and signature record: <%1$s> vs <%2$s>"), + + /** + * This APK Signature Scheme v2 signer's signature algorithms listed in the signatures + * record do not match the signature algorithms listed in the signatures record. + * + * <ul> + * <li>Parameter 1: signature algorithms from signatures record ({@code List<Integer>})</li> + * <li>Parameter 2: signature algorithms from digests record ({@code List<Integer>})</li> + * </ul> + */ + V2_SIG_SIG_ALG_MISMATCH_BETWEEN_SIGNATURES_AND_DIGESTS_RECORDS( + "Signature algorithms mismatch between signatures and digests records" + + ": %1$s vs %2$s"), + + /** + * The APK's digest does not match the digest contained in the APK Signature Scheme v2 + * signature. + * + * <ul> + * <li>Parameter 1: content digest algorithm ({@link ContentDigestAlgorithm})</li> + * <li>Parameter 2: hex-encoded expected digest of the APK ({@code String})</li> + * <li>Parameter 3: hex-encoded actual digest of the APK ({@code String})</li> + * </ul> + */ + V2_SIG_APK_DIGEST_DID_NOT_VERIFY( + "APK integrity check failed. %1$s digest mismatch." + + " Expected: <%2$s>, actual: <%3$s>"), + + /** + * Failed to parse the list of signers contained in the APK Signature Scheme v3 signature. + */ + V3_SIG_MALFORMED_SIGNERS("Malformed list of signers"), + + /** + * Failed to parse this signer's signer block contained in the APK Signature Scheme v3 + * signature. + */ + V3_SIG_MALFORMED_SIGNER("Malformed signer block"), + + /** + * Public key embedded in the APK Signature Scheme v3 signature of this signer could not be + * parsed. + * + * <ul> + * <li>Parameter 1: error details ({@code Throwable})</li> + * </ul> + */ + V3_SIG_MALFORMED_PUBLIC_KEY("Malformed public key: %1$s"), + + /** + * This APK Signature Scheme v3 signer's certificate could not be parsed. + * + * <ul> + * <li>Parameter 1: index ({@code 0}-based) of the certificate in the signer's list of + * certificates ({@code Integer})</li> + * <li>Parameter 2: sequence number ({@code 1}-based) of the certificate in the signer's + * list of certificates ({@code Integer})</li> + * <li>Parameter 3: error details ({@code Throwable})</li> + * </ul> + */ + V3_SIG_MALFORMED_CERTIFICATE("Malformed certificate #%2$d: %3$s"), + + /** + * Failed to parse this signer's signature record contained in the APK Signature Scheme v3 + * signature. + * + * <ul> + * <li>Parameter 1: record number (first record is {@code 1}) ({@code Integer})</li> + * </ul> + */ + V3_SIG_MALFORMED_SIGNATURE("Malformed APK Signature Scheme v3 signature record #%1$d"), + + /** + * Failed to parse this signer's digest record contained in the APK Signature Scheme v3 + * signature. + * + * <ul> + * <li>Parameter 1: record number (first record is {@code 1}) ({@code Integer})</li> + * </ul> + */ + V3_SIG_MALFORMED_DIGEST("Malformed APK Signature Scheme v3 digest record #%1$d"), + + /** + * This APK Signature Scheme v3 signer contains a malformed additional attribute. + * + * <ul> + * <li>Parameter 1: attribute number (first attribute is {@code 1}) {@code Integer})</li> + * </ul> + */ + V3_SIG_MALFORMED_ADDITIONAL_ATTRIBUTE("Malformed additional attribute #%1$d"), + + /** + * APK Signature Scheme v3 signature contains no signers. + */ + V3_SIG_NO_SIGNERS("No signers in APK Signature Scheme v3 signature"), + + /** + * APK Signature Scheme v3 signature contains multiple signers (only one allowed per + * platform version). + */ + V3_SIG_MULTIPLE_SIGNERS("Multiple APK Signature Scheme v3 signatures found for a single " + + " platform version."), + + /** + * APK Signature Scheme v3 signature found, but multiple v1 and/or multiple v2 signers + * found, where only one may be used with APK Signature Scheme v3 + */ + V3_SIG_MULTIPLE_PAST_SIGNERS("Multiple signatures found for pre-v3 signing with an APK " + + " Signature Scheme v3 signer. Only one allowed."), + + /** + * APK Signature Scheme v3 signature found, but its signer doesn't match the v1/v2 signers, + * or have them as the root of its signing certificate history + */ + V3_SIG_PAST_SIGNERS_MISMATCH( + "v3 signer differs from v1/v2 signer without proper signing certificate lineage."), + + /** + * This APK Signature Scheme v3 signer contains a signature produced using an unknown + * algorithm. + * + * <ul> + * <li>Parameter 1: algorithm ID ({@code Integer})</li> + * </ul> + */ + V3_SIG_UNKNOWN_SIG_ALGORITHM("Unknown signature algorithm: %1$#x"), + + /** + * This APK Signature Scheme v3 signer contains an unknown additional attribute. + * + * <ul> + * <li>Parameter 1: attribute ID ({@code Integer})</li> + * </ul> + */ + V3_SIG_UNKNOWN_ADDITIONAL_ATTRIBUTE("Unknown additional attribute: ID %1$#x"), + + /** + * An exception was encountered while verifying APK Signature Scheme v3 signature of this + * signer. + * + * <ul> + * <li>Parameter 1: signature algorithm ({@link SignatureAlgorithm})</li> + * <li>Parameter 2: exception ({@code Throwable})</li> + * </ul> + */ + V3_SIG_VERIFY_EXCEPTION("Failed to verify %1$s signature: %2$s"), + + /** + * The APK Signature Scheme v3 signer contained an invalid value for either min or max SDK + * versions. + * + * <ul> + * <li>Parameter 1: minSdkVersion ({@code Integer}) + * <li>Parameter 2: maxSdkVersion ({@code Integer}) + * </ul> + */ + V3_SIG_INVALID_SDK_VERSIONS("Invalid SDK Version parameter(s) encountered in APK Signature " + + "scheme v3 signature: minSdkVersion %1$s maxSdkVersion: %2$s"), + + /** + * APK Signature Scheme v3 signature over this signer's signed-data block did not verify. + * + * <ul> + * <li>Parameter 1: signature algorithm ({@link SignatureAlgorithm})</li> + * </ul> + */ + V3_SIG_DID_NOT_VERIFY("%1$s signature over signed-data did not verify"), + + /** + * This APK Signature Scheme v3 signer offers no signatures. + */ + V3_SIG_NO_SIGNATURES("No signatures"), + + /** + * This APK Signature Scheme v3 signer offers signatures but none of them are supported. + */ + V3_SIG_NO_SUPPORTED_SIGNATURES("No supported signatures"), + + /** + * This APK Signature Scheme v3 signer offers no certificates. + */ + V3_SIG_NO_CERTIFICATES("No certificates"), + + /** + * This APK Signature Scheme v3 signer's minSdkVersion listed in the signer's signed data + * does not match the minSdkVersion listed in the signatures record. + * + * <ul> + * <li>Parameter 1: minSdkVersion in signature record ({@code Integer}) </li> + * <li>Parameter 2: minSdkVersion in signed data ({@code Integer}) </li> + * </ul> + */ + V3_MIN_SDK_VERSION_MISMATCH_BETWEEN_SIGNER_AND_SIGNED_DATA_RECORD( + "minSdkVersion mismatch between signed data and signature record:" + + " <%1$s> vs <%2$s>"), + + /** + * This APK Signature Scheme v3 signer's maxSdkVersion listed in the signer's signed data + * does not match the maxSdkVersion listed in the signatures record. + * + * <ul> + * <li>Parameter 1: maxSdkVersion in signature record ({@code Integer}) </li> + * <li>Parameter 2: maxSdkVersion in signed data ({@code Integer}) </li> + * </ul> + */ + V3_MAX_SDK_VERSION_MISMATCH_BETWEEN_SIGNER_AND_SIGNED_DATA_RECORD( + "maxSdkVersion mismatch between signed data and signature record:" + + " <%1$s> vs <%2$s>"), + + /** + * This APK Signature Scheme v3 signer's public key listed in the signer's certificate does + * not match the public key listed in the signatures record. + * + * <ul> + * <li>Parameter 1: hex-encoded public key from certificate ({@code String})</li> + * <li>Parameter 2: hex-encoded public key from signatures record ({@code String})</li> + * </ul> + */ + V3_SIG_PUBLIC_KEY_MISMATCH_BETWEEN_CERTIFICATE_AND_SIGNATURES_RECORD( + "Public key mismatch between certificate and signature record: <%1$s> vs <%2$s>"), + + /** + * This APK Signature Scheme v3 signer's signature algorithms listed in the signatures + * record do not match the signature algorithms listed in the signatures record. + * + * <ul> + * <li>Parameter 1: signature algorithms from signatures record ({@code List<Integer>})</li> + * <li>Parameter 2: signature algorithms from digests record ({@code List<Integer>})</li> + * </ul> + */ + V3_SIG_SIG_ALG_MISMATCH_BETWEEN_SIGNATURES_AND_DIGESTS_RECORDS( + "Signature algorithms mismatch between signatures and digests records" + + ": %1$s vs %2$s"), + + /** + * The APK's digest does not match the digest contained in the APK Signature Scheme v3 + * signature. + * + * <ul> + * <li>Parameter 1: content digest algorithm ({@link ContentDigestAlgorithm})</li> + * <li>Parameter 2: hex-encoded expected digest of the APK ({@code String})</li> + * <li>Parameter 3: hex-encoded actual digest of the APK ({@code String})</li> + * </ul> + */ + V3_SIG_APK_DIGEST_DID_NOT_VERIFY( + "APK integrity check failed. %1$s digest mismatch." + + " Expected: <%2$s>, actual: <%3$s>"), + + /** + * The signer's SigningCertificateLineage attribute containd a proof-of-rotation record with + * signature(s) that did not verify. + */ + V3_SIG_POR_DID_NOT_VERIFY("SigningCertificateLineage attribute containd a proof-of-rotation" + + " record with signature(s) that did not verify."), + + /** + * Failed to parse the SigningCertificateLineage structure in the APK Signature Scheme v3 + * signature's additional attributes section. + */ + V3_SIG_MALFORMED_LINEAGE("Failed to parse the SigningCertificateLineage structure in the " + + "APK Signature Scheme v3 signature's additional attributes section."), + + /** + * The APK's signing certificate does not match the terminal node in the provided + * proof-of-rotation structure describing the signing certificate history + */ + V3_SIG_POR_CERT_MISMATCH( + "APK signing certificate differs from the associated certificate found in the " + + "signer's SigningCertificateLineage."), + + /** + * The APK Signature Scheme v3 signers encountered do not offer a continuous set of + * supported platform versions. Either they overlap, resulting in potentially two + * acceptable signers for a platform version, or there are holes which would create problems + * in the event of platform version upgrades. + */ + V3_INCONSISTENT_SDK_VERSIONS("APK Signature Scheme v3 signers supported min/max SDK " + + "versions are not continuous."), + + /** + * The APK Signature Scheme v3 signers don't cover all requested SDK versions. + * + * <ul> + * <li>Parameter 1: minSdkVersion ({@code Integer}) + * <li>Parameter 2: maxSdkVersion ({@code Integer}) + * </ul> + */ + V3_MISSING_SDK_VERSIONS("APK Signature Scheme v3 signers supported min/max SDK " + + "versions do not cover the entire desired range. Found min: %1$s max %2$s"), + + /** + * The SigningCertificateLineages for different platform versions using APK Signature Scheme + * v3 do not go together. Specifically, each should be a subset of another, with the size + * of each increasing as the platform level increases. + */ + V3_INCONSISTENT_LINEAGES("SigningCertificateLineages targeting different platform versions" + + " using APK Signature Scheme v3 are not all a part of the same overall lineage."), + + /** + * The v3 stripping protection attribute for rotation is present, but a v3.1 signing block + * was not found. + * + * <ul> + * <li>Parameter 1: min SDK version supporting rotation from attribute ({@code Integer}) + * </ul> + */ + V31_BLOCK_MISSING( + "The v3 signer indicates key rotation should be supported starting from SDK " + + "version %1$s, but a v3.1 block was not found"), + + /** + * The v3 stripping protection attribute for rotation does not match the minimum SDK version + * targeting rotation in the v3.1 signer block. + * + * <ul> + * <li>Parameter 1: min SDK version supporting rotation from attribute ({@code Integer}) + * <li>Parameter 2: min SDK version supporting rotation from v3.1 block ({@code Integer}) + * </ul> + */ + V31_ROTATION_MIN_SDK_MISMATCH( + "The v3 signer indicates key rotation should be supported starting from SDK " + + "version %1$s, but the v3.1 block targets %2$s for rotation"), + + /** + * The APK supports key rotation with SDK version targeting using v3.1, but the rotation min + * SDK version stripping protection attribute was not written to the v3 signer. + * + * <ul> + * <li>Parameter 1: min SDK version supporting rotation from v3.1 block ({@code Integer}) + * </ul> + */ + V31_ROTATION_MIN_SDK_ATTR_MISSING( + "APK supports key rotation starting from SDK version %1$s, but the v3 signer does" + + " not contain the attribute to detect if this signature is stripped"), + + /** + * The APK contains a v3.1 signing block without a v3.0 block. The v3.1 block should only + * be used for targeting rotation for a later SDK version; if an APK's minSdkVersion is the + * same as the SDK version for rotation then this should be written to a v3.0 block. + */ + V31_BLOCK_FOUND_WITHOUT_V3_BLOCK( + "The APK contains a v3.1 signing block without a v3.0 base block"), + + /** + * The APK contains a v3.0 signing block with a rotation-targets-dev-release attribute in + * the signer; this attribute is only intended for v3.1 signers to indicate they should be + * targeting the next development release that is using the SDK version of the previously + * released platform SDK version. + */ + V31_ROTATION_TARGETS_DEV_RELEASE_ATTR_ON_V3_SIGNER( + "The rotation-targets-dev-release attribute is only supported on v3.1 signers; " + + "this attribute will be ignored by the platform in a v3.0 signer"), + + /** + * APK Signing Block contains an unknown entry. + * + * <ul> + * <li>Parameter 1: entry ID ({@code Integer})</li> + * </ul> + */ + APK_SIG_BLOCK_UNKNOWN_ENTRY_ID("APK Signing Block contains unknown entry: ID %1$#x"), + + /** + * Failed to parse this signer's signature record contained in the APK Signature Scheme + * V4 signature. + * + * <ul> + * <li>Parameter 1: record number (first record is {@code 1}) ({@code Integer})</li> + * </ul> + */ + V4_SIG_MALFORMED_SIGNERS( + "V4 signature has malformed signer block"), + + /** + * This APK Signature Scheme V4 signer contains a signature produced using an + * unknown algorithm. + * + * <ul> + * <li>Parameter 1: algorithm ID ({@code Integer})</li> + * </ul> + */ + V4_SIG_UNKNOWN_SIG_ALGORITHM( + "V4 signature has unknown signing algorithm: %1$#x"), + + /** + * This APK Signature Scheme V4 signer offers no signatures. + */ + V4_SIG_NO_SIGNATURES( + "V4 signature has no signature found"), + + /** + * This APK Signature Scheme V4 signer offers signatures but none of them are + * supported. + */ + V4_SIG_NO_SUPPORTED_SIGNATURES( + "V4 signature has no supported signature"), + + /** + * APK Signature Scheme v3 signature over this signer's signed-data block did not verify. + * + * <ul> + * <li>Parameter 1: signature algorithm ({@link SignatureAlgorithm})</li> + * </ul> + */ + V4_SIG_DID_NOT_VERIFY("%1$s signature over signed-data did not verify"), + + /** + * An exception was encountered while verifying APK Signature Scheme v3 signature of this + * signer. + * + * <ul> + * <li>Parameter 1: signature algorithm ({@link SignatureAlgorithm})</li> + * <li>Parameter 2: exception ({@code Throwable})</li> + * </ul> + */ + V4_SIG_VERIFY_EXCEPTION("Failed to verify %1$s signature: %2$s"), + + /** + * Public key embedded in the APK Signature Scheme v4 signature of this signer could not be + * parsed. + * + * <ul> + * <li>Parameter 1: error details ({@code Throwable})</li> + * </ul> + */ + V4_SIG_MALFORMED_PUBLIC_KEY("Malformed public key: %1$s"), + + /** + * This APK Signature Scheme V4 signer's certificate could not be parsed. + * + * <ul> + * <li>Parameter 1: index ({@code 0}-based) of the certificate in the signer's list of + * certificates ({@code Integer})</li> + * <li>Parameter 2: sequence number ({@code 1}-based) of the certificate in the signer's + * list of certificates ({@code Integer})</li> + * <li>Parameter 3: error details ({@code Throwable})</li> + * </ul> + */ + V4_SIG_MALFORMED_CERTIFICATE( + "V4 signature has malformed certificate"), + + /** + * This APK Signature Scheme V4 signer offers no certificate. + */ + V4_SIG_NO_CERTIFICATE("V4 signature has no certificate"), + + /** + * This APK Signature Scheme V4 signer's public key listed in the signer's + * certificate does not match the public key listed in the signature proto. + * + * <ul> + * <li>Parameter 1: hex-encoded public key from certificate ({@code String})</li> + * <li>Parameter 2: hex-encoded public key from signature proto ({@code String})</li> + * </ul> + */ + V4_SIG_PUBLIC_KEY_MISMATCH_BETWEEN_CERTIFICATE_AND_SIGNATURES_RECORD( + "V4 signature has mismatched certificate and signature: <%1$s> vs <%2$s>"), + + /** + * The APK's hash root (aka digest) does not match the hash root contained in the Signature + * Scheme V4 signature. + * + * <ul> + * <li>Parameter 1: content digest algorithm ({@link ContentDigestAlgorithm})</li> + * <li>Parameter 2: hex-encoded expected digest of the APK ({@code String})</li> + * <li>Parameter 3: hex-encoded actual digest of the APK ({@code String})</li> + * </ul> + */ + V4_SIG_APK_ROOT_DID_NOT_VERIFY( + "V4 signature's hash tree root (content digest) did not verity"), + + /** + * The APK's hash tree does not match the hash tree contained in the Signature + * Scheme V4 signature. + * + * <ul> + * <li>Parameter 1: content digest algorithm ({@link ContentDigestAlgorithm})</li> + * <li>Parameter 2: hex-encoded expected hash tree of the APK ({@code String})</li> + * <li>Parameter 3: hex-encoded actual hash tree of the APK ({@code String})</li> + * </ul> + */ + V4_SIG_APK_TREE_DID_NOT_VERIFY( + "V4 signature's hash tree did not verity"), + + /** + * Using more than one Signer to sign APK Signature Scheme V4 signature. + */ + V4_SIG_MULTIPLE_SIGNERS( + "V4 signature only supports one signer"), + + /** + * V4.1 signature requires two signers to match the v3 and the v3.1. + */ + V41_SIG_NEEDS_TWO_SIGNERS("V4.1 signature requires two signers"), + + /** + * The signer used to sign APK Signature Scheme V2/V3 signature does not match the signer + * used to sign APK Signature Scheme V4 signature. + */ + V4_SIG_V2_V3_SIGNERS_MISMATCH( + "V4 signature and V2/V3 signature have mismatched certificates"), + + /** + * The v4 signature's digest does not match the digest from the corresponding v2 / v3 + * signature. + * + * <ul> + * <li>Parameter 1: Signature scheme of mismatched digest ({@code int}) + * <li>Parameter 2: v2/v3 digest ({@code String}) + * <li>Parameter 3: v4 digest ({@code String}) + * </ul> + */ + V4_SIG_V2_V3_DIGESTS_MISMATCH( + "V4 signature and V%1$d signature have mismatched digests, V%1$d digest: %2$s, V4" + + " digest: %3$s"), + + /** + * The v4 signature does not contain the expected number of digests. + * + * <ul> + * <li>Parameter 1: Number of digests found ({@code int}) + * </ul> + */ + V4_SIG_UNEXPECTED_DIGESTS( + "V4 signature does not have the expected number of digests, found %1$d"), + + /** + * The v4 signature format version isn't the same as the tool's current version, something + * may go wrong. + */ + V4_SIG_VERSION_NOT_CURRENT( + "V4 signature format version %1$d is different from the tool's current " + + "version %2$d"), + + /** + * The APK does not contain the source stamp certificate digest file nor the signature block + * when verification expected a source stamp to be present. + */ + SOURCE_STAMP_CERT_DIGEST_AND_SIG_BLOCK_MISSING( + "Neither the source stamp certificate digest file nor the signature block are " + + "present in the APK"), + + /** APK contains SourceStamp file, but does not contain a SourceStamp signature. */ + SOURCE_STAMP_SIG_MISSING("No SourceStamp signature"), + + /** + * SourceStamp's certificate could not be parsed. + * + * <ul> + * <li>Parameter 1: error details ({@code Throwable}) + * </ul> + */ + SOURCE_STAMP_MALFORMED_CERTIFICATE("Malformed certificate: %1$s"), + + /** Failed to parse SourceStamp's signature. */ + SOURCE_STAMP_MALFORMED_SIGNATURE("Malformed SourceStamp signature"), + + /** + * SourceStamp contains a signature produced using an unknown algorithm. + * + * <ul> + * <li>Parameter 1: algorithm ID ({@code Integer}) + * </ul> + */ + SOURCE_STAMP_UNKNOWN_SIG_ALGORITHM("Unknown signature algorithm: %1$#x"), + + /** + * An exception was encountered while verifying SourceStamp signature. + * + * <ul> + * <li>Parameter 1: signature algorithm ({@link SignatureAlgorithm}) + * <li>Parameter 2: exception ({@code Throwable}) + * </ul> + */ + SOURCE_STAMP_VERIFY_EXCEPTION("Failed to verify %1$s signature: %2$s"), + + /** + * SourceStamp signature block did not verify. + * + * <ul> + * <li>Parameter 1: signature algorithm ({@link SignatureAlgorithm}) + * </ul> + */ + SOURCE_STAMP_DID_NOT_VERIFY("%1$s signature over signed-data did not verify"), + + /** SourceStamp offers no signatures. */ + SOURCE_STAMP_NO_SIGNATURE("No signature"), + + /** + * SourceStamp offers an unsupported signature. + * <ul> + * <li>Parameter 1: list of {@link SignatureAlgorithm}s in the source stamp + * signing block. + * <li>Parameter 2: {@code Exception} caught when attempting to obtain the list of + * supported signatures. + * </ul> + */ + SOURCE_STAMP_NO_SUPPORTED_SIGNATURE("Signature(s) {%1$s} not supported: %2$s"), + + /** + * SourceStamp's certificate listed in the APK signing block does not match the certificate + * listed in the SourceStamp file in the APK. + * + * <ul> + * <li>Parameter 1: SHA-256 hash of certificate from SourceStamp block in APK signing + * block ({@code String}) + * <li>Parameter 2: SHA-256 hash of certificate from SourceStamp file in APK ({@code + * String}) + * </ul> + */ + SOURCE_STAMP_CERTIFICATE_MISMATCH_BETWEEN_SIGNATURE_BLOCK_AND_APK( + "Certificate mismatch between SourceStamp block in APK signing block and" + + " SourceStamp file in APK: <%1$s> vs <%2$s>"), + + /** + * The APK contains a source stamp signature block without the expected certificate digest + * in the APK contents. + */ + SOURCE_STAMP_SIGNATURE_BLOCK_WITHOUT_CERT_DIGEST( + "A source stamp signature block was found without a corresponding certificate " + + "digest in the APK"), + + /** + * When verifying just the source stamp, the certificate digest in the APK does not match + * the expected digest. + * <ul> + * <li>Parameter 1: SHA-256 digest of the source stamp certificate in the APK. + * <li>Parameter 2: SHA-256 digest of the expected source stamp certificate. + * </ul> + */ + SOURCE_STAMP_EXPECTED_DIGEST_MISMATCH( + "The source stamp certificate digest in the APK, %1$s, does not match the " + + "expected digest, %2$s"), + + /** + * Source stamp block contains a malformed attribute. + * + * <ul> + * <li>Parameter 1: attribute number (first attribute is {@code 1}) {@code Integer})</li> + * </ul> + */ + SOURCE_STAMP_MALFORMED_ATTRIBUTE("Malformed stamp attribute #%1$d"), + + /** + * Source stamp block contains an unknown attribute. + * + * <ul> + * <li>Parameter 1: attribute ID ({@code Integer})</li> + * </ul> + */ + SOURCE_STAMP_UNKNOWN_ATTRIBUTE("Unknown stamp attribute: ID %1$#x"), + + /** + * Failed to parse the SigningCertificateLineage structure in the source stamp + * attributes section. + */ + SOURCE_STAMP_MALFORMED_LINEAGE("Failed to parse the SigningCertificateLineage " + + "structure in the source stamp attributes section."), + + /** + * The source stamp certificate does not match the terminal node in the provided + * proof-of-rotation structure describing the stamp certificate history. + */ + SOURCE_STAMP_POR_CERT_MISMATCH( + "APK signing certificate differs from the associated certificate found in the " + + "signer's SigningCertificateLineage."), + + /** + * The source stamp SigningCertificateLineage attribute contains a proof-of-rotation record + * with signature(s) that did not verify. + */ + SOURCE_STAMP_POR_DID_NOT_VERIFY("Source stamp SigningCertificateLineage attribute " + + "contains a proof-of-rotation record with signature(s) that did not verify."), + + /** + * The source stamp timestamp attribute has an invalid value (<= 0). + * <ul> + * <li>Parameter 1: The invalid timestamp value. + * </ul> + */ + SOURCE_STAMP_INVALID_TIMESTAMP( + "The source stamp" + + " timestamp attribute has an invalid value: %1$d"), + + /** + * The APK could not be properly parsed due to a ZIP or APK format exception. + * <ul> + * <li>Parameter 1: The {@code Exception} caught when attempting to parse the APK. + * </ul> + */ + MALFORMED_APK( + "Malformed APK; the following exception was caught when attempting to parse the " + + "APK: %1$s"), + + /** + * An unexpected exception was caught when attempting to verify the signature(s) within the + * APK. + * <ul> + * <li>Parameter 1: The {@code Exception} caught during verification. + * </ul> + */ + UNEXPECTED_EXCEPTION( + "An unexpected exception was caught when verifying the signature: %1$s"); + + private final String mFormat; + + Issue(String format) { + mFormat = format; + } + + /** + * Returns the format string suitable for combining the parameters of this issue into a + * readable string. See {@link java.util.Formatter} for format. + */ + private String getFormat() { + return mFormat; + } + } + + /** + * {@link Issue} with associated parameters. {@link #toString()} produces a readable formatted + * form. + */ + public static class IssueWithParams extends ApkVerificationIssue { + private final Issue mIssue; + private final Object[] mParams; + + /** + * Constructs a new {@code IssueWithParams} of the specified type and with provided + * parameters. + */ + public IssueWithParams(Issue issue, Object[] params) { + super(issue.mFormat, params); + mIssue = issue; + mParams = params; + } + + /** + * Returns the type of this issue. + */ + public Issue getIssue() { + return mIssue; + } + + /** + * Returns the parameters of this issue. + */ + public Object[] getParams() { + return mParams.clone(); + } + + /** + * Returns a readable form of this issue. + */ + @Override + public String toString() { + return String.format(mIssue.getFormat(), mParams); + } + } + + /** + * Wrapped around {@code byte[]} which ensures that {@code equals} and {@code hashCode} operate + * on the contents of the arrays rather than on references. + */ + private static class ByteArray { + private final byte[] mArray; + private final int mHashCode; + + private ByteArray(byte[] arr) { + mArray = arr; + mHashCode = Arrays.hashCode(mArray); + } + + @Override + public int hashCode() { + return mHashCode; + } + + @Override + public boolean equals(Object obj) { + if (this == obj) { + return true; + } + if (!(obj instanceof ByteArray)) { + return false; + } + ByteArray other = (ByteArray) obj; + if (hashCode() != other.hashCode()) { + return false; + } + if (!Arrays.equals(mArray, other.mArray)) { + return false; + } + return true; + } + } + + /** + * Builder of {@link ApkVerifier} instances. + * + * <p>The resulting verifier by default checks whether the APK will verify on all platform + * versions supported by the APK, as specified by {@code android:minSdkVersion} attributes in + * the APK's {@code AndroidManifest.xml}. The range of platform versions can be customized using + * {@link #setMinCheckedPlatformVersion(int)} and {@link #setMaxCheckedPlatformVersion(int)}. + */ + public static class Builder { + private final File mApkFile; + private final DataSource mApkDataSource; + private File mV4SignatureFile; + + private Integer mMinSdkVersion; + private int mMaxSdkVersion = Integer.MAX_VALUE; + + /** + * Constructs a new {@code Builder} for verifying the provided APK file. + */ + public Builder(File apk) { + if (apk == null) { + throw new NullPointerException("apk == null"); + } + mApkFile = apk; + mApkDataSource = null; + } + + /** + * Constructs a new {@code Builder} for verifying the provided APK. + */ + public Builder(DataSource apk) { + if (apk == null) { + throw new NullPointerException("apk == null"); + } + mApkDataSource = apk; + mApkFile = null; + } + + /** + * Sets the oldest Android platform version for which the APK is verified. APK verification + * will confirm that the APK is expected to install successfully on all known Android + * platforms starting from the platform version with the provided API Level. The upper end + * of the platform versions range can be modified via + * {@link #setMaxCheckedPlatformVersion(int)}. + * + * <p>This method is useful for overriding the default behavior which checks that the APK + * will verify on all platform versions supported by the APK, as specified by + * {@code android:minSdkVersion} attributes in the APK's {@code AndroidManifest.xml}. + * + * @param minSdkVersion API Level of the oldest platform for which to verify the APK + * @see #setMinCheckedPlatformVersion(int) + */ + public Builder setMinCheckedPlatformVersion(int minSdkVersion) { + mMinSdkVersion = minSdkVersion; + return this; + } + + /** + * Sets the newest Android platform version for which the APK is verified. APK verification + * will confirm that the APK is expected to install successfully on all platform versions + * supported by the APK up until and including the provided version. The lower end + * of the platform versions range can be modified via + * {@link #setMinCheckedPlatformVersion(int)}. + * + * @param maxSdkVersion API Level of the newest platform for which to verify the APK + * @see #setMinCheckedPlatformVersion(int) + */ + public Builder setMaxCheckedPlatformVersion(int maxSdkVersion) { + mMaxSdkVersion = maxSdkVersion; + return this; + } + + public Builder setV4SignatureFile(File v4SignatureFile) { + mV4SignatureFile = v4SignatureFile; + return this; + } + + /** + * Returns an {@link ApkVerifier} initialized according to the configuration of this + * builder. + */ + public ApkVerifier build() { + return new ApkVerifier( + mApkFile, + mApkDataSource, + mV4SignatureFile, + mMinSdkVersion, + mMaxSdkVersion); + } + } + + /** + * Adapter for converting base {@link ApkVerificationIssue} instances to their {@link + * IssueWithParams} equivalent. + */ + public static class ApkVerificationIssueAdapter { + private ApkVerificationIssueAdapter() { + } + + // This field is visible for testing + static final Map<Integer, Issue> sVerificationIssueIdToIssue = new HashMap<>(); + + static { + sVerificationIssueIdToIssue.put(ApkVerificationIssue.V2_SIG_MALFORMED_SIGNERS, + Issue.V2_SIG_MALFORMED_SIGNERS); + sVerificationIssueIdToIssue.put(ApkVerificationIssue.V2_SIG_NO_SIGNERS, + Issue.V2_SIG_NO_SIGNERS); + sVerificationIssueIdToIssue.put(ApkVerificationIssue.V2_SIG_MALFORMED_SIGNER, + Issue.V2_SIG_MALFORMED_SIGNER); + sVerificationIssueIdToIssue.put(ApkVerificationIssue.V2_SIG_MALFORMED_SIGNATURE, + Issue.V2_SIG_MALFORMED_SIGNATURE); + sVerificationIssueIdToIssue.put(ApkVerificationIssue.V2_SIG_NO_SIGNATURES, + Issue.V2_SIG_NO_SIGNATURES); + sVerificationIssueIdToIssue.put(ApkVerificationIssue.V2_SIG_MALFORMED_CERTIFICATE, + Issue.V2_SIG_MALFORMED_CERTIFICATE); + sVerificationIssueIdToIssue.put(ApkVerificationIssue.V2_SIG_NO_CERTIFICATES, + Issue.V2_SIG_NO_CERTIFICATES); + sVerificationIssueIdToIssue.put(ApkVerificationIssue.V2_SIG_MALFORMED_DIGEST, + Issue.V2_SIG_MALFORMED_DIGEST); + sVerificationIssueIdToIssue.put(ApkVerificationIssue.V3_SIG_MALFORMED_SIGNERS, + Issue.V3_SIG_MALFORMED_SIGNERS); + sVerificationIssueIdToIssue.put(ApkVerificationIssue.V3_SIG_NO_SIGNERS, + Issue.V3_SIG_NO_SIGNERS); + sVerificationIssueIdToIssue.put(ApkVerificationIssue.V3_SIG_MALFORMED_SIGNER, + Issue.V3_SIG_MALFORMED_SIGNER); + sVerificationIssueIdToIssue.put(ApkVerificationIssue.V3_SIG_MALFORMED_SIGNATURE, + Issue.V3_SIG_MALFORMED_SIGNATURE); + sVerificationIssueIdToIssue.put(ApkVerificationIssue.V3_SIG_NO_SIGNATURES, + Issue.V3_SIG_NO_SIGNATURES); + sVerificationIssueIdToIssue.put(ApkVerificationIssue.V3_SIG_MALFORMED_CERTIFICATE, + Issue.V3_SIG_MALFORMED_CERTIFICATE); + sVerificationIssueIdToIssue.put(ApkVerificationIssue.V3_SIG_NO_CERTIFICATES, + Issue.V3_SIG_NO_CERTIFICATES); + sVerificationIssueIdToIssue.put(ApkVerificationIssue.V3_SIG_MALFORMED_DIGEST, + Issue.V3_SIG_MALFORMED_DIGEST); + sVerificationIssueIdToIssue.put(ApkVerificationIssue.SOURCE_STAMP_NO_SIGNATURE, + Issue.SOURCE_STAMP_NO_SIGNATURE); + sVerificationIssueIdToIssue.put(ApkVerificationIssue.SOURCE_STAMP_MALFORMED_CERTIFICATE, + Issue.SOURCE_STAMP_MALFORMED_CERTIFICATE); + sVerificationIssueIdToIssue.put(ApkVerificationIssue.SOURCE_STAMP_UNKNOWN_SIG_ALGORITHM, + Issue.SOURCE_STAMP_UNKNOWN_SIG_ALGORITHM); + sVerificationIssueIdToIssue.put(ApkVerificationIssue.SOURCE_STAMP_MALFORMED_SIGNATURE, + Issue.SOURCE_STAMP_MALFORMED_SIGNATURE); + sVerificationIssueIdToIssue.put(ApkVerificationIssue.SOURCE_STAMP_DID_NOT_VERIFY, + Issue.SOURCE_STAMP_DID_NOT_VERIFY); + sVerificationIssueIdToIssue.put(ApkVerificationIssue.SOURCE_STAMP_VERIFY_EXCEPTION, + Issue.SOURCE_STAMP_VERIFY_EXCEPTION); + sVerificationIssueIdToIssue.put( + ApkVerificationIssue.SOURCE_STAMP_EXPECTED_DIGEST_MISMATCH, + Issue.SOURCE_STAMP_EXPECTED_DIGEST_MISMATCH); + sVerificationIssueIdToIssue.put( + ApkVerificationIssue.SOURCE_STAMP_SIGNATURE_BLOCK_WITHOUT_CERT_DIGEST, + Issue.SOURCE_STAMP_SIGNATURE_BLOCK_WITHOUT_CERT_DIGEST); + sVerificationIssueIdToIssue.put( + ApkVerificationIssue.SOURCE_STAMP_CERT_DIGEST_AND_SIG_BLOCK_MISSING, + Issue.SOURCE_STAMP_CERT_DIGEST_AND_SIG_BLOCK_MISSING); + sVerificationIssueIdToIssue.put( + ApkVerificationIssue.SOURCE_STAMP_NO_SUPPORTED_SIGNATURE, + Issue.SOURCE_STAMP_NO_SUPPORTED_SIGNATURE); + sVerificationIssueIdToIssue.put( + ApkVerificationIssue + .SOURCE_STAMP_CERTIFICATE_MISMATCH_BETWEEN_SIGNATURE_BLOCK_AND_APK, + Issue.SOURCE_STAMP_CERTIFICATE_MISMATCH_BETWEEN_SIGNATURE_BLOCK_AND_APK); + sVerificationIssueIdToIssue.put(ApkVerificationIssue.MALFORMED_APK, + Issue.MALFORMED_APK); + sVerificationIssueIdToIssue.put(ApkVerificationIssue.UNEXPECTED_EXCEPTION, + Issue.UNEXPECTED_EXCEPTION); + sVerificationIssueIdToIssue.put(ApkVerificationIssue.SOURCE_STAMP_SIG_MISSING, + Issue.SOURCE_STAMP_SIG_MISSING); + sVerificationIssueIdToIssue.put(ApkVerificationIssue.SOURCE_STAMP_MALFORMED_ATTRIBUTE, + Issue.SOURCE_STAMP_MALFORMED_ATTRIBUTE); + sVerificationIssueIdToIssue.put(ApkVerificationIssue.SOURCE_STAMP_UNKNOWN_ATTRIBUTE, + Issue.SOURCE_STAMP_UNKNOWN_ATTRIBUTE); + sVerificationIssueIdToIssue.put(ApkVerificationIssue.SOURCE_STAMP_MALFORMED_LINEAGE, + Issue.SOURCE_STAMP_MALFORMED_LINEAGE); + sVerificationIssueIdToIssue.put(ApkVerificationIssue.SOURCE_STAMP_POR_CERT_MISMATCH, + Issue.SOURCE_STAMP_POR_CERT_MISMATCH); + sVerificationIssueIdToIssue.put(ApkVerificationIssue.SOURCE_STAMP_POR_DID_NOT_VERIFY, + Issue.SOURCE_STAMP_POR_DID_NOT_VERIFY); + sVerificationIssueIdToIssue.put(ApkVerificationIssue.JAR_SIG_NO_SIGNATURES, + Issue.JAR_SIG_NO_SIGNATURES); + sVerificationIssueIdToIssue.put(ApkVerificationIssue.JAR_SIG_PARSE_EXCEPTION, + Issue.JAR_SIG_PARSE_EXCEPTION); + sVerificationIssueIdToIssue.put(ApkVerificationIssue.SOURCE_STAMP_INVALID_TIMESTAMP, + Issue.SOURCE_STAMP_INVALID_TIMESTAMP); + } + + /** + * Converts the provided {@code verificationIssues} to a {@code List} of corresponding + * {@link IssueWithParams} instances. + */ + public static List<IssueWithParams> getIssuesFromVerificationIssues( + List<? extends ApkVerificationIssue> verificationIssues) { + List<IssueWithParams> result = new ArrayList<>(verificationIssues.size()); + for (ApkVerificationIssue issue : verificationIssues) { + if (issue instanceof IssueWithParams) { + result.add((IssueWithParams) issue); + } else { + result.add( + new IssueWithParams(sVerificationIssueIdToIssue.get(issue.getIssueId()), + issue.getParams())); + } + } + return result; + } + } +} diff --git a/platform/android/java/editor/src/main/java/com/android/apksig/Constants.java b/platform/android/java/editor/src/main/java/com/android/apksig/Constants.java new file mode 100644 index 0000000000..dd33028cd5 --- /dev/null +++ b/platform/android/java/editor/src/main/java/com/android/apksig/Constants.java @@ -0,0 +1,65 @@ +/* + * Copyright (C) 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.apksig; + +import com.android.apksig.internal.apk.stamp.SourceStampConstants; +import com.android.apksig.internal.apk.v1.V1SchemeConstants; +import com.android.apksig.internal.apk.v2.V2SchemeConstants; +import com.android.apksig.internal.apk.v3.V3SchemeConstants; + +/** + * Exports internally defined constants to allow clients to reference these values without relying + * on internal code. + */ +public class Constants { + private Constants() {} + + public static final int VERSION_SOURCE_STAMP = 0; + public static final int VERSION_JAR_SIGNATURE_SCHEME = 1; + public static final int VERSION_APK_SIGNATURE_SCHEME_V2 = 2; + public static final int VERSION_APK_SIGNATURE_SCHEME_V3 = 3; + public static final int VERSION_APK_SIGNATURE_SCHEME_V31 = 31; + public static final int VERSION_APK_SIGNATURE_SCHEME_V4 = 4; + + /** + * The maximum number of signers supported by the v1 and v2 APK Signature Schemes. + */ + public static final int MAX_APK_SIGNERS = 10; + + /** + * The default page alignment for native library files in bytes. + */ + public static final short LIBRARY_PAGE_ALIGNMENT_BYTES = 16384; + + public static final String MANIFEST_ENTRY_NAME = V1SchemeConstants.MANIFEST_ENTRY_NAME; + + public static final int APK_SIGNATURE_SCHEME_V2_BLOCK_ID = + V2SchemeConstants.APK_SIGNATURE_SCHEME_V2_BLOCK_ID; + + public static final int APK_SIGNATURE_SCHEME_V3_BLOCK_ID = + V3SchemeConstants.APK_SIGNATURE_SCHEME_V3_BLOCK_ID; + public static final int APK_SIGNATURE_SCHEME_V31_BLOCK_ID = + V3SchemeConstants.APK_SIGNATURE_SCHEME_V31_BLOCK_ID; + public static final int PROOF_OF_ROTATION_ATTR_ID = V3SchemeConstants.PROOF_OF_ROTATION_ATTR_ID; + + public static final int V1_SOURCE_STAMP_BLOCK_ID = + SourceStampConstants.V1_SOURCE_STAMP_BLOCK_ID; + public static final int V2_SOURCE_STAMP_BLOCK_ID = + SourceStampConstants.V2_SOURCE_STAMP_BLOCK_ID; + + public static final String OID_RSA_ENCRYPTION = "1.2.840.113549.1.1.1"; +} diff --git a/platform/android/java/editor/src/main/java/com/android/apksig/DefaultApkSignerEngine.java b/platform/android/java/editor/src/main/java/com/android/apksig/DefaultApkSignerEngine.java new file mode 100644 index 0000000000..957f48ad43 --- /dev/null +++ b/platform/android/java/editor/src/main/java/com/android/apksig/DefaultApkSignerEngine.java @@ -0,0 +1,2241 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.apksig; + +import static com.android.apksig.apk.ApkUtils.SOURCE_STAMP_CERTIFICATE_HASH_ZIP_ENTRY_NAME; +import static com.android.apksig.apk.ApkUtils.computeSha256DigestBytes; +import static com.android.apksig.internal.apk.ApkSigningBlockUtils.VERITY_PADDING_BLOCK_ID; +import static com.android.apksig.internal.apk.ApkSigningBlockUtils.VERSION_APK_SIGNATURE_SCHEME_V2; +import static com.android.apksig.internal.apk.ApkSigningBlockUtils.VERSION_APK_SIGNATURE_SCHEME_V3; +import static com.android.apksig.internal.apk.ApkSigningBlockUtils.VERSION_JAR_SIGNATURE_SCHEME; +import static com.android.apksig.internal.apk.v3.V3SchemeConstants.MIN_SDK_WITH_V31_SUPPORT; +import static com.android.apksig.internal.apk.v3.V3SchemeConstants.MIN_SDK_WITH_V3_SUPPORT; + +import com.android.apksig.apk.ApkFormatException; +import com.android.apksig.apk.ApkUtils; +import com.android.apksig.internal.apk.ApkSigningBlockUtils; +import com.android.apksig.internal.apk.ContentDigestAlgorithm; +import com.android.apksig.internal.apk.SignatureAlgorithm; +import com.android.apksig.internal.apk.stamp.V2SourceStampSigner; +import com.android.apksig.internal.apk.v1.DigestAlgorithm; +import com.android.apksig.internal.apk.v1.V1SchemeConstants; +import com.android.apksig.internal.apk.v1.V1SchemeSigner; +import com.android.apksig.internal.apk.v1.V1SchemeVerifier; +import com.android.apksig.internal.apk.v2.V2SchemeSigner; +import com.android.apksig.internal.apk.v3.V3SchemeConstants; +import com.android.apksig.internal.apk.v3.V3SchemeSigner; +import com.android.apksig.internal.apk.v4.V4SchemeSigner; +import com.android.apksig.internal.apk.v4.V4Signature; +import com.android.apksig.internal.jar.ManifestParser; +import com.android.apksig.internal.util.AndroidSdkVersion; +import com.android.apksig.internal.util.Pair; +import com.android.apksig.internal.util.TeeDataSink; +import com.android.apksig.util.DataSink; +import com.android.apksig.util.DataSinks; +import com.android.apksig.util.DataSource; +import com.android.apksig.util.RunnablesExecutor; + +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.IOException; +import java.io.OutputStream; +import java.nio.ByteBuffer; +import java.security.InvalidKeyException; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.security.PrivateKey; +import java.security.PublicKey; +import java.security.SignatureException; +import java.security.cert.CertificateEncodingException; +import java.security.cert.CertificateException; +import java.security.cert.X509Certificate; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Iterator; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * Default implementation of {@link ApkSignerEngine}. + * + * <p>Use {@link Builder} to obtain instances of this engine. + */ +public class DefaultApkSignerEngine implements ApkSignerEngine { + + // IMPLEMENTATION NOTE: This engine generates a signed APK as follows: + // 1. The engine asks its client to output input JAR entries which are not part of JAR + // signature. + // 2. If JAR signing (v1 signing) is enabled, the engine inspects the output JAR entries to + // compute their digests, to be placed into output META-INF/MANIFEST.MF. It also inspects + // the contents of input and output META-INF/MANIFEST.MF to borrow the main section of the + // file. It does not care about individual (i.e., JAR entry-specific) sections. It then + // emits the v1 signature (a set of JAR entries) and asks the client to output them. + // 3. If APK Signature Scheme v2 (v2 signing) is enabled, the engine emits an APK Signing Block + // from outputZipSections() and asks its client to insert this block into the output. + // 4. If APK Signature Scheme v3 (v3 signing) is enabled, the engine includes it in the APK + // Signing BLock output from outputZipSections() and asks its client to insert this block + // into the output. If both v2 and v3 signing is enabled, they are both added to the APK + // Signing Block before asking the client to insert it into the output. + + private final boolean mV1SigningEnabled; + private final boolean mV2SigningEnabled; + private final boolean mV3SigningEnabled; + private final boolean mVerityEnabled; + private final boolean mDebuggableApkPermitted; + private final boolean mOtherSignersSignaturesPreserved; + private final String mCreatedBy; + private final List<SignerConfig> mSignerConfigs; + private final List<SignerConfig> mTargetedSignerConfigs; + private final SignerConfig mSourceStampSignerConfig; + private final SigningCertificateLineage mSourceStampSigningCertificateLineage; + private final boolean mSourceStampTimestampEnabled; + private final int mMinSdkVersion; + private final SigningCertificateLineage mSigningCertificateLineage; + + private List<byte[]> mPreservedV2Signers = Collections.emptyList(); + private List<Pair<byte[], Integer>> mPreservedSignatureBlocks = Collections.emptyList(); + + private List<V1SchemeSigner.SignerConfig> mV1SignerConfigs = Collections.emptyList(); + private DigestAlgorithm mV1ContentDigestAlgorithm; + + private boolean mClosed; + + private boolean mV1SignaturePending; + + /** Names of JAR entries which this engine is expected to output as part of v1 signing. */ + private Set<String> mSignatureExpectedOutputJarEntryNames = Collections.emptySet(); + + /** Requests for digests of output JAR entries. */ + private final Map<String, GetJarEntryDataDigestRequest> mOutputJarEntryDigestRequests = + new HashMap<>(); + + /** Digests of output JAR entries. */ + private final Map<String, byte[]> mOutputJarEntryDigests = new HashMap<>(); + + /** Data of JAR entries emitted by this engine as v1 signature. */ + private final Map<String, byte[]> mEmittedSignatureJarEntryData = new HashMap<>(); + + /** Requests for data of output JAR entries which comprise the v1 signature. */ + private final Map<String, GetJarEntryDataRequest> mOutputSignatureJarEntryDataRequests = + new HashMap<>(); + /** + * Request to obtain the data of MANIFEST.MF or {@code null} if the request hasn't been issued. + */ + private GetJarEntryDataRequest mInputJarManifestEntryDataRequest; + + /** + * Request to obtain the data of AndroidManifest.xml or {@code null} if the request hasn't been + * issued. + */ + private GetJarEntryDataRequest mOutputAndroidManifestEntryDataRequest; + + /** + * Whether the package being signed is marked as {@code android:debuggable} or {@code null} if + * this is not yet known. + */ + private Boolean mDebuggable; + + /** + * Request to output the emitted v1 signature or {@code null} if the request hasn't been issued. + */ + private OutputJarSignatureRequestImpl mAddV1SignatureRequest; + + private boolean mV2SignaturePending; + private boolean mV3SignaturePending; + + /** + * Request to output the emitted v2 and/or v3 signature(s) {@code null} if the request hasn't + * been issued. + */ + private OutputApkSigningBlockRequestImpl mAddSigningBlockRequest; + + private RunnablesExecutor mExecutor = RunnablesExecutor.MULTI_THREADED; + + /** + * A Set of block IDs to be discarded when requesting to preserve the original signatures. + */ + private static final Set<Integer> DISCARDED_SIGNATURE_BLOCK_IDS; + static { + DISCARDED_SIGNATURE_BLOCK_IDS = new HashSet<>(3); + // The verity padding block is recomputed on an + // ApkSigningBlockUtils.ANDROID_COMMON_PAGE_ALIGNMENT_BYTES boundary. + DISCARDED_SIGNATURE_BLOCK_IDS.add(VERITY_PADDING_BLOCK_ID); + // The source stamp block is not currently preserved; appending a new signature scheme + // block will invalidate the previous source stamp. + DISCARDED_SIGNATURE_BLOCK_IDS.add(Constants.V1_SOURCE_STAMP_BLOCK_ID); + DISCARDED_SIGNATURE_BLOCK_IDS.add(Constants.V2_SOURCE_STAMP_BLOCK_ID); + } + + private DefaultApkSignerEngine( + List<SignerConfig> signerConfigs, + List<SignerConfig> targetedSignerConfigs, + SignerConfig sourceStampSignerConfig, + SigningCertificateLineage sourceStampSigningCertificateLineage, + boolean sourceStampTimestampEnabled, + int minSdkVersion, + boolean v1SigningEnabled, + boolean v2SigningEnabled, + boolean v3SigningEnabled, + boolean verityEnabled, + boolean debuggableApkPermitted, + boolean otherSignersSignaturesPreserved, + String createdBy, + SigningCertificateLineage signingCertificateLineage) + throws InvalidKeyException { + if (signerConfigs.isEmpty() && targetedSignerConfigs.isEmpty()) { + throw new IllegalArgumentException("At least one signer config must be provided"); + } + + mV1SigningEnabled = v1SigningEnabled; + mV2SigningEnabled = v2SigningEnabled; + mV3SigningEnabled = v3SigningEnabled; + mVerityEnabled = verityEnabled; + mV1SignaturePending = v1SigningEnabled; + mV2SignaturePending = v2SigningEnabled; + mV3SignaturePending = v3SigningEnabled; + mDebuggableApkPermitted = debuggableApkPermitted; + mOtherSignersSignaturesPreserved = otherSignersSignaturesPreserved; + mCreatedBy = createdBy; + mSignerConfigs = signerConfigs; + mTargetedSignerConfigs = targetedSignerConfigs; + mSourceStampSignerConfig = sourceStampSignerConfig; + mSourceStampSigningCertificateLineage = sourceStampSigningCertificateLineage; + mSourceStampTimestampEnabled = sourceStampTimestampEnabled; + mMinSdkVersion = minSdkVersion; + mSigningCertificateLineage = signingCertificateLineage; + + if (v1SigningEnabled) { + if (v3SigningEnabled) { + + // v3 signing only supports single signers, of which the oldest (first) will be the + // one to use for v1 and v2 signing + SignerConfig oldestConfig = !signerConfigs.isEmpty() ? signerConfigs.get(0) + : targetedSignerConfigs.get(0); + + // in the event of signing certificate changes, make sure we have the oldest in the + // signing history to sign with v1 + if (signingCertificateLineage != null) { + SigningCertificateLineage subLineage = + signingCertificateLineage.getSubLineage( + oldestConfig.mCertificates.get(0)); + if (subLineage.size() != 1) { + throw new IllegalArgumentException( + "v1 signing enabled but the oldest signer in the" + + " SigningCertificateLineage is missing. Please provide the" + + " oldest signer to enable v1 signing"); + } + } + createV1SignerConfigs(Collections.singletonList(oldestConfig), minSdkVersion); + } else { + createV1SignerConfigs(signerConfigs, minSdkVersion); + } + } + } + + private void createV1SignerConfigs(List<SignerConfig> signerConfigs, int minSdkVersion) + throws InvalidKeyException { + mV1SignerConfigs = new ArrayList<>(signerConfigs.size()); + Map<String, Integer> v1SignerNameToSignerIndex = new HashMap<>(signerConfigs.size()); + DigestAlgorithm v1ContentDigestAlgorithm = null; + for (int i = 0; i < signerConfigs.size(); i++) { + SignerConfig signerConfig = signerConfigs.get(i); + List<X509Certificate> certificates = signerConfig.getCertificates(); + PublicKey publicKey = certificates.get(0).getPublicKey(); + + String v1SignerName = V1SchemeSigner.getSafeSignerName(signerConfig.getName()); + // Check whether the signer's name is unique among all v1 signers + Integer indexOfOtherSignerWithSameName = v1SignerNameToSignerIndex.put(v1SignerName, i); + if (indexOfOtherSignerWithSameName != null) { + throw new IllegalArgumentException( + "Signers #" + + (indexOfOtherSignerWithSameName + 1) + + " and #" + + (i + 1) + + " have the same name: " + + v1SignerName + + ". v1 signer names must be unique"); + } + + DigestAlgorithm v1SignatureDigestAlgorithm = + V1SchemeSigner.getSuggestedSignatureDigestAlgorithm(publicKey, minSdkVersion); + V1SchemeSigner.SignerConfig v1SignerConfig = new V1SchemeSigner.SignerConfig(); + v1SignerConfig.name = v1SignerName; + v1SignerConfig.privateKey = signerConfig.getPrivateKey(); + v1SignerConfig.certificates = certificates; + v1SignerConfig.signatureDigestAlgorithm = v1SignatureDigestAlgorithm; + v1SignerConfig.deterministicDsaSigning = signerConfig.getDeterministicDsaSigning(); + // For digesting contents of APK entries and of MANIFEST.MF, pick the algorithm + // of comparable strength to the digest algorithm used for computing the signature. + // When there are multiple signers, pick the strongest digest algorithm out of their + // signature digest algorithms. This avoids reducing the digest strength used by any + // of the signers to protect APK contents. + if (v1ContentDigestAlgorithm == null) { + v1ContentDigestAlgorithm = v1SignatureDigestAlgorithm; + } else { + if (DigestAlgorithm.BY_STRENGTH_COMPARATOR.compare( + v1SignatureDigestAlgorithm, v1ContentDigestAlgorithm) + > 0) { + v1ContentDigestAlgorithm = v1SignatureDigestAlgorithm; + } + } + mV1SignerConfigs.add(v1SignerConfig); + } + mV1ContentDigestAlgorithm = v1ContentDigestAlgorithm; + mSignatureExpectedOutputJarEntryNames = + V1SchemeSigner.getOutputEntryNames(mV1SignerConfigs); + } + + private List<ApkSigningBlockUtils.SignerConfig> createV2SignerConfigs( + boolean apkSigningBlockPaddingSupported) throws InvalidKeyException { + if (mV3SigningEnabled) { + + // v3 signing only supports single signers, of which the oldest (first) will be the one + // to use for v1 and v2 signing + List<ApkSigningBlockUtils.SignerConfig> signerConfig = new ArrayList<>(); + + SignerConfig oldestConfig = !mSignerConfigs.isEmpty() ? mSignerConfigs.get(0) + : mTargetedSignerConfigs.get(0); + + // first make sure that if we have signing certificate history that the oldest signer + // corresponds to the oldest ancestor + if (mSigningCertificateLineage != null) { + SigningCertificateLineage subLineage = + mSigningCertificateLineage.getSubLineage(oldestConfig.mCertificates.get(0)); + if (subLineage.size() != 1) { + throw new IllegalArgumentException( + "v2 signing enabled but the oldest signer in" + + " the SigningCertificateLineage is missing. Please provide" + + " the oldest signer to enable v2 signing."); + } + } + signerConfig.add( + createSigningBlockSignerConfig( + oldestConfig, + apkSigningBlockPaddingSupported, + ApkSigningBlockUtils.VERSION_APK_SIGNATURE_SCHEME_V2)); + return signerConfig; + } else { + return createSigningBlockSignerConfigs( + apkSigningBlockPaddingSupported, + ApkSigningBlockUtils.VERSION_APK_SIGNATURE_SCHEME_V2); + } + } + + private List<ApkSigningBlockUtils.SignerConfig> processV3Configs( + List<ApkSigningBlockUtils.SignerConfig> rawConfigs) throws InvalidKeyException { + // If the caller only specified targeted signing configs, ensure those configs cover the + // full range for V3 support (or the APK's minSdkVersion if > P). + int minRequiredV3SdkVersion = Math.max(AndroidSdkVersion.P, mMinSdkVersion); + if (mSignerConfigs.isEmpty() && + mTargetedSignerConfigs.get(0).getMinSdkVersion() > minRequiredV3SdkVersion) { + throw new IllegalArgumentException( + "The provided targeted signer configs do not cover the SDK range for V3 " + + "support; either provide the original signer or ensure a signer " + + "targets SDK version " + minRequiredV3SdkVersion); + } + + List<ApkSigningBlockUtils.SignerConfig> processedConfigs = new ArrayList<>(); + + // we have our configs, now touch them up to appropriately cover all SDK levels since APK + // signature scheme v3 was introduced + int currentMinSdk = Integer.MAX_VALUE; + for (int i = rawConfigs.size() - 1; i >= 0; i--) { + ApkSigningBlockUtils.SignerConfig config = rawConfigs.get(i); + if (config.signatureAlgorithms == null) { + // no valid algorithm was found for this signer, and we haven't yet covered all + // platform versions, something's wrong + String keyAlgorithm = config.certificates.get(0).getPublicKey().getAlgorithm(); + throw new InvalidKeyException( + "Unsupported key algorithm " + + keyAlgorithm + + " is " + + "not supported for APK Signature Scheme v3 signing"); + } + if (i == rawConfigs.size() - 1) { + // first go through the loop, config should support all future platform versions. + // this assumes we don't deprecate support for signers in the future. If we do, + // this needs to change + config.maxSdkVersion = Integer.MAX_VALUE; + } else { + // If the previous signer was targeting a development release, then the current + // signer's maxSdkVersion should overlap with the previous signer's minSdkVersion + // to ensure the current signer applies to the production release. + ApkSigningBlockUtils.SignerConfig prevSigner = processedConfigs.get( + processedConfigs.size() - 1); + if (prevSigner.signerTargetsDevRelease) { + config.maxSdkVersion = prevSigner.minSdkVersion; + } else { + config.maxSdkVersion = currentMinSdk - 1; + } + } + if (config.minSdkVersion == V3SchemeConstants.DEV_RELEASE) { + // If the current signer is targeting the current development release, then set + // the signer's minSdkVersion to the last production release and the flag indicating + // this signer is targeting a dev release. + config.minSdkVersion = V3SchemeConstants.PROD_RELEASE; + config.signerTargetsDevRelease = true; + } else if (config.minSdkVersion == 0) { + config.minSdkVersion = getMinSdkFromV3SignatureAlgorithms( + config.signatureAlgorithms); + } + // Truncate the lineage to the current signer if it is not the latest signer. + X509Certificate signerCert = config.certificates.get(0); + if (config.signingCertificateLineage != null + && !config.signingCertificateLineage.isCertificateLatestInLineage(signerCert)) { + config.signingCertificateLineage = config.signingCertificateLineage.getSubLineage( + signerCert); + } + // we know that this config will be used, so add it to our result, order doesn't matter + // at this point + processedConfigs.add(config); + currentMinSdk = config.minSdkVersion; + if (config.signerTargetsDevRelease ? currentMinSdk < minRequiredV3SdkVersion + : currentMinSdk <= minRequiredV3SdkVersion) { + // this satisfies all we need, stop here + break; + } + } + if (currentMinSdk > AndroidSdkVersion.P && currentMinSdk > mMinSdkVersion) { + // we can't cover all desired SDK versions, abort + throw new InvalidKeyException( + "Provided key algorithms not supported on all desired " + + "Android SDK versions"); + } + + return processedConfigs; + } + + private List<ApkSigningBlockUtils.SignerConfig> createV3SignerConfigs( + boolean apkSigningBlockPaddingSupported) throws InvalidKeyException { + return processV3Configs(createSigningBlockSignerConfigs(apkSigningBlockPaddingSupported, + ApkSigningBlockUtils.VERSION_APK_SIGNATURE_SCHEME_V3)); + } + + private List<ApkSigningBlockUtils.SignerConfig> processV31SignerConfigs( + List<ApkSigningBlockUtils.SignerConfig> v3SignerConfigs) { + // The V3.1 signature scheme supports SDK targeted signing config, but this scheme should + // only be used when a separate signing config exists for the V3.0 block. + if (v3SignerConfigs.size() == 1) { + return null; + } + + // When there are multiple signing configs, the signer with the minimum SDK version should + // be used for the V3.0 block, and all other signers should be used for the V3.1 block. + int signerMinSdkVersion = v3SignerConfigs.stream().mapToInt( + signer -> signer.minSdkVersion).min().orElse(AndroidSdkVersion.P); + List<ApkSigningBlockUtils.SignerConfig> v31SignerConfigs = new ArrayList<>(); + Iterator<ApkSigningBlockUtils.SignerConfig> v3SignerIterator = v3SignerConfigs.iterator(); + while (v3SignerIterator.hasNext()) { + ApkSigningBlockUtils.SignerConfig signerConfig = v3SignerIterator.next(); + // If the signer config's minSdkVersion supports V3.1 and is not the min signer in the + // list, then add it to the V3.1 signer configs and remove it from the V3.0 list. If + // the signer is targeting the minSdkVersion as a development release, then it should + // be included in V3.1 to allow the V3.0 block to target the production release of the + // same SDK version. + if (signerConfig.minSdkVersion >= MIN_SDK_WITH_V31_SUPPORT + && (signerConfig.minSdkVersion > signerMinSdkVersion + || (signerConfig.minSdkVersion >= signerMinSdkVersion + && signerConfig.signerTargetsDevRelease))) { + v31SignerConfigs.add(signerConfig); + v3SignerIterator.remove(); + } + } + return v31SignerConfigs; + } + + private V4SchemeSigner.SignerConfig createV4SignerConfig() throws InvalidKeyException { + List<ApkSigningBlockUtils.SignerConfig> v4Configs = createSigningBlockSignerConfigs(true, + ApkSigningBlockUtils.VERSION_APK_SIGNATURE_SCHEME_V4); + if (v4Configs.size() != 1) { + // V4 uses signer config to connect back to v3. Use the same filtering logic. + v4Configs = processV3Configs(v4Configs); + } + List<ApkSigningBlockUtils.SignerConfig> v41configs = processV31SignerConfigs(v4Configs); + return new V4SchemeSigner.SignerConfig(v4Configs, v41configs); + } + + private ApkSigningBlockUtils.SignerConfig createSourceStampSignerConfig() + throws InvalidKeyException { + ApkSigningBlockUtils.SignerConfig config = createSigningBlockSignerConfig( + mSourceStampSignerConfig, + /* apkSigningBlockPaddingSupported= */ false, + ApkSigningBlockUtils.VERSION_SOURCE_STAMP); + if (mSourceStampSigningCertificateLineage != null) { + config.signingCertificateLineage = mSourceStampSigningCertificateLineage.getSubLineage( + config.certificates.get(0)); + } + return config; + } + + private int getMinSdkFromV3SignatureAlgorithms(List<SignatureAlgorithm> algorithms) { + int min = Integer.MAX_VALUE; + for (SignatureAlgorithm algorithm : algorithms) { + int current = algorithm.getMinSdkVersion(); + if (current < min) { + if (current <= mMinSdkVersion || current <= AndroidSdkVersion.P) { + // this algorithm satisfies all of our needs, no need to keep looking + return current; + } else { + min = current; + } + } + } + return min; + } + + private List<ApkSigningBlockUtils.SignerConfig> createSigningBlockSignerConfigs( + boolean apkSigningBlockPaddingSupported, int schemeId) throws InvalidKeyException { + List<ApkSigningBlockUtils.SignerConfig> signerConfigs = + new ArrayList<>(mSignerConfigs.size() + mTargetedSignerConfigs.size()); + for (int i = 0; i < mSignerConfigs.size(); i++) { + SignerConfig signerConfig = mSignerConfigs.get(i); + signerConfigs.add( + createSigningBlockSignerConfig( + signerConfig, apkSigningBlockPaddingSupported, schemeId)); + } + if (schemeId >= VERSION_APK_SIGNATURE_SCHEME_V3) { + for (int i = 0; i < mTargetedSignerConfigs.size(); i++) { + SignerConfig signerConfig = mTargetedSignerConfigs.get(i); + signerConfigs.add( + createSigningBlockSignerConfig( + signerConfig, apkSigningBlockPaddingSupported, schemeId)); + } + } + return signerConfigs; + } + + private ApkSigningBlockUtils.SignerConfig createSigningBlockSignerConfig( + SignerConfig signerConfig, boolean apkSigningBlockPaddingSupported, int schemeId) + throws InvalidKeyException { + List<X509Certificate> certificates = signerConfig.getCertificates(); + PublicKey publicKey = certificates.get(0).getPublicKey(); + + ApkSigningBlockUtils.SignerConfig newSignerConfig = new ApkSigningBlockUtils.SignerConfig(); + newSignerConfig.privateKey = signerConfig.getPrivateKey(); + newSignerConfig.certificates = certificates; + newSignerConfig.minSdkVersion = signerConfig.getMinSdkVersion(); + newSignerConfig.signerTargetsDevRelease = signerConfig.getSignerTargetsDevRelease(); + newSignerConfig.signingCertificateLineage = signerConfig.getSigningCertificateLineage(); + + switch (schemeId) { + case ApkSigningBlockUtils.VERSION_APK_SIGNATURE_SCHEME_V2: + newSignerConfig.signatureAlgorithms = + V2SchemeSigner.getSuggestedSignatureAlgorithms( + publicKey, + mMinSdkVersion, + apkSigningBlockPaddingSupported && mVerityEnabled, + signerConfig.getDeterministicDsaSigning()); + break; + case ApkSigningBlockUtils.VERSION_APK_SIGNATURE_SCHEME_V3: + try { + newSignerConfig.signatureAlgorithms = + V3SchemeSigner.getSuggestedSignatureAlgorithms( + publicKey, + mMinSdkVersion, + apkSigningBlockPaddingSupported && mVerityEnabled, + signerConfig.getDeterministicDsaSigning()); + } catch (InvalidKeyException e) { + + // It is possible for a signer used for v1/v2 signing to not be allowed for use + // with v3 signing. This is ok as long as there exists a more recent v3 signer + // that covers all supported platform versions. Populate signatureAlgorithm + // with null, it will be cleaned-up in a later step. + newSignerConfig.signatureAlgorithms = null; + } + break; + case ApkSigningBlockUtils.VERSION_APK_SIGNATURE_SCHEME_V4: + try { + newSignerConfig.signatureAlgorithms = + V4SchemeSigner.getSuggestedSignatureAlgorithms( + publicKey, mMinSdkVersion, apkSigningBlockPaddingSupported, + signerConfig.getDeterministicDsaSigning()); + } catch (InvalidKeyException e) { + // V4 is an optional signing schema, ok to proceed without. + newSignerConfig.signatureAlgorithms = null; + } + break; + case ApkSigningBlockUtils.VERSION_SOURCE_STAMP: + newSignerConfig.signatureAlgorithms = + Collections.singletonList( + SignatureAlgorithm.RSA_PKCS1_V1_5_WITH_SHA256); + break; + default: + throw new IllegalArgumentException("Unknown APK Signature Scheme ID requested"); + } + return newSignerConfig; + } + + private boolean isDebuggable(String entryName) { + return mDebuggableApkPermitted + || !ApkUtils.ANDROID_MANIFEST_ZIP_ENTRY_NAME.equals(entryName); + } + + /** + * Initializes DefaultApkSignerEngine with the existing MANIFEST.MF. This reads existing digests + * from the MANIFEST.MF file (they are assumed correct) and stores them for the final signature + * without recalculation. This step has a significant performance benefit in case of incremental + * build. + * + * <p>This method extracts and stored computed digest for every entry that it would compute it + * for in the {@link #outputJarEntry(String)} method + * + * @param manifestBytes raw representation of MANIFEST.MF file + * @param entryNames a set of expected entries names + * @return set of entry names which were processed by the engine during the initialization, a + * subset of entryNames + */ + @Override + @SuppressWarnings("AndroidJdkLibsChecker") + public Set<String> initWith(byte[] manifestBytes, Set<String> entryNames) { + V1SchemeVerifier.Result result = new V1SchemeVerifier.Result(); + Pair<ManifestParser.Section, Map<String, ManifestParser.Section>> sections = + V1SchemeVerifier.parseManifest(manifestBytes, entryNames, result); + String alg = V1SchemeSigner.getJcaMessageDigestAlgorithm(mV1ContentDigestAlgorithm); + for (Map.Entry<String, ManifestParser.Section> entry : sections.getSecond().entrySet()) { + String entryName = entry.getKey(); + if (V1SchemeSigner.isJarEntryDigestNeededInManifest(entry.getKey()) + && isDebuggable(entryName)) { + + V1SchemeVerifier.NamedDigest extractedDigest = null; + Collection<V1SchemeVerifier.NamedDigest> digestsToVerify = + V1SchemeVerifier.getDigestsToVerify( + entry.getValue(), "-Digest", mMinSdkVersion, Integer.MAX_VALUE); + for (V1SchemeVerifier.NamedDigest digestToVerify : digestsToVerify) { + if (digestToVerify.jcaDigestAlgorithm.equals(alg)) { + extractedDigest = digestToVerify; + break; + } + } + if (extractedDigest != null) { + mOutputJarEntryDigests.put(entryName, extractedDigest.digest); + } + } + } + return mOutputJarEntryDigests.keySet(); + } + + @Override + public void setExecutor(RunnablesExecutor executor) { + mExecutor = executor; + } + + @Override + public void inputApkSigningBlock(DataSource apkSigningBlock) { + checkNotClosed(); + + if ((apkSigningBlock == null) || (apkSigningBlock.size() == 0)) { + return; + } + + if (mOtherSignersSignaturesPreserved) { + boolean schemeSignatureBlockPreserved = false; + mPreservedSignatureBlocks = new ArrayList<>(); + try { + List<Pair<byte[], Integer>> signatureBlocks = + ApkSigningBlockUtils.getApkSignatureBlocks(apkSigningBlock); + for (Pair<byte[], Integer> signatureBlock : signatureBlocks) { + if (signatureBlock.getSecond() == Constants.APK_SIGNATURE_SCHEME_V2_BLOCK_ID) { + // If a V2 signature block is found and the engine is configured to use V2 + // then save any of the previous signers that are not part of the current + // signing request. + if (mV2SigningEnabled) { + List<Pair<List<X509Certificate>, byte[]>> v2Signers = + ApkSigningBlockUtils.getApkSignatureBlockSigners( + signatureBlock.getFirst()); + mPreservedV2Signers = new ArrayList<>(v2Signers.size()); + for (Pair<List<X509Certificate>, byte[]> v2Signer : v2Signers) { + if (!isConfiguredWithSigner(v2Signer.getFirst())) { + mPreservedV2Signers.add(v2Signer.getSecond()); + schemeSignatureBlockPreserved = true; + } + } + } else { + // else V2 signing is not enabled; save the entire signature block to be + // added to the final APK signing block. + mPreservedSignatureBlocks.add(signatureBlock); + schemeSignatureBlockPreserved = true; + } + } else if (signatureBlock.getSecond() + == Constants.APK_SIGNATURE_SCHEME_V3_BLOCK_ID) { + // Preserving other signers in the presence of a V3 signature block is only + // supported if the engine is configured to resign the APK with the V3 + // signature scheme, and the V3 signer in the signature block is the same + // as the engine is configured to use. + if (!mV3SigningEnabled) { + throw new IllegalStateException( + "Preserving an existing V3 signature is not supported"); + } + List<Pair<List<X509Certificate>, byte[]>> v3Signers = + ApkSigningBlockUtils.getApkSignatureBlockSigners( + signatureBlock.getFirst()); + if (v3Signers.size() > 1) { + throw new IllegalArgumentException( + "The provided APK signing block contains " + v3Signers.size() + + " V3 signers; the V3 signature scheme only supports" + + " one signer"); + } + // If there is only a single V3 signer then ensure it is the signer + // configured to sign the APK. + if (v3Signers.size() == 1 + && !isConfiguredWithSigner(v3Signers.get(0).getFirst())) { + throw new IllegalStateException( + "The V3 signature scheme only supports one signer; a request " + + "was made to preserve the existing V3 signature, " + + "but the engine is configured to sign with a " + + "different signer"); + } + } else if (!DISCARDED_SIGNATURE_BLOCK_IDS.contains( + signatureBlock.getSecond())) { + mPreservedSignatureBlocks.add(signatureBlock); + } + } + } catch (ApkFormatException | CertificateException | IOException e) { + throw new IllegalArgumentException("Unable to parse the provided signing block", e); + } + // Signature scheme V3+ only support a single signer; if the engine is configured to + // sign with V3+ then ensure no scheme signature blocks have been preserved. + if (mV3SigningEnabled && schemeSignatureBlockPreserved) { + throw new IllegalStateException( + "Signature scheme V3+ only supports a single signer and cannot be " + + "appended to the existing signature scheme blocks"); + } + return; + } + } + + /** + * Returns whether the engine is configured to sign the APK with a signer using the specified + * {@code signerCerts}. + */ + private boolean isConfiguredWithSigner(List<X509Certificate> signerCerts) { + for (SignerConfig signerConfig : mSignerConfigs) { + if (signerCerts.containsAll(signerConfig.getCertificates())) { + return true; + } + } + return false; + } + + @Override + public InputJarEntryInstructions inputJarEntry(String entryName) { + checkNotClosed(); + + InputJarEntryInstructions.OutputPolicy outputPolicy = + getInputJarEntryOutputPolicy(entryName); + switch (outputPolicy) { + case SKIP: + return new InputJarEntryInstructions(InputJarEntryInstructions.OutputPolicy.SKIP); + case OUTPUT: + return new InputJarEntryInstructions(InputJarEntryInstructions.OutputPolicy.OUTPUT); + case OUTPUT_BY_ENGINE: + if (V1SchemeConstants.MANIFEST_ENTRY_NAME.equals(entryName)) { + // We copy the main section of the JAR manifest from input to output. Thus, this + // invalidates v1 signature and we need to see the entry's data. + mInputJarManifestEntryDataRequest = new GetJarEntryDataRequest(entryName); + return new InputJarEntryInstructions( + InputJarEntryInstructions.OutputPolicy.OUTPUT_BY_ENGINE, + mInputJarManifestEntryDataRequest); + } + return new InputJarEntryInstructions( + InputJarEntryInstructions.OutputPolicy.OUTPUT_BY_ENGINE); + default: + throw new RuntimeException("Unsupported output policy: " + outputPolicy); + } + } + + @Override + public InspectJarEntryRequest outputJarEntry(String entryName) { + checkNotClosed(); + invalidateV2Signature(); + + if (!isDebuggable(entryName)) { + forgetOutputApkDebuggableStatus(); + } + + if (!mV1SigningEnabled) { + // No need to inspect JAR entries when v1 signing is not enabled. + if (!isDebuggable(entryName)) { + // To reject debuggable APKs we need to inspect the APK's AndroidManifest.xml to + // check whether it declares that the APK is debuggable + mOutputAndroidManifestEntryDataRequest = new GetJarEntryDataRequest(entryName); + return mOutputAndroidManifestEntryDataRequest; + } + return null; + } + // v1 signing is enabled + + if (V1SchemeSigner.isJarEntryDigestNeededInManifest(entryName)) { + // This entry is covered by v1 signature. We thus need to inspect the entry's data to + // compute its digest(s) for v1 signature. + + // TODO: Handle the case where other signer's v1 signatures are present and need to be + // preserved. In that scenario we can't modify MANIFEST.MF and add/remove JAR entries + // covered by v1 signature. + invalidateV1Signature(); + GetJarEntryDataDigestRequest dataDigestRequest = + new GetJarEntryDataDigestRequest( + entryName, + V1SchemeSigner.getJcaMessageDigestAlgorithm(mV1ContentDigestAlgorithm)); + mOutputJarEntryDigestRequests.put(entryName, dataDigestRequest); + mOutputJarEntryDigests.remove(entryName); + + if ((!mDebuggableApkPermitted) + && (ApkUtils.ANDROID_MANIFEST_ZIP_ENTRY_NAME.equals(entryName))) { + // To reject debuggable APKs we need to inspect the APK's AndroidManifest.xml to + // check whether it declares that the APK is debuggable + mOutputAndroidManifestEntryDataRequest = new GetJarEntryDataRequest(entryName); + return new CompoundInspectJarEntryRequest( + entryName, mOutputAndroidManifestEntryDataRequest, dataDigestRequest); + } + + return dataDigestRequest; + } + + if (mSignatureExpectedOutputJarEntryNames.contains(entryName)) { + // This entry is part of v1 signature generated by this engine. We need to check whether + // the entry's data is as output by the engine. + invalidateV1Signature(); + GetJarEntryDataRequest dataRequest; + if (V1SchemeConstants.MANIFEST_ENTRY_NAME.equals(entryName)) { + dataRequest = new GetJarEntryDataRequest(entryName); + mInputJarManifestEntryDataRequest = dataRequest; + } else { + // If this entry is part of v1 signature which has been emitted by this engine, + // check whether the output entry's data matches what the engine emitted. + dataRequest = + (mEmittedSignatureJarEntryData.containsKey(entryName)) + ? new GetJarEntryDataRequest(entryName) + : null; + } + + if (dataRequest != null) { + mOutputSignatureJarEntryDataRequests.put(entryName, dataRequest); + } + return dataRequest; + } + + // This entry is not covered by v1 signature and isn't part of v1 signature. + return null; + } + + @Override + public InputJarEntryInstructions.OutputPolicy inputJarEntryRemoved(String entryName) { + checkNotClosed(); + return getInputJarEntryOutputPolicy(entryName); + } + + @Override + public void outputJarEntryRemoved(String entryName) { + checkNotClosed(); + invalidateV2Signature(); + if (!mV1SigningEnabled) { + return; + } + + if (V1SchemeSigner.isJarEntryDigestNeededInManifest(entryName)) { + // This entry is covered by v1 signature. + invalidateV1Signature(); + mOutputJarEntryDigests.remove(entryName); + mOutputJarEntryDigestRequests.remove(entryName); + mOutputSignatureJarEntryDataRequests.remove(entryName); + return; + } + + if (mSignatureExpectedOutputJarEntryNames.contains(entryName)) { + // This entry is part of the v1 signature generated by this engine. + invalidateV1Signature(); + return; + } + } + + @Override + public OutputJarSignatureRequest outputJarEntries() + throws ApkFormatException, InvalidKeyException, SignatureException, + NoSuchAlgorithmException { + checkNotClosed(); + + if (!mV1SignaturePending) { + return null; + } + + if ((mInputJarManifestEntryDataRequest != null) + && (!mInputJarManifestEntryDataRequest.isDone())) { + throw new IllegalStateException( + "Still waiting to inspect input APK's " + + mInputJarManifestEntryDataRequest.getEntryName()); + } + + for (GetJarEntryDataDigestRequest digestRequest : mOutputJarEntryDigestRequests.values()) { + String entryName = digestRequest.getEntryName(); + if (!digestRequest.isDone()) { + throw new IllegalStateException( + "Still waiting to inspect output APK's " + entryName); + } + mOutputJarEntryDigests.put(entryName, digestRequest.getDigest()); + } + if (isEligibleForSourceStamp()) { + MessageDigest messageDigest = + MessageDigest.getInstance( + V1SchemeSigner.getJcaMessageDigestAlgorithm(mV1ContentDigestAlgorithm)); + messageDigest.update(generateSourceStampCertificateDigest()); + mOutputJarEntryDigests.put( + SOURCE_STAMP_CERTIFICATE_HASH_ZIP_ENTRY_NAME, messageDigest.digest()); + } + mOutputJarEntryDigestRequests.clear(); + + for (GetJarEntryDataRequest dataRequest : mOutputSignatureJarEntryDataRequests.values()) { + if (!dataRequest.isDone()) { + throw new IllegalStateException( + "Still waiting to inspect output APK's " + dataRequest.getEntryName()); + } + } + + List<Integer> apkSigningSchemeIds = new ArrayList<>(); + if (mV2SigningEnabled) { + apkSigningSchemeIds.add(ApkSigningBlockUtils.VERSION_APK_SIGNATURE_SCHEME_V2); + } + if (mV3SigningEnabled) { + apkSigningSchemeIds.add(ApkSigningBlockUtils.VERSION_APK_SIGNATURE_SCHEME_V3); + } + byte[] inputJarManifest = + (mInputJarManifestEntryDataRequest != null) + ? mInputJarManifestEntryDataRequest.getData() + : null; + if (isEligibleForSourceStamp()) { + inputJarManifest = + V1SchemeSigner.generateManifestFile( + mV1ContentDigestAlgorithm, + mOutputJarEntryDigests, + inputJarManifest) + .contents; + } + + // Check whether the most recently used signature (if present) is still fine. + checkOutputApkNotDebuggableIfDebuggableMustBeRejected(); + List<Pair<String, byte[]>> signatureZipEntries; + if ((mAddV1SignatureRequest == null) || (!mAddV1SignatureRequest.isDone())) { + try { + signatureZipEntries = + V1SchemeSigner.sign( + mV1SignerConfigs, + mV1ContentDigestAlgorithm, + mOutputJarEntryDigests, + apkSigningSchemeIds, + inputJarManifest, + mCreatedBy); + } catch (CertificateException e) { + throw new SignatureException("Failed to generate v1 signature", e); + } + } else { + V1SchemeSigner.OutputManifestFile newManifest = + V1SchemeSigner.generateManifestFile( + mV1ContentDigestAlgorithm, mOutputJarEntryDigests, inputJarManifest); + byte[] emittedSignatureManifest = + mEmittedSignatureJarEntryData.get(V1SchemeConstants.MANIFEST_ENTRY_NAME); + if (!Arrays.equals(newManifest.contents, emittedSignatureManifest)) { + // Emitted v1 signature is no longer valid. + try { + signatureZipEntries = + V1SchemeSigner.signManifest( + mV1SignerConfigs, + mV1ContentDigestAlgorithm, + apkSigningSchemeIds, + mCreatedBy, + newManifest); + } catch (CertificateException e) { + throw new SignatureException("Failed to generate v1 signature", e); + } + } else { + // Emitted v1 signature is still valid. Check whether the signature is there in the + // output. + signatureZipEntries = new ArrayList<>(); + for (Map.Entry<String, byte[]> expectedOutputEntry : + mEmittedSignatureJarEntryData.entrySet()) { + String entryName = expectedOutputEntry.getKey(); + byte[] expectedData = expectedOutputEntry.getValue(); + GetJarEntryDataRequest actualDataRequest = + mOutputSignatureJarEntryDataRequests.get(entryName); + if (actualDataRequest == null) { + // This signature entry hasn't been output. + signatureZipEntries.add(Pair.of(entryName, expectedData)); + continue; + } + byte[] actualData = actualDataRequest.getData(); + if (!Arrays.equals(expectedData, actualData)) { + signatureZipEntries.add(Pair.of(entryName, expectedData)); + } + } + if (signatureZipEntries.isEmpty()) { + // v1 signature in the output is valid + return null; + } + // v1 signature in the output is not valid. + } + } + + if (signatureZipEntries.isEmpty()) { + // v1 signature in the output is valid + mV1SignaturePending = false; + return null; + } + + List<OutputJarSignatureRequest.JarEntry> sigEntries = + new ArrayList<>(signatureZipEntries.size()); + for (Pair<String, byte[]> entry : signatureZipEntries) { + String entryName = entry.getFirst(); + byte[] entryData = entry.getSecond(); + sigEntries.add(new OutputJarSignatureRequest.JarEntry(entryName, entryData)); + mEmittedSignatureJarEntryData.put(entryName, entryData); + } + mAddV1SignatureRequest = new OutputJarSignatureRequestImpl(sigEntries); + return mAddV1SignatureRequest; + } + + @Deprecated + @Override + public OutputApkSigningBlockRequest outputZipSections( + DataSource zipEntries, DataSource zipCentralDirectory, DataSource zipEocd) + throws IOException, InvalidKeyException, SignatureException, NoSuchAlgorithmException { + return outputZipSectionsInternal(zipEntries, zipCentralDirectory, zipEocd, false); + } + + @Override + public OutputApkSigningBlockRequest2 outputZipSections2( + DataSource zipEntries, DataSource zipCentralDirectory, DataSource zipEocd) + throws IOException, InvalidKeyException, SignatureException, NoSuchAlgorithmException { + return outputZipSectionsInternal(zipEntries, zipCentralDirectory, zipEocd, true); + } + + private OutputApkSigningBlockRequestImpl outputZipSectionsInternal( + DataSource zipEntries, + DataSource zipCentralDirectory, + DataSource zipEocd, + boolean apkSigningBlockPaddingSupported) + throws IOException, InvalidKeyException, SignatureException, NoSuchAlgorithmException { + checkNotClosed(); + checkV1SigningDoneIfEnabled(); + if (!mV2SigningEnabled && !mV3SigningEnabled && !isEligibleForSourceStamp()) { + return null; + } + checkOutputApkNotDebuggableIfDebuggableMustBeRejected(); + + // adjust to proper padding + Pair<DataSource, Integer> paddingPair = + ApkSigningBlockUtils.generateApkSigningBlockPadding( + zipEntries, apkSigningBlockPaddingSupported); + DataSource beforeCentralDir = paddingPair.getFirst(); + int padSizeBeforeApkSigningBlock = paddingPair.getSecond(); + DataSource eocd = ApkSigningBlockUtils.copyWithModifiedCDOffset(beforeCentralDir, zipEocd); + + List<Pair<byte[], Integer>> signingSchemeBlocks = new ArrayList<>(); + ApkSigningBlockUtils.SigningSchemeBlockAndDigests v2SigningSchemeBlockAndDigests = null; + ApkSigningBlockUtils.SigningSchemeBlockAndDigests v3SigningSchemeBlockAndDigests = null; + // If the engine is configured to preserve previous signature blocks and any were found in + // the existing APK signing block then add them to the list to be used to generate the + // new APK signing block. + if (mOtherSignersSignaturesPreserved && mPreservedSignatureBlocks != null + && !mPreservedSignatureBlocks.isEmpty()) { + signingSchemeBlocks.addAll(mPreservedSignatureBlocks); + } + + // create APK Signature Scheme V2 Signature if requested + if (mV2SigningEnabled) { + invalidateV2Signature(); + List<ApkSigningBlockUtils.SignerConfig> v2SignerConfigs = + createV2SignerConfigs(apkSigningBlockPaddingSupported); + v2SigningSchemeBlockAndDigests = + V2SchemeSigner.generateApkSignatureSchemeV2Block( + mExecutor, + beforeCentralDir, + zipCentralDirectory, + eocd, + v2SignerConfigs, + mV3SigningEnabled, + mOtherSignersSignaturesPreserved ? mPreservedV2Signers : null); + signingSchemeBlocks.add(v2SigningSchemeBlockAndDigests.signingSchemeBlock); + } + if (mV3SigningEnabled) { + invalidateV3Signature(); + List<ApkSigningBlockUtils.SignerConfig> v3SignerConfigs = + createV3SignerConfigs(apkSigningBlockPaddingSupported); + List<ApkSigningBlockUtils.SignerConfig> v31SignerConfigs = processV31SignerConfigs( + v3SignerConfigs); + if (v31SignerConfigs != null && v31SignerConfigs.size() > 0) { + ApkSigningBlockUtils.SigningSchemeBlockAndDigests + v31SigningSchemeBlockAndDigests = + new V3SchemeSigner.Builder(beforeCentralDir, zipCentralDirectory, eocd, + v31SignerConfigs) + .setRunnablesExecutor(mExecutor) + .setBlockId(V3SchemeConstants.APK_SIGNATURE_SCHEME_V31_BLOCK_ID) + .build() + .generateApkSignatureSchemeV3BlockAndDigests(); + signingSchemeBlocks.add(v31SigningSchemeBlockAndDigests.signingSchemeBlock); + } + V3SchemeSigner.Builder builder = new V3SchemeSigner.Builder(beforeCentralDir, + zipCentralDirectory, eocd, v3SignerConfigs) + .setRunnablesExecutor(mExecutor) + .setBlockId(V3SchemeConstants.APK_SIGNATURE_SCHEME_V3_BLOCK_ID); + if (v31SignerConfigs != null && !v31SignerConfigs.isEmpty()) { + // The V3.1 stripping protection writes the minimum SDK version from the targeted + // signers as an additional attribute in the V3.0 signing block. + int minSdkVersionForV31 = v31SignerConfigs.stream().mapToInt( + signer -> signer.minSdkVersion).min().orElse(MIN_SDK_WITH_V31_SUPPORT); + builder.setMinSdkVersionForV31(minSdkVersionForV31); + } + v3SigningSchemeBlockAndDigests = + builder.build().generateApkSignatureSchemeV3BlockAndDigests(); + signingSchemeBlocks.add(v3SigningSchemeBlockAndDigests.signingSchemeBlock); + } + if (isEligibleForSourceStamp()) { + ApkSigningBlockUtils.SignerConfig sourceStampSignerConfig = + createSourceStampSignerConfig(); + Map<Integer, Map<ContentDigestAlgorithm, byte[]>> signatureSchemeDigestInfos = + new HashMap<>(); + if (mV3SigningEnabled) { + signatureSchemeDigestInfos.put( + VERSION_APK_SIGNATURE_SCHEME_V3, v3SigningSchemeBlockAndDigests.digestInfo); + } + if (mV2SigningEnabled) { + signatureSchemeDigestInfos.put( + VERSION_APK_SIGNATURE_SCHEME_V2, v2SigningSchemeBlockAndDigests.digestInfo); + } + if (mV1SigningEnabled) { + Map<ContentDigestAlgorithm, byte[]> v1SigningSchemeDigests = new HashMap<>(); + try { + // Jar signing related variables must have been already populated at this point + // if V1 signing is enabled since it is happening before computations on the APK + // signing block (V2/V3/V4/SourceStamp signing). + byte[] inputJarManifest = + (mInputJarManifestEntryDataRequest != null) + ? mInputJarManifestEntryDataRequest.getData() + : null; + byte[] jarManifest = + V1SchemeSigner.generateManifestFile( + mV1ContentDigestAlgorithm, + mOutputJarEntryDigests, + inputJarManifest) + .contents; + // The digest of the jar manifest does not need to be computed in chunks due to + // the small size of the manifest. + v1SigningSchemeDigests.put( + ContentDigestAlgorithm.SHA256, computeSha256DigestBytes(jarManifest)); + } catch (ApkFormatException e) { + throw new RuntimeException("Failed to generate manifest file", e); + } + signatureSchemeDigestInfos.put( + VERSION_JAR_SIGNATURE_SCHEME, v1SigningSchemeDigests); + } + V2SourceStampSigner v2SourceStampSigner = + new V2SourceStampSigner.Builder(sourceStampSignerConfig, + signatureSchemeDigestInfos) + .setSourceStampTimestampEnabled(mSourceStampTimestampEnabled) + .build(); + signingSchemeBlocks.add(v2SourceStampSigner.generateSourceStampBlock()); + } + + // create APK Signing Block with v2 and/or v3 and/or SourceStamp blocks + byte[] apkSigningBlock = ApkSigningBlockUtils.generateApkSigningBlock(signingSchemeBlocks); + + mAddSigningBlockRequest = + new OutputApkSigningBlockRequestImpl(apkSigningBlock, padSizeBeforeApkSigningBlock); + return mAddSigningBlockRequest; + } + + @Override + public void outputDone() { + checkNotClosed(); + checkV1SigningDoneIfEnabled(); + checkSigningBlockDoneIfEnabled(); + } + + @Override + public void signV4(DataSource dataSource, File outputFile, boolean ignoreFailures) + throws SignatureException { + if (outputFile == null) { + if (ignoreFailures) { + return; + } + throw new SignatureException("Missing V4 output file."); + } + try { + V4SchemeSigner.SignerConfig v4SignerConfig = createV4SignerConfig(); + V4SchemeSigner.generateV4Signature(dataSource, v4SignerConfig, outputFile); + } catch (InvalidKeyException | IOException | NoSuchAlgorithmException e) { + if (ignoreFailures) { + return; + } + throw new SignatureException("V4 signing failed", e); + } + } + + /** For external use only to generate V4 & tree separately. */ + public byte[] produceV4Signature(DataSource dataSource, OutputStream sigOutput) + throws SignatureException { + if (sigOutput == null) { + throw new SignatureException("Missing V4 output streams."); + } + try { + V4SchemeSigner.SignerConfig v4SignerConfig = createV4SignerConfig(); + Pair<V4Signature, byte[]> pair = + V4SchemeSigner.generateV4Signature(dataSource, v4SignerConfig); + pair.getFirst().writeTo(sigOutput); + return pair.getSecond(); + } catch (InvalidKeyException | IOException | NoSuchAlgorithmException e) { + throw new SignatureException("V4 signing failed", e); + } + } + + @Override + public boolean isEligibleForSourceStamp() { + return mSourceStampSignerConfig != null + && (mV2SigningEnabled || mV3SigningEnabled || mV1SigningEnabled); + } + + @Override + public byte[] generateSourceStampCertificateDigest() throws SignatureException { + if (mSourceStampSignerConfig.getCertificates().isEmpty()) { + throw new SignatureException("No certificates configured for stamp"); + } + try { + return computeSha256DigestBytes( + mSourceStampSignerConfig.getCertificates().get(0).getEncoded()); + } catch (CertificateEncodingException e) { + throw new SignatureException("Failed to encode source stamp certificate", e); + } + } + + @Override + public void close() { + mClosed = true; + + mAddV1SignatureRequest = null; + mInputJarManifestEntryDataRequest = null; + mOutputAndroidManifestEntryDataRequest = null; + mDebuggable = null; + mOutputJarEntryDigestRequests.clear(); + mOutputJarEntryDigests.clear(); + mEmittedSignatureJarEntryData.clear(); + mOutputSignatureJarEntryDataRequests.clear(); + + mAddSigningBlockRequest = null; + } + + private void invalidateV1Signature() { + if (mV1SigningEnabled) { + mV1SignaturePending = true; + } + invalidateV2Signature(); + } + + private void invalidateV2Signature() { + if (mV2SigningEnabled) { + mV2SignaturePending = true; + mAddSigningBlockRequest = null; + } + } + + private void invalidateV3Signature() { + if (mV3SigningEnabled) { + mV3SignaturePending = true; + mAddSigningBlockRequest = null; + } + } + + private void checkNotClosed() { + if (mClosed) { + throw new IllegalStateException("Engine closed"); + } + } + + private void checkV1SigningDoneIfEnabled() { + if (!mV1SignaturePending) { + return; + } + + if (mAddV1SignatureRequest == null) { + throw new IllegalStateException( + "v1 signature (JAR signature) not yet generated. Skipped outputJarEntries()?"); + } + if (!mAddV1SignatureRequest.isDone()) { + throw new IllegalStateException( + "v1 signature (JAR signature) addition requested by outputJarEntries() hasn't" + + " been fulfilled"); + } + for (Map.Entry<String, byte[]> expectedOutputEntry : + mEmittedSignatureJarEntryData.entrySet()) { + String entryName = expectedOutputEntry.getKey(); + byte[] expectedData = expectedOutputEntry.getValue(); + GetJarEntryDataRequest actualDataRequest = + mOutputSignatureJarEntryDataRequests.get(entryName); + if (actualDataRequest == null) { + throw new IllegalStateException( + "APK entry " + + entryName + + " not yet output despite this having been" + + " requested"); + } else if (!actualDataRequest.isDone()) { + throw new IllegalStateException( + "Still waiting to inspect output APK's " + entryName); + } + byte[] actualData = actualDataRequest.getData(); + if (!Arrays.equals(expectedData, actualData)) { + throw new IllegalStateException( + "Output APK entry " + entryName + " data differs from what was requested"); + } + } + mV1SignaturePending = false; + } + + private void checkSigningBlockDoneIfEnabled() { + if (!mV2SignaturePending && !mV3SignaturePending) { + return; + } + if (mAddSigningBlockRequest == null) { + throw new IllegalStateException( + "Signed APK Signing BLock not yet generated. Skipped outputZipSections()?"); + } + if (!mAddSigningBlockRequest.isDone()) { + throw new IllegalStateException( + "APK Signing Block addition of signature(s) requested by" + + " outputZipSections() hasn't been fulfilled yet"); + } + mAddSigningBlockRequest = null; + mV2SignaturePending = false; + mV3SignaturePending = false; + } + + private void checkOutputApkNotDebuggableIfDebuggableMustBeRejected() throws SignatureException { + if (mDebuggableApkPermitted) { + return; + } + + try { + if (isOutputApkDebuggable()) { + throw new SignatureException( + "APK is debuggable (see android:debuggable attribute) and this engine is" + + " configured to refuse to sign debuggable APKs"); + } + } catch (ApkFormatException e) { + throw new SignatureException("Failed to determine whether the APK is debuggable", e); + } + } + + /** + * Returns whether the output APK is debuggable according to its {@code android:debuggable} + * declaration. + */ + private boolean isOutputApkDebuggable() throws ApkFormatException { + if (mDebuggable != null) { + return mDebuggable; + } + + if (mOutputAndroidManifestEntryDataRequest == null) { + throw new IllegalStateException( + "Cannot determine debuggable status of output APK because " + + ApkUtils.ANDROID_MANIFEST_ZIP_ENTRY_NAME + + " entry contents have not yet been requested"); + } + + if (!mOutputAndroidManifestEntryDataRequest.isDone()) { + throw new IllegalStateException( + "Still waiting to inspect output APK's " + + mOutputAndroidManifestEntryDataRequest.getEntryName()); + } + mDebuggable = + ApkUtils.getDebuggableFromBinaryAndroidManifest( + ByteBuffer.wrap(mOutputAndroidManifestEntryDataRequest.getData())); + return mDebuggable; + } + + private void forgetOutputApkDebuggableStatus() { + mDebuggable = null; + } + + /** Returns the output policy for the provided input JAR entry. */ + private InputJarEntryInstructions.OutputPolicy getInputJarEntryOutputPolicy(String entryName) { + if (mSignatureExpectedOutputJarEntryNames.contains(entryName)) { + return InputJarEntryInstructions.OutputPolicy.OUTPUT_BY_ENGINE; + } + if ((mOtherSignersSignaturesPreserved) + || (V1SchemeSigner.isJarEntryDigestNeededInManifest(entryName))) { + return InputJarEntryInstructions.OutputPolicy.OUTPUT; + } + return InputJarEntryInstructions.OutputPolicy.SKIP; + } + + private static class OutputJarSignatureRequestImpl implements OutputJarSignatureRequest { + private final List<JarEntry> mAdditionalJarEntries; + private volatile boolean mDone; + + private OutputJarSignatureRequestImpl(List<JarEntry> additionalZipEntries) { + mAdditionalJarEntries = + Collections.unmodifiableList(new ArrayList<>(additionalZipEntries)); + } + + @Override + public List<JarEntry> getAdditionalJarEntries() { + return mAdditionalJarEntries; + } + + @Override + public void done() { + mDone = true; + } + + private boolean isDone() { + return mDone; + } + } + + @SuppressWarnings("deprecation") + private static class OutputApkSigningBlockRequestImpl + implements OutputApkSigningBlockRequest, OutputApkSigningBlockRequest2 { + private final byte[] mApkSigningBlock; + private final int mPaddingBeforeApkSigningBlock; + private volatile boolean mDone; + + private OutputApkSigningBlockRequestImpl(byte[] apkSigingBlock, int paddingBefore) { + mApkSigningBlock = apkSigingBlock.clone(); + mPaddingBeforeApkSigningBlock = paddingBefore; + } + + @Override + public byte[] getApkSigningBlock() { + return mApkSigningBlock.clone(); + } + + @Override + public void done() { + mDone = true; + } + + private boolean isDone() { + return mDone; + } + + @Override + public int getPaddingSizeBeforeApkSigningBlock() { + return mPaddingBeforeApkSigningBlock; + } + } + + /** JAR entry inspection request which obtain the entry's uncompressed data. */ + private static class GetJarEntryDataRequest implements InspectJarEntryRequest { + private final String mEntryName; + private final Object mLock = new Object(); + + private boolean mDone; + private DataSink mDataSink; + private ByteArrayOutputStream mDataSinkBuf; + + private GetJarEntryDataRequest(String entryName) { + mEntryName = entryName; + } + + @Override + public String getEntryName() { + return mEntryName; + } + + @Override + public DataSink getDataSink() { + synchronized (mLock) { + checkNotDone(); + if (mDataSinkBuf == null) { + mDataSinkBuf = new ByteArrayOutputStream(); + } + if (mDataSink == null) { + mDataSink = DataSinks.asDataSink(mDataSinkBuf); + } + return mDataSink; + } + } + + @Override + public void done() { + synchronized (mLock) { + if (mDone) { + return; + } + mDone = true; + } + } + + private boolean isDone() { + synchronized (mLock) { + return mDone; + } + } + + private void checkNotDone() throws IllegalStateException { + synchronized (mLock) { + if (mDone) { + throw new IllegalStateException("Already done"); + } + } + } + + private byte[] getData() { + synchronized (mLock) { + if (!mDone) { + throw new IllegalStateException("Not yet done"); + } + return (mDataSinkBuf != null) ? mDataSinkBuf.toByteArray() : new byte[0]; + } + } + } + + /** JAR entry inspection request which obtains the digest of the entry's uncompressed data. */ + private static class GetJarEntryDataDigestRequest implements InspectJarEntryRequest { + private final String mEntryName; + private final String mJcaDigestAlgorithm; + private final Object mLock = new Object(); + + private boolean mDone; + private DataSink mDataSink; + private MessageDigest mMessageDigest; + private byte[] mDigest; + + private GetJarEntryDataDigestRequest(String entryName, String jcaDigestAlgorithm) { + mEntryName = entryName; + mJcaDigestAlgorithm = jcaDigestAlgorithm; + } + + @Override + public String getEntryName() { + return mEntryName; + } + + @Override + public DataSink getDataSink() { + synchronized (mLock) { + checkNotDone(); + if (mDataSink == null) { + mDataSink = DataSinks.asDataSink(getMessageDigest()); + } + return mDataSink; + } + } + + private MessageDigest getMessageDigest() { + synchronized (mLock) { + if (mMessageDigest == null) { + try { + mMessageDigest = MessageDigest.getInstance(mJcaDigestAlgorithm); + } catch (NoSuchAlgorithmException e) { + throw new RuntimeException( + mJcaDigestAlgorithm + " MessageDigest not available", e); + } + } + return mMessageDigest; + } + } + + @Override + public void done() { + synchronized (mLock) { + if (mDone) { + return; + } + mDone = true; + mDigest = getMessageDigest().digest(); + mMessageDigest = null; + mDataSink = null; + } + } + + private boolean isDone() { + synchronized (mLock) { + return mDone; + } + } + + private void checkNotDone() throws IllegalStateException { + synchronized (mLock) { + if (mDone) { + throw new IllegalStateException("Already done"); + } + } + } + + private byte[] getDigest() { + synchronized (mLock) { + if (!mDone) { + throw new IllegalStateException("Not yet done"); + } + return mDigest.clone(); + } + } + } + + /** JAR entry inspection request which transparently satisfies multiple such requests. */ + private static class CompoundInspectJarEntryRequest implements InspectJarEntryRequest { + private final String mEntryName; + private final InspectJarEntryRequest[] mRequests; + private final Object mLock = new Object(); + + private DataSink mSink; + + private CompoundInspectJarEntryRequest( + String entryName, InspectJarEntryRequest... requests) { + mEntryName = entryName; + mRequests = requests; + } + + @Override + public String getEntryName() { + return mEntryName; + } + + @Override + public DataSink getDataSink() { + synchronized (mLock) { + if (mSink == null) { + DataSink[] sinks = new DataSink[mRequests.length]; + for (int i = 0; i < sinks.length; i++) { + sinks[i] = mRequests[i].getDataSink(); + } + mSink = new TeeDataSink(sinks); + } + return mSink; + } + } + + @Override + public void done() { + for (InspectJarEntryRequest request : mRequests) { + request.done(); + } + } + } + + /** + * Configuration of a signer. + * + * <p>Use {@link Builder} to obtain configuration instances. + */ + public static class SignerConfig { + private final String mName; + private final PrivateKey mPrivateKey; + private final List<X509Certificate> mCertificates; + private final boolean mDeterministicDsaSigning; + private final int mMinSdkVersion; + private final boolean mSignerTargetsDevRelease; + private final SigningCertificateLineage mSigningCertificateLineage; + + private SignerConfig(Builder builder) { + mName = builder.mName; + mPrivateKey = builder.mPrivateKey; + mCertificates = Collections.unmodifiableList(new ArrayList<>(builder.mCertificates)); + mDeterministicDsaSigning = builder.mDeterministicDsaSigning; + mMinSdkVersion = builder.mMinSdkVersion; + mSignerTargetsDevRelease = builder.mSignerTargetsDevRelease; + mSigningCertificateLineage = builder.mSigningCertificateLineage; + } + + /** Returns the name of this signer. */ + public String getName() { + return mName; + } + + /** Returns the signing key of this signer. */ + public PrivateKey getPrivateKey() { + return mPrivateKey; + } + + /** + * Returns the certificate(s) of this signer. The first certificate's public key corresponds + * to this signer's private key. + */ + public List<X509Certificate> getCertificates() { + return mCertificates; + } + + /** + * If this signer is a DSA signer, whether or not the signing is done deterministically. + */ + public boolean getDeterministicDsaSigning() { + return mDeterministicDsaSigning; + } + + /** Returns the minimum SDK version for which this signer should be used. */ + public int getMinSdkVersion() { + return mMinSdkVersion; + } + + /** Returns whether this signer targets a development release. */ + public boolean getSignerTargetsDevRelease() { + return mSignerTargetsDevRelease; + } + + /** Returns the {@link SigningCertificateLineage} for this signer. */ + public SigningCertificateLineage getSigningCertificateLineage() { + return mSigningCertificateLineage; + } + + /** Builder of {@link SignerConfig} instances. */ + public static class Builder { + private final String mName; + private final PrivateKey mPrivateKey; + private final List<X509Certificate> mCertificates; + private final boolean mDeterministicDsaSigning; + private int mMinSdkVersion; + private boolean mSignerTargetsDevRelease; + private SigningCertificateLineage mSigningCertificateLineage; + + /** + * Constructs a new {@code Builder}. + * + * @param name signer's name. The name is reflected in the name of files comprising the + * JAR signature of the APK. + * @param privateKey signing key + * @param certificates list of one or more X.509 certificates. The subject public key of + * the first certificate must correspond to the {@code privateKey}. + */ + public Builder(String name, PrivateKey privateKey, List<X509Certificate> certificates) { + this(name, privateKey, certificates, false); + } + + /** + * Constructs a new {@code Builder}. + * + * @param name signer's name. The name is reflected in the name of files comprising the + * JAR signature of the APK. + * @param privateKey signing key + * @param certificates list of one or more X.509 certificates. The subject public key of + * the first certificate must correspond to the {@code privateKey}. + * @param deterministicDsaSigning When signing using DSA, whether or not the + * deterministic signing algorithm variant (RFC6979) should be used. + */ + public Builder(String name, PrivateKey privateKey, List<X509Certificate> certificates, + boolean deterministicDsaSigning) { + if (name.isEmpty()) { + throw new IllegalArgumentException("Empty name"); + } + mName = name; + mPrivateKey = privateKey; + mCertificates = new ArrayList<>(certificates); + mDeterministicDsaSigning = deterministicDsaSigning; + } + + /** @see #setLineageForMinSdkVersion(SigningCertificateLineage, int) */ + public Builder setMinSdkVersion(int minSdkVersion) { + return setLineageForMinSdkVersion(null, minSdkVersion); + } + + /** + * Sets the specified {@code minSdkVersion} as the minimum Android platform version + * (API level) for which the provided {@code lineage} (where applicable) should be used + * to produce the APK's signature. This method is useful if callers want to specify a + * particular rotated signer or lineage with restricted capabilities for later + * platform releases. + * + * <p><em>Note:</em>>The V1 and V2 signature schemes do not support key rotation and + * signing lineages with capabilities; only an app's original signer(s) can be used for + * the V1 and V2 signature blocks. Because of this, only a value of {@code + * minSdkVersion} >= 28 (Android P) where support for the V3 signature scheme was + * introduced can be specified. + * + * <p><em>Note:</em>Due to limitations with platform targeting in the V3.0 signature + * scheme, specifying a {@code minSdkVersion} value <= 32 (Android Sv2) will result in + * the current {@code SignerConfig} being used in the V3.0 signing block and applied to + * Android P through at least Sv2 (and later depending on the {@code minSdkVersion} for + * subsequent {@code SignerConfig} instances). Because of this, only a single {@code + * SignerConfig} can be instantiated with a minimum SDK version <= 32. + * + * @param lineage the {@code SigningCertificateLineage} to target the specified {@code + * minSdkVersion} + * @param minSdkVersion the minimum SDK version for which this {@code SignerConfig} + * should be used + * @return this {@code Builder} instance + * + * @throws IllegalArgumentException if the provided {@code minSdkVersion} < 28 or the + * certificate provided in the constructor is not in the specified {@code lineage}. + */ + public Builder setLineageForMinSdkVersion(SigningCertificateLineage lineage, + int minSdkVersion) { + if (minSdkVersion < AndroidSdkVersion.P) { + throw new IllegalArgumentException( + "SDK targeted signing config is only supported with the V3 signature " + + "scheme on Android P (SDK version " + + AndroidSdkVersion.P + ") and later"); + } + if (minSdkVersion < MIN_SDK_WITH_V31_SUPPORT) { + minSdkVersion = AndroidSdkVersion.P; + } + mMinSdkVersion = minSdkVersion; + // If a lineage is provided, ensure the signing certificate for this signer is in + // the lineage; in the case of multiple signing certificates, the first is always + // used in the lineage. + if (lineage != null && !lineage.isCertificateInLineage(mCertificates.get(0))) { + throw new IllegalArgumentException( + "The provided lineage does not contain the signing certificate, " + + mCertificates.get(0).getSubjectDN() + + ", for this SignerConfig"); + } + mSigningCertificateLineage = lineage; + return this; + } + + /** + * Sets whether this signer's min SDK version is intended to target a development + * release. + * + * <p>This is primarily required for a signer testing on a platform's development + * release; however, it is recommended that signer's use the latest development SDK + * version instead of explicitly specifying this boolean. This class will properly + * handle an SDK that is currently targeting a development release and will use the + * finalized SDK version on release. + */ + private Builder setSignerTargetsDevRelease(boolean signerTargetsDevRelease) { + if (signerTargetsDevRelease && mMinSdkVersion < MIN_SDK_WITH_V31_SUPPORT) { + throw new IllegalArgumentException( + "Rotation can only target a development release for signers targeting " + + MIN_SDK_WITH_V31_SUPPORT + " or later"); + } + mSignerTargetsDevRelease = signerTargetsDevRelease; + return this; + } + + + /** + * Returns a new {@code SignerConfig} instance configured based on the configuration of + * this builder. + */ + public SignerConfig build() { + return new SignerConfig(this); + } + } + } + + /** Builder of {@link DefaultApkSignerEngine} instances. */ + public static class Builder { + private List<SignerConfig> mSignerConfigs; + private List<SignerConfig> mTargetedSignerConfigs; + private SignerConfig mStampSignerConfig; + private SigningCertificateLineage mSourceStampSigningCertificateLineage; + private boolean mSourceStampTimestampEnabled = true; + private final int mMinSdkVersion; + + private boolean mV1SigningEnabled = true; + private boolean mV2SigningEnabled = true; + private boolean mV3SigningEnabled = true; + private int mRotationMinSdkVersion = V3SchemeConstants.DEFAULT_ROTATION_MIN_SDK_VERSION; + private boolean mRotationTargetsDevRelease = false; + private boolean mVerityEnabled = false; + private boolean mDebuggableApkPermitted = true; + private boolean mOtherSignersSignaturesPreserved; + private String mCreatedBy = "1.0 (Android)"; + + private SigningCertificateLineage mSigningCertificateLineage; + + // APK Signature Scheme v3 only supports a single signing certificate, so to move to v3 + // signing by default, but not require prior clients to update to explicitly disable v3 + // signing for multiple signers, we modify the mV3SigningEnabled depending on the provided + // inputs (multiple signers and mSigningCertificateLineage in particular). Maintain two + // extra variables to record whether or not mV3SigningEnabled has been set directly by a + // client and so should override the default behavior. + private boolean mV3SigningExplicitlyDisabled = false; + private boolean mV3SigningExplicitlyEnabled = false; + + /** + * Constructs a new {@code Builder}. + * + * @param signerConfigs information about signers with which the APK will be signed. At + * least one signer configuration must be provided. + * @param minSdkVersion API Level of the oldest Android platform on which the APK is + * supposed to be installed. See {@code minSdkVersion} attribute in the APK's {@code + * AndroidManifest.xml}. The higher the version, the stronger signing features will be + * enabled. + */ + public Builder(List<SignerConfig> signerConfigs, int minSdkVersion) { + if (signerConfigs.isEmpty()) { + throw new IllegalArgumentException("At least one signer config must be provided"); + } + if (signerConfigs.size() > 1) { + // APK Signature Scheme v3 only supports single signer, unless a + // SigningCertificateLineage is provided, in which case this will be reset to true, + // since we don't yet have a v4 scheme about which to worry + mV3SigningEnabled = false; + } + mSignerConfigs = new ArrayList<>(signerConfigs); + mMinSdkVersion = minSdkVersion; + } + + /** + * Sets the APK signature schemes that should be enabled based on the options provided by + * the caller. + */ + private void setEnabledSignatureSchemes() { + if (mV3SigningExplicitlyDisabled && mV3SigningExplicitlyEnabled) { + throw new IllegalStateException( + "Builder configured to both enable and disable APK " + + "Signature Scheme v3 signing"); + } + if (mV3SigningExplicitlyDisabled) { + mV3SigningEnabled = false; + } else if (mV3SigningExplicitlyEnabled) { + mV3SigningEnabled = true; + } + } + + /** + * Sets the SDK targeted signer configs based on the signing config and rotation options + * provided by the caller. + * + * @throws InvalidKeyException if a {@link SigningCertificateLineage} cannot be created + * from the provided options + */ + private void setTargetedSignerConfigs() throws InvalidKeyException { + // If the caller specified any SDK targeted signer configs, then the min SDK version + // should be set for those configs, all others should have a default 0 min SDK version. + mSignerConfigs.sort(((signerConfig1, signerConfig2) -> signerConfig1.getMinSdkVersion() + - signerConfig2.getMinSdkVersion())); + // With the signer configs sorted, find the first targeted signer config with a min + // SDK version > 0 to create the separate targeted signer configs. + mTargetedSignerConfigs = new ArrayList<>(); + for (int i = 0; i < mSignerConfigs.size(); i++) { + if (mSignerConfigs.get(i).getMinSdkVersion() > 0) { + mTargetedSignerConfigs = mSignerConfigs.subList(i, mSignerConfigs.size()); + mSignerConfigs = mSignerConfigs.subList(0, i); + break; + } + } + + // A lineage provided outside a targeted signing config is intended for the original + // rotation; sort the untargeted signing configs based on this lineage and create a new + // targeted signing config for the initial rotation. + if (mSigningCertificateLineage != null) { + if (!mTargetedSignerConfigs.isEmpty()) { + // Only the initial rotation can use the rotation-min-sdk-version; all + // subsequent targeted rotations must use targeted signing configs. + int firstTargetedSdkVersion = mTargetedSignerConfigs.get(0).getMinSdkVersion(); + if (mRotationMinSdkVersion >= firstTargetedSdkVersion) { + throw new IllegalStateException( + "The rotation-min-sdk-version, " + mRotationMinSdkVersion + + ", must be less than the first targeted SDK version, " + + firstTargetedSdkVersion); + } + } + try { + mSignerConfigs = mSigningCertificateLineage.sortSignerConfigs(mSignerConfigs); + } catch (IllegalArgumentException e) { + throw new IllegalStateException( + "Provided signer configs do not match the " + + "provided SigningCertificateLineage", + e); + } + // Get the last signer in the lineage, create a new targeted signer from it, + // and add it as a targeted signer config. + SignerConfig rotatedSignerConfig = mSignerConfigs.remove(mSignerConfigs.size() - 1); + SignerConfig.Builder rotatedConfigBuilder = new SignerConfig.Builder( + rotatedSignerConfig.getName(), rotatedSignerConfig.getPrivateKey(), + rotatedSignerConfig.getCertificates(), + rotatedSignerConfig.getDeterministicDsaSigning()); + rotatedConfigBuilder.setLineageForMinSdkVersion(mSigningCertificateLineage, + mRotationMinSdkVersion); + rotatedConfigBuilder.setSignerTargetsDevRelease(mRotationTargetsDevRelease); + mTargetedSignerConfigs.add(0, rotatedConfigBuilder.build()); + } + mSigningCertificateLineage = mergeTargetedSigningConfigLineages(); + } + + /** + * Merges and returns the lineages from any caller provided SDK targeted {@link + * SignerConfig} instances with an optional {@code lineage} specified as part of the general + * signing config. + * + * <p>If multiple signing configs target the same SDK version, or if any of the lineages + * cannot be merged, then an {@code IllegalStateException} is thrown. + */ + private SigningCertificateLineage mergeTargetedSigningConfigLineages() + throws InvalidKeyException { + SigningCertificateLineage mergedLineage = null; + int prevSdkVersion = 0; + for (SignerConfig signerConfig : mTargetedSignerConfigs) { + int signerMinSdkVersion = signerConfig.getMinSdkVersion(); + if (signerMinSdkVersion < AndroidSdkVersion.P) { + throw new IllegalStateException( + "Targeted signing config is not supported prior to SDK version " + + AndroidSdkVersion.P + "; received value " + + signerMinSdkVersion); + } + SigningCertificateLineage signerLineage = + signerConfig.getSigningCertificateLineage(); + // It is possible for a lineage to be null if the user is using one of the + // signers from the lineage as the only signer to target an SDK version; create + // a single element lineage to verify the signer is part of the merged lineage. + if (signerLineage == null) { + try { + signerLineage = new SigningCertificateLineage.Builder( + new SigningCertificateLineage.SignerConfig.Builder( + signerConfig.mPrivateKey, + signerConfig.mCertificates.get(0)) + .build()) + .build(); + } catch (CertificateEncodingException | NoSuchAlgorithmException + | SignatureException e) { + throw new IllegalStateException( + "Unable to create a SignerConfig for signer from certificate " + + signerConfig.mCertificates.get(0).getSubjectDN()); + } + } + // The V3.0 signature scheme does not support verified targeted SDK signing + // configs; if a signer is targeting any SDK version < T, then it will + // target P with the V3.0 signature scheme. + if (signerMinSdkVersion < AndroidSdkVersion.T) { + signerMinSdkVersion = AndroidSdkVersion.P; + } + // Ensure there are no SignerConfigs targeting the same SDK version. + if (signerMinSdkVersion == prevSdkVersion) { + throw new IllegalStateException( + "Multiple SignerConfigs were found targeting SDK version " + + signerMinSdkVersion); + } + // If multiple lineages have been provided, then verify each subsequent lineage + // is a valid descendant or ancestor of the previously merged lineages. + if (mergedLineage == null) { + mergedLineage = signerLineage; + } else { + try { + mergedLineage = mergedLineage.mergeLineageWith(signerLineage); + } catch (IllegalArgumentException e) { + throw new IllegalStateException( + "The provided lineage targeting SDK " + signerMinSdkVersion + + " is not in the signing history of the other targeted " + + "signing configs", e); + } + } + prevSdkVersion = signerMinSdkVersion; + } + return mergedLineage; + } + + /** + * Returns a new {@code DefaultApkSignerEngine} instance configured based on the + * configuration of this builder. + */ + public DefaultApkSignerEngine build() throws InvalidKeyException { + setEnabledSignatureSchemes(); + setTargetedSignerConfigs(); + + // make sure our signers are appropriately setup + if (mSigningCertificateLineage != null) { + if (!mV3SigningEnabled && mSignerConfigs.size() > 1) { + // this is a strange situation: we've provided a valid rotation history, but + // are only signing with v1/v2. blow up, since we don't know for sure with + // which signer the user intended to sign + throw new IllegalStateException( + "Provided multiple signers which are part of the" + + " SigningCertificateLineage, but not signing with APK" + + " Signature Scheme v3"); + } + } else if (mV3SigningEnabled && mSignerConfigs.size() > 1) { + throw new IllegalStateException( + "Multiple signing certificates provided for use with APK Signature Scheme" + + " v3 without an accompanying SigningCertificateLineage"); + } + + return new DefaultApkSignerEngine( + mSignerConfigs, + mTargetedSignerConfigs, + mStampSignerConfig, + mSourceStampSigningCertificateLineage, + mSourceStampTimestampEnabled, + mMinSdkVersion, + mV1SigningEnabled, + mV2SigningEnabled, + mV3SigningEnabled, + mVerityEnabled, + mDebuggableApkPermitted, + mOtherSignersSignaturesPreserved, + mCreatedBy, + mSigningCertificateLineage); + } + + /** Sets the signer configuration for the SourceStamp to be embedded in the APK. */ + public Builder setStampSignerConfig(SignerConfig stampSignerConfig) { + mStampSignerConfig = stampSignerConfig; + return this; + } + + /** + * Sets the source stamp {@link SigningCertificateLineage}. This structure provides proof of + * signing certificate rotation for certificates previously used to sign source stamps. + */ + public Builder setSourceStampSigningCertificateLineage( + SigningCertificateLineage sourceStampSigningCertificateLineage) { + mSourceStampSigningCertificateLineage = sourceStampSigningCertificateLineage; + return this; + } + + /** + * Sets whether the source stamp should contain the timestamp attribute with the time + * at which the source stamp was signed. + */ + public Builder setSourceStampTimestampEnabled(boolean value) { + mSourceStampTimestampEnabled = value; + return this; + } + + /** + * Sets whether the APK should be signed using JAR signing (aka v1 signature scheme). + * + * <p>By default, the APK will be signed using this scheme. + */ + public Builder setV1SigningEnabled(boolean enabled) { + mV1SigningEnabled = enabled; + return this; + } + + /** + * Sets whether the APK should be signed using APK Signature Scheme v2 (aka v2 signature + * scheme). + * + * <p>By default, the APK will be signed using this scheme. + */ + public Builder setV2SigningEnabled(boolean enabled) { + mV2SigningEnabled = enabled; + return this; + } + + /** + * Sets whether the APK should be signed using APK Signature Scheme v3 (aka v3 signature + * scheme). + * + * <p>By default, the APK will be signed using this scheme. + */ + public Builder setV3SigningEnabled(boolean enabled) { + mV3SigningEnabled = enabled; + if (enabled) { + mV3SigningExplicitlyEnabled = true; + } else { + mV3SigningExplicitlyDisabled = true; + } + return this; + } + + /** + * Sets whether the APK should be signed using the verity signature algorithm in the v2 and + * v3 signature blocks. + * + * <p>By default, the APK will be signed using the verity signature algorithm for the v2 and + * v3 signature schemes. + */ + public Builder setVerityEnabled(boolean enabled) { + mVerityEnabled = enabled; + return this; + } + + /** + * Sets whether the APK should be signed even if it is marked as debuggable ({@code + * android:debuggable="true"} in its {@code AndroidManifest.xml}). For backward + * compatibility reasons, the default value of this setting is {@code true}. + * + * <p>It is dangerous to sign debuggable APKs with production/release keys because Android + * platform loosens security checks for such APKs. For example, arbitrary unauthorized code + * may be executed in the context of such an app by anybody with ADB shell access. + */ + public Builder setDebuggableApkPermitted(boolean permitted) { + mDebuggableApkPermitted = permitted; + return this; + } + + /** + * Sets whether signatures produced by signers other than the ones configured in this engine + * should be copied from the input APK to the output APK. + * + * <p>By default, signatures of other signers are omitted from the output APK. + */ + public Builder setOtherSignersSignaturesPreserved(boolean preserved) { + mOtherSignersSignaturesPreserved = preserved; + return this; + } + + /** Sets the value of the {@code Created-By} field in JAR signature files. */ + public Builder setCreatedBy(String createdBy) { + if (createdBy == null) { + throw new NullPointerException(); + } + mCreatedBy = createdBy; + return this; + } + + /** + * Sets the {@link SigningCertificateLineage} to use with the v3 signature scheme. This + * structure provides proof of signing certificate rotation linking {@link SignerConfig} + * objects to previous ones. + */ + public Builder setSigningCertificateLineage( + SigningCertificateLineage signingCertificateLineage) { + if (signingCertificateLineage != null) { + mV3SigningEnabled = true; + mSigningCertificateLineage = signingCertificateLineage; + } + return this; + } + + /** + * Sets the minimum Android platform version (API Level) for which an APK's rotated signing + * key should be used to produce the APK's signature. The original signing key for the APK + * will be used for all previous platform versions. If a rotated key with signing lineage is + * not provided then this method is a noop. + * + * <p>By default, if a signing lineage is specified with {@link + * #setSigningCertificateLineage(SigningCertificateLineage)}, then the APK Signature Scheme + * V3.1 will be used to only apply the rotation on devices running Android T+. + * + * <p><em>Note:</em>Specifying a {@code minSdkVersion} value <= 32 (Android Sv2) will result + * in the original V3 signing block being used without platform targeting. + */ + public Builder setMinSdkVersionForRotation(int minSdkVersion) { + // If the provided SDK version does not support v3.1, then use the default SDK version + // with rotation support. + if (minSdkVersion < MIN_SDK_WITH_V31_SUPPORT) { + mRotationMinSdkVersion = MIN_SDK_WITH_V3_SUPPORT; + } else { + mRotationMinSdkVersion = minSdkVersion; + } + return this; + } + + /** + * Sets whether the rotation-min-sdk-version is intended to target a development release; + * this is primarily required after the T SDK is finalized, and an APK needs to target U + * during its development cycle for rotation. + * + * <p>This is only required after the T SDK is finalized since S and earlier releases do + * not know about the V3.1 block ID, but once T is released and work begins on U, U will + * use the SDK version of T during development. Specifying a rotation-min-sdk-version of T's + * SDK version along with setting {@code enabled} to true will allow an APK to use the + * rotated key on a device running U while causing this to be bypassed for T. + * + * <p><em>Note:</em>If the rotation-min-sdk-version is less than or equal to 32 (Android + * Sv2), then the rotated signing key will be used in the v3.0 signing block and this call + * will be a noop. + */ + public Builder setRotationTargetsDevRelease(boolean enabled) { + mRotationTargetsDevRelease = enabled; + return this; + } + } +} diff --git a/platform/android/java/editor/src/main/java/com/android/apksig/Hints.java b/platform/android/java/editor/src/main/java/com/android/apksig/Hints.java new file mode 100644 index 0000000000..4070fa231a --- /dev/null +++ b/platform/android/java/editor/src/main/java/com/android/apksig/Hints.java @@ -0,0 +1,123 @@ +/* + * Copyright (C) 2018 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.apksig; +import java.io.IOException; +import java.io.DataOutputStream; +import java.io.ByteArrayOutputStream; +import java.io.UnsupportedEncodingException; +import java.util.ArrayList; +import java.util.List; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +public final class Hints { + /** + * Name of hint pattern asset file in APK. + */ + public static final String PIN_HINT_ASSET_ZIP_ENTRY_NAME = "assets/com.android.hints.pins.txt"; + + /** + * Name of hint byte range data file in APK. Keep in sync with PinnerService.java. + */ + public static final String PIN_BYTE_RANGE_ZIP_ENTRY_NAME = "pinlist.meta"; + + private static int clampToInt(long value) { + return (int) Math.max(0, Math.min(value, Integer.MAX_VALUE)); + } + + public static final class ByteRange { + final long start; + final long end; + + public ByteRange(long start, long end) { + this.start = start; + this.end = end; + } + } + + public static final class PatternWithRange { + final Pattern pattern; + final long offset; + final long size; + + public PatternWithRange(String pattern) { + this.pattern = Pattern.compile(pattern); + this.offset= 0; + this.size = Long.MAX_VALUE; + } + + public PatternWithRange(String pattern, long offset, long size) { + this.pattern = Pattern.compile(pattern); + this.offset = offset; + this.size = size; + } + + public Matcher matcher(CharSequence input) { + return this.pattern.matcher(input); + } + + public ByteRange ClampToAbsoluteByteRange(ByteRange rangeIn) { + if (rangeIn.end - rangeIn.start < this.offset) { + return null; + } + long rangeOutStart = rangeIn.start + this.offset; + long rangeOutSize = Math.min(rangeIn.end - rangeOutStart, + this.size); + return new ByteRange(rangeOutStart, + rangeOutStart + rangeOutSize); + } + } + + /** + * Create a blob of bytes that PinnerService understands as a + * sequence of byte ranges to pin. + */ + public static byte[] encodeByteRangeList(List<ByteRange> pinByteRanges) { + ByteArrayOutputStream bos = new ByteArrayOutputStream(pinByteRanges.size() * 8); + DataOutputStream out = new DataOutputStream(bos); + try { + for (ByteRange pinByteRange : pinByteRanges) { + out.writeInt(clampToInt(pinByteRange.start)); + out.writeInt(clampToInt(pinByteRange.end - pinByteRange.start)); + } + } catch (IOException ex) { + throw new AssertionError("impossible", ex); + } + return bos.toByteArray(); + } + + public static ArrayList<PatternWithRange> parsePinPatterns(byte[] patternBlob) { + ArrayList<PatternWithRange> pinPatterns = new ArrayList<>(); + try { + for (String rawLine : new String(patternBlob, "UTF-8").split("\n")) { + String line = rawLine.replaceFirst("#.*", ""); // # starts a comment + String[] fields = line.split(" "); + if (fields.length == 1) { + pinPatterns.add(new PatternWithRange(fields[0])); + } else if (fields.length == 3) { + long start = Long.parseLong(fields[1]); + long end = Long.parseLong(fields[2]); + pinPatterns.add(new PatternWithRange(fields[0], start, end - start)); + } else { + throw new AssertionError("bad pin pattern line " + line); + } + } + } catch (UnsupportedEncodingException ex) { + throw new RuntimeException("UTF-8 must be supported", ex); + } + return pinPatterns; + } +} diff --git a/platform/android/java/editor/src/main/java/com/android/apksig/README.md b/platform/android/java/editor/src/main/java/com/android/apksig/README.md new file mode 100644 index 0000000000..a89b848909 --- /dev/null +++ b/platform/android/java/editor/src/main/java/com/android/apksig/README.md @@ -0,0 +1,32 @@ +# apksig ([commit ac5cbb07d87cc342fcf07715857a812305d69888](https://android.googlesource.com/platform/tools/apksig/+/ac5cbb07d87cc342fcf07715857a812305d69888)) + +apksig is a project which aims to simplify APK signing and checking whether APK signatures are +expected to verify on Android. apksig supports +[JAR signing](https://docs.oracle.com/javase/8/docs/technotes/guides/jar/jar.html#Signed_JAR_File) +(used by Android since day one) and +[APK Signature Scheme v2](https://source.android.com/security/apksigning/v2.html) (supported since +Android Nougat, API Level 24). apksig is meant to be used outside of Android devices. + +The key feature of apksig is that it knows about differences in APK signature verification logic +between different versions of the Android platform. apksig thus thoroughly checks whether an APK's +signature is expected to verify on all Android platform versions supported by the APK. When signing +an APK, apksig chooses the most appropriate cryptographic algorithms based on the Android platform +versions supported by the APK being signed. + +## apksig library + +apksig library offers three primitives: + +* `ApkSigner` which signs the provided APK so that it verifies on all Android platform versions + supported by the APK. The range of platform versions can be customized. +* `ApkVerifier` which checks whether the provided APK is expected to verify on all Android + platform versions supported by the APK. The range of platform versions can be customized. +* `(Default)ApkSignerEngine` which abstracts away signing APKs from parsing and building APKs. + This is useful in optimized APK building pipelines, such as in Android Plugin for Gradle, + which need to perform signing while building an APK, instead of after. For simpler use cases + where the APK to be signed is available upfront, the `ApkSigner` above is easier to use. + +_NOTE: Some public classes of the library are in packages having the word "internal" in their name. +These are not public API of the library. Do not use \*.internal.\* classes directly because these +classes may change any time without regard to existing clients outside of `apksig` and `apksigner`._ + diff --git a/platform/android/java/editor/src/main/java/com/android/apksig/SigningCertificateLineage.java b/platform/android/java/editor/src/main/java/com/android/apksig/SigningCertificateLineage.java new file mode 100644 index 0000000000..0f1cc33c98 --- /dev/null +++ b/platform/android/java/editor/src/main/java/com/android/apksig/SigningCertificateLineage.java @@ -0,0 +1,1325 @@ +/* + * Copyright (C) 2018 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.apksig; + +import static com.android.apksig.internal.apk.ApkSigningBlockUtils.getLengthPrefixedSlice; + +import com.android.apksig.apk.ApkFormatException; +import com.android.apksig.apk.ApkUtils; +import com.android.apksig.internal.apk.ApkSigningBlockUtils; +import com.android.apksig.internal.apk.SignatureAlgorithm; +import com.android.apksig.internal.apk.SignatureInfo; +import com.android.apksig.internal.apk.v3.V3SchemeConstants; +import com.android.apksig.internal.apk.v3.V3SchemeSigner; +import com.android.apksig.internal.apk.v3.V3SigningCertificateLineage; +import com.android.apksig.internal.apk.v3.V3SigningCertificateLineage.SigningCertificateNode; +import com.android.apksig.internal.util.AndroidSdkVersion; +import com.android.apksig.internal.util.ByteBufferUtils; +import com.android.apksig.internal.util.Pair; +import com.android.apksig.internal.util.RandomAccessFileDataSink; +import com.android.apksig.util.DataSink; +import com.android.apksig.util.DataSource; +import com.android.apksig.util.DataSources; +import com.android.apksig.zip.ZipFormatException; + +import java.io.File; +import java.io.IOException; +import java.io.RandomAccessFile; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.security.InvalidKeyException; +import java.security.NoSuchAlgorithmException; +import java.security.PrivateKey; +import java.security.PublicKey; +import java.security.SignatureException; +import java.security.cert.CertificateEncodingException; +import java.security.cert.X509Certificate; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +/** + * APK Signer Lineage. + * + * <p>The signer lineage contains a history of signing certificates with each ancestor attesting to + * the validity of its descendant. Each additional descendant represents a new identity that can be + * used to sign an APK, and each generation has accompanying attributes which represent how the + * APK would like to view the older signing certificates, specifically how they should be trusted in + * certain situations. + * + * <p> Its primary use is to enable APK Signing Certificate Rotation. The Android platform verifies + * the APK Signer Lineage, and if the current signing certificate for the APK is in the Signer + * Lineage, and the Lineage contains the certificate the platform associates with the APK, it will + * allow upgrades to the new certificate. + * + * @see <a href="https://source.android.com/security/apksigning/index.html">Application Signing</a> + */ +public class SigningCertificateLineage { + + public final static int MAGIC = 0x3eff39d1; + + private final static int FIRST_VERSION = 1; + + private static final int CURRENT_VERSION = FIRST_VERSION; + + /** accept data from already installed pkg with this cert */ + private static final int PAST_CERT_INSTALLED_DATA = 1; + + /** accept sharedUserId with pkg with this cert */ + private static final int PAST_CERT_SHARED_USER_ID = 2; + + /** grant SIGNATURE permissions to pkgs with this cert */ + private static final int PAST_CERT_PERMISSION = 4; + + /** + * Enable updates back to this certificate. WARNING: this effectively removes any benefit of + * signing certificate changes, since a compromised key could retake control of an app even + * after change, and should only be used if there is a problem encountered when trying to ditch + * an older cert. + */ + private static final int PAST_CERT_ROLLBACK = 8; + + /** + * Preserve authenticator module-based access in AccountManager gated by signing certificate. + */ + private static final int PAST_CERT_AUTH = 16; + + private final int mMinSdkVersion; + + /** + * The signing lineage is just a list of nodes, with the first being the original signing + * certificate and the most recent being the one with which the APK is to actually be signed. + */ + private final List<SigningCertificateNode> mSigningLineage; + + private SigningCertificateLineage(int minSdkVersion, List<SigningCertificateNode> list) { + mMinSdkVersion = minSdkVersion; + mSigningLineage = list; + } + + /** + * Creates a {@code SigningCertificateLineage} with a single signer in the lineage. + */ + private static SigningCertificateLineage createSigningLineage(int minSdkVersion, + SignerConfig signer, SignerCapabilities capabilities) { + SigningCertificateLineage signingCertificateLineage = new SigningCertificateLineage( + minSdkVersion, new ArrayList<>()); + return signingCertificateLineage.spawnFirstDescendant(signer, capabilities); + } + + private static SigningCertificateLineage createSigningLineage( + int minSdkVersion, SignerConfig parent, SignerCapabilities parentCapabilities, + SignerConfig child, SignerCapabilities childCapabilities) + throws CertificateEncodingException, InvalidKeyException, NoSuchAlgorithmException, + SignatureException { + SigningCertificateLineage signingCertificateLineage = + new SigningCertificateLineage(minSdkVersion, new ArrayList<>()); + signingCertificateLineage = + signingCertificateLineage.spawnFirstDescendant(parent, parentCapabilities); + return signingCertificateLineage.spawnDescendant(parent, child, childCapabilities); + } + + public static SigningCertificateLineage readFromBytes(byte[] lineageBytes) + throws IOException { + return readFromDataSource(DataSources.asDataSource(ByteBuffer.wrap(lineageBytes))); + } + + public static SigningCertificateLineage readFromFile(File file) + throws IOException { + if (file == null) { + throw new NullPointerException("file == null"); + } + RandomAccessFile inputFile = new RandomAccessFile(file, "r"); + return readFromDataSource(DataSources.asDataSource(inputFile)); + } + + public static SigningCertificateLineage readFromDataSource(DataSource dataSource) + throws IOException { + if (dataSource == null) { + throw new NullPointerException("dataSource == null"); + } + ByteBuffer inBuff = dataSource.getByteBuffer(0, (int) dataSource.size()); + inBuff.order(ByteOrder.LITTLE_ENDIAN); + return read(inBuff); + } + + /** + * Extracts a Signing Certificate Lineage from a v3 signer proof-of-rotation attribute. + * + * <note> + * this may not give a complete representation of an APK's signing certificate history, + * since the APK may have multiple signers corresponding to different platform versions. + * Use <code> readFromApkFile</code> to handle this case. + * </note> + * @param attrValue + */ + public static SigningCertificateLineage readFromV3AttributeValue(byte[] attrValue) + throws IOException { + List<SigningCertificateNode> parsedLineage = + V3SigningCertificateLineage.readSigningCertificateLineage(ByteBuffer.wrap( + attrValue).order(ByteOrder.LITTLE_ENDIAN)); + int minSdkVersion = calculateMinSdkVersion(parsedLineage); + return new SigningCertificateLineage(minSdkVersion, parsedLineage); + } + + /** + * Extracts a Signing Certificate Lineage from the proof-of-rotation attribute in the V3 + * signature block of the provided APK File. + * + * @throws IllegalArgumentException if the provided APK does not contain a V3 signature block, + * or if the V3 signature block does not contain a valid lineage. + */ + public static SigningCertificateLineage readFromApkFile(File apkFile) + throws IOException, ApkFormatException { + try (RandomAccessFile f = new RandomAccessFile(apkFile, "r")) { + DataSource apk = DataSources.asDataSource(f, 0, f.length()); + return readFromApkDataSource(apk); + } + } + + /** + * Extracts a Signing Certificate Lineage from the proof-of-rotation attribute in the V3 and + * V3.1 signature blocks of the provided APK DataSource. + * + * @throws IllegalArgumentException if the provided APK does not contain a V3 nor V3.1 + * signature block, or if the V3 and V3.1 signature blocks do not contain a valid lineage. + */ + + public static SigningCertificateLineage readFromApkDataSource(DataSource apk) + throws IOException, ApkFormatException { + return readFromApkDataSource(apk, /* readV31Lineage= */ true, /* readV3Lineage= */true); + } + + /** + * Extracts a Signing Certificate Lineage from the proof-of-rotation attribute in the V3.1 + * signature blocks of the provided APK DataSource. + * + * @throws IllegalArgumentException if the provided APK does not contain a V3.1 signature block, + * or if the V3.1 signature block does not contain a valid lineage. + */ + + public static SigningCertificateLineage readV31FromApkDataSource(DataSource apk) + throws IOException, ApkFormatException { + return readFromApkDataSource(apk, /* readV31Lineage= */ true, + /* readV3Lineage= */ false); + } + + private static SigningCertificateLineage readFromApkDataSource( + DataSource apk, + boolean readV31Lineage, + boolean readV3Lineage) + throws IOException, ApkFormatException { + ApkUtils.ZipSections zipSections; + try { + zipSections = ApkUtils.findZipSections(apk); + } catch (ZipFormatException e) { + throw new ApkFormatException(e.getMessage()); + } + + List<SignatureInfo> signatureInfoList = new ArrayList<>(); + if (readV31Lineage) { + try { + ApkSigningBlockUtils.Result result = new ApkSigningBlockUtils.Result( + ApkSigningBlockUtils.VERSION_APK_SIGNATURE_SCHEME_V31); + signatureInfoList.add( + ApkSigningBlockUtils.findSignature(apk, zipSections, + V3SchemeConstants.APK_SIGNATURE_SCHEME_V31_BLOCK_ID, result)); + } catch (ApkSigningBlockUtils.SignatureNotFoundException ignored) { + // This could be expected if there's only a V3 signature block. + } + } + if (readV3Lineage) { + try { + ApkSigningBlockUtils.Result result = new ApkSigningBlockUtils.Result( + ApkSigningBlockUtils.VERSION_APK_SIGNATURE_SCHEME_V3); + signatureInfoList.add( + ApkSigningBlockUtils.findSignature(apk, zipSections, + V3SchemeConstants.APK_SIGNATURE_SCHEME_V3_BLOCK_ID, result)); + } catch (ApkSigningBlockUtils.SignatureNotFoundException ignored) { + // This could be expected if the provided APK is not signed with the V3 signature + // scheme + } + } + if (signatureInfoList.isEmpty()) { + String message; + if (readV31Lineage && readV3Lineage) { + message = "The provided APK does not contain a valid V3 nor V3.1 signature block."; + } else if (readV31Lineage) { + message = "The provided APK does not contain a valid V3.1 signature block."; + } else if (readV3Lineage) { + message = "The provided APK does not contain a valid V3 signature block."; + } else { + message = "No signature blocks were requested."; + } + throw new IllegalArgumentException(message); + } + + List<SigningCertificateLineage> lineages = new ArrayList<>(1); + for (SignatureInfo signatureInfo : signatureInfoList) { + // FORMAT: + // * length-prefixed sequence of length-prefixed signers: + // * length-prefixed signed data + // * minSDK + // * maxSDK + // * length-prefixed sequence of length-prefixed signatures + // * length-prefixed public key + ByteBuffer signers = getLengthPrefixedSlice(signatureInfo.signatureBlock); + while (signers.hasRemaining()) { + ByteBuffer signer = getLengthPrefixedSlice(signers); + ByteBuffer signedData = getLengthPrefixedSlice(signer); + try { + SigningCertificateLineage lineage = readFromSignedData(signedData); + lineages.add(lineage); + } catch (IllegalArgumentException ignored) { + // The current signer block does not contain a valid lineage, but it is possible + // another block will. + } + } + } + + SigningCertificateLineage result; + if (lineages.isEmpty()) { + throw new IllegalArgumentException( + "The provided APK does not contain a valid lineage."); + } else if (lineages.size() > 1) { + result = consolidateLineages(lineages); + } else { + result = lineages.get(0); + } + return result; + } + + /** + * Extracts a Signing Certificate Lineage from the proof-of-rotation attribute in the provided + * signed data portion of a signer in a V3 signature block. + * + * @throws IllegalArgumentException if the provided signed data does not contain a valid + * lineage. + */ + public static SigningCertificateLineage readFromSignedData(ByteBuffer signedData) + throws IOException, ApkFormatException { + // FORMAT: + // * length-prefixed sequence of length-prefixed digests: + // * length-prefixed sequence of certificates: + // * length-prefixed bytes: X.509 certificate (ASN.1 DER encoded). + // * uint-32: minSdkVersion + // * uint-32: maxSdkVersion + // * length-prefixed sequence of length-prefixed additional attributes: + // * uint32: ID + // * (length - 4) bytes: value + // * uint32: Proof-of-rotation ID: 0x3ba06f8c + // * length-prefixed proof-of-rotation structure + // consume the digests through the maxSdkVersion to reach the lineage in the attributes + getLengthPrefixedSlice(signedData); + getLengthPrefixedSlice(signedData); + signedData.getInt(); + signedData.getInt(); + // iterate over the additional attributes adding any lineages to the List + ByteBuffer additionalAttributes = getLengthPrefixedSlice(signedData); + List<SigningCertificateLineage> lineages = new ArrayList<>(1); + while (additionalAttributes.hasRemaining()) { + ByteBuffer attribute = getLengthPrefixedSlice(additionalAttributes); + int id = attribute.getInt(); + if (id == V3SchemeConstants.PROOF_OF_ROTATION_ATTR_ID) { + byte[] value = ByteBufferUtils.toByteArray(attribute); + SigningCertificateLineage lineage = readFromV3AttributeValue(value); + lineages.add(lineage); + } + } + SigningCertificateLineage result; + // There should only be a single attribute with the lineage, but if there are multiple then + // attempt to consolidate the lineages. + if (lineages.isEmpty()) { + throw new IllegalArgumentException("The signed data does not contain a valid lineage."); + } else if (lineages.size() > 1) { + result = consolidateLineages(lineages); + } else { + result = lineages.get(0); + } + return result; + } + + public byte[] getBytes() { + return write().array(); + } + + public void writeToFile(File file) throws IOException { + if (file == null) { + throw new NullPointerException("file == null"); + } + RandomAccessFile outputFile = new RandomAccessFile(file, "rw"); + writeToDataSink(new RandomAccessFileDataSink(outputFile)); + } + + public void writeToDataSink(DataSink dataSink) throws IOException { + if (dataSink == null) { + throw new NullPointerException("dataSink == null"); + } + dataSink.consume(write()); + } + + /** + * Add a new signing certificate to the lineage. This effectively creates a signing certificate + * rotation event, forcing APKs which include this lineage to be signed by the new signer. The + * flags associated with the new signer are set to a default value. + * + * @param parent current signing certificate of the containing APK + * @param child new signing certificate which will sign the APK contents + */ + public SigningCertificateLineage spawnDescendant(SignerConfig parent, SignerConfig child) + throws CertificateEncodingException, InvalidKeyException, NoSuchAlgorithmException, + SignatureException { + if (parent == null || child == null) { + throw new NullPointerException("can't add new descendant to lineage with null inputs"); + } + SignerCapabilities signerCapabilities = new SignerCapabilities.Builder().build(); + return spawnDescendant(parent, child, signerCapabilities); + } + + /** + * Add a new signing certificate to the lineage. This effectively creates a signing certificate + * rotation event, forcing APKs which include this lineage to be signed by the new signer. + * + * @param parent current signing certificate of the containing APK + * @param child new signing certificate which will sign the APK contents + * @param childCapabilities flags + */ + public SigningCertificateLineage spawnDescendant( + SignerConfig parent, SignerConfig child, SignerCapabilities childCapabilities) + throws CertificateEncodingException, InvalidKeyException, + NoSuchAlgorithmException, SignatureException { + if (parent == null) { + throw new NullPointerException("parent == null"); + } + if (child == null) { + throw new NullPointerException("child == null"); + } + if (childCapabilities == null) { + throw new NullPointerException("childCapabilities == null"); + } + if (mSigningLineage.isEmpty()) { + throw new IllegalArgumentException("Cannot spawn descendant signing certificate on an" + + " empty SigningCertificateLineage: no parent node"); + } + + // make sure that the parent matches our newest generation (leaf node/sink) + SigningCertificateNode currentGeneration = mSigningLineage.get(mSigningLineage.size() - 1); + if (!Arrays.equals(currentGeneration.signingCert.getEncoded(), + parent.getCertificate().getEncoded())) { + throw new IllegalArgumentException("SignerConfig Certificate containing private key" + + " to sign the new SigningCertificateLineage record does not match the" + + " existing most recent record"); + } + + // create data to be signed, including the algorithm we're going to use + SignatureAlgorithm signatureAlgorithm = getSignatureAlgorithm(parent); + ByteBuffer prefixedSignedData = ByteBuffer.wrap( + V3SigningCertificateLineage.encodeSignedData( + child.getCertificate(), signatureAlgorithm.getId())); + prefixedSignedData.position(4); + ByteBuffer signedDataBuffer = ByteBuffer.allocate(prefixedSignedData.remaining()); + signedDataBuffer.put(prefixedSignedData); + byte[] signedData = signedDataBuffer.array(); + + // create SignerConfig to do the signing + List<X509Certificate> certificates = new ArrayList<>(1); + certificates.add(parent.getCertificate()); + ApkSigningBlockUtils.SignerConfig newSignerConfig = + new ApkSigningBlockUtils.SignerConfig(); + newSignerConfig.privateKey = parent.getPrivateKey(); + newSignerConfig.certificates = certificates; + newSignerConfig.signatureAlgorithms = Collections.singletonList(signatureAlgorithm); + + // sign it + List<Pair<Integer, byte[]>> signatures = + ApkSigningBlockUtils.generateSignaturesOverData(newSignerConfig, signedData); + + // finally, add it to our lineage + SignatureAlgorithm sigAlgorithm = SignatureAlgorithm.findById(signatures.get(0).getFirst()); + byte[] signature = signatures.get(0).getSecond(); + currentGeneration.sigAlgorithm = sigAlgorithm; + SigningCertificateNode childNode = + new SigningCertificateNode( + child.getCertificate(), sigAlgorithm, null, + signature, childCapabilities.getFlags()); + List<SigningCertificateNode> lineageCopy = new ArrayList<>(mSigningLineage); + lineageCopy.add(childNode); + return new SigningCertificateLineage(mMinSdkVersion, lineageCopy); + } + + /** + * The number of signing certificates in the lineage, including the current signer, which means + * this value can also be used to V2determine the number of signing certificate rotations by + * subtracting 1. + */ + public int size() { + return mSigningLineage.size(); + } + + private SignatureAlgorithm getSignatureAlgorithm(SignerConfig parent) + throws InvalidKeyException { + PublicKey publicKey = parent.getCertificate().getPublicKey(); + + // TODO switch to one signature algorithm selection, or add support for multiple algorithms + List<SignatureAlgorithm> algorithms = V3SchemeSigner.getSuggestedSignatureAlgorithms( + publicKey, mMinSdkVersion, false /* verityEnabled */, + false /* deterministicDsaSigning */); + return algorithms.get(0); + } + + private SigningCertificateLineage spawnFirstDescendant( + SignerConfig parent, SignerCapabilities signerCapabilities) { + if (!mSigningLineage.isEmpty()) { + throw new IllegalStateException("SigningCertificateLineage already has its first node"); + } + + // check to make sure that the public key for the first node is acceptable for our minSdk + try { + getSignatureAlgorithm(parent); + } catch (InvalidKeyException e) { + throw new IllegalArgumentException("Algorithm associated with first signing certificate" + + " invalid on desired platform versions", e); + } + + // create "fake" signed data (there will be no signature over it, since there is no parent + SigningCertificateNode firstNode = new SigningCertificateNode( + parent.getCertificate(), null, null, new byte[0], signerCapabilities.getFlags()); + return new SigningCertificateLineage(mMinSdkVersion, Collections.singletonList(firstNode)); + } + + private static SigningCertificateLineage read(ByteBuffer inputByteBuffer) + throws IOException { + ApkSigningBlockUtils.checkByteOrderLittleEndian(inputByteBuffer); + if (inputByteBuffer.remaining() < 8) { + throw new IllegalArgumentException( + "Improper SigningCertificateLineage format: insufficient data for header."); + } + + if (inputByteBuffer.getInt() != MAGIC) { + throw new IllegalArgumentException( + "Improper SigningCertificateLineage format: MAGIC header mismatch."); + } + return read(inputByteBuffer, inputByteBuffer.getInt()); + } + + private static SigningCertificateLineage read(ByteBuffer inputByteBuffer, int version) + throws IOException { + switch (version) { + case FIRST_VERSION: + try { + List<SigningCertificateNode> nodes = + V3SigningCertificateLineage.readSigningCertificateLineage( + getLengthPrefixedSlice(inputByteBuffer)); + int minSdkVersion = calculateMinSdkVersion(nodes); + return new SigningCertificateLineage(minSdkVersion, nodes); + } catch (ApkFormatException e) { + // unable to get a proper length-prefixed lineage slice + throw new IOException("Unable to read list of signing certificate nodes in " + + "SigningCertificateLineage", e); + } + default: + throw new IllegalArgumentException( + "Improper SigningCertificateLineage format: unrecognized version."); + } + } + + private static int calculateMinSdkVersion(List<SigningCertificateNode> nodes) { + if (nodes == null) { + throw new IllegalArgumentException("Can't calculate minimum SDK version of null nodes"); + } + int minSdkVersion = AndroidSdkVersion.P; // lineage introduced in P + for (SigningCertificateNode node : nodes) { + if (node.sigAlgorithm != null) { + int nodeMinSdkVersion = node.sigAlgorithm.getMinSdkVersion(); + if (nodeMinSdkVersion > minSdkVersion) { + minSdkVersion = nodeMinSdkVersion; + } + } + } + return minSdkVersion; + } + + private ByteBuffer write() { + byte[] encodedLineage = + V3SigningCertificateLineage.encodeSigningCertificateLineage(mSigningLineage); + int payloadSize = 4 + 4 + 4 + encodedLineage.length; + ByteBuffer result = ByteBuffer.allocate(payloadSize); + result.order(ByteOrder.LITTLE_ENDIAN); + result.putInt(MAGIC); + result.putInt(CURRENT_VERSION); + result.putInt(encodedLineage.length); + result.put(encodedLineage); + result.flip(); + return result; + } + + public byte[] encodeSigningCertificateLineage() { + return V3SigningCertificateLineage.encodeSigningCertificateLineage(mSigningLineage); + } + + public List<DefaultApkSignerEngine.SignerConfig> sortSignerConfigs( + List<DefaultApkSignerEngine.SignerConfig> signerConfigs) { + if (signerConfigs == null) { + throw new NullPointerException("signerConfigs == null"); + } + + // not the most elegant sort, but we expect signerConfigs to be quite small (1 or 2 signers + // in most cases) and likely already sorted, so not worth the overhead of doing anything + // fancier + List<DefaultApkSignerEngine.SignerConfig> sortedSignerConfigs = + new ArrayList<>(signerConfigs.size()); + for (int i = 0; i < mSigningLineage.size(); i++) { + for (int j = 0; j < signerConfigs.size(); j++) { + DefaultApkSignerEngine.SignerConfig config = signerConfigs.get(j); + if (mSigningLineage.get(i).signingCert.equals(config.getCertificates().get(0))) { + sortedSignerConfigs.add(config); + break; + } + } + } + if (sortedSignerConfigs.size() != signerConfigs.size()) { + throw new IllegalArgumentException("SignerConfigs supplied which are not present in the" + + " SigningCertificateLineage"); + } + return sortedSignerConfigs; + } + + /** + * Returns the SignerCapabilities for the signer in the lineage that matches the provided + * config. + */ + public SignerCapabilities getSignerCapabilities(SignerConfig config) { + if (config == null) { + throw new NullPointerException("config == null"); + } + + X509Certificate cert = config.getCertificate(); + return getSignerCapabilities(cert); + } + + /** + * Returns the SignerCapabilities for the signer in the lineage that matches the provided + * certificate. + */ + public SignerCapabilities getSignerCapabilities(X509Certificate cert) { + if (cert == null) { + throw new NullPointerException("cert == null"); + } + + for (int i = 0; i < mSigningLineage.size(); i++) { + SigningCertificateNode lineageNode = mSigningLineage.get(i); + if (lineageNode.signingCert.equals(cert)) { + int flags = lineageNode.flags; + return new SignerCapabilities.Builder(flags).build(); + } + } + + // the provided signer certificate was not found in the lineage + throw new IllegalArgumentException("Certificate (" + cert.getSubjectDN() + + ") not found in the SigningCertificateLineage"); + } + + /** + * Updates the SignerCapabilities for the signer in the lineage that matches the provided + * config. Only those capabilities that have been modified through the setXX methods will be + * updated for the signer to prevent unset default values from being applied. + */ + public void updateSignerCapabilities(SignerConfig config, SignerCapabilities capabilities) { + if (config == null) { + throw new NullPointerException("config == null"); + } + updateSignerCapabilities(config.getCertificate(), capabilities); + } + + /** + * Updates the {@code capabilities} for the signer with the provided {@code certificate} in the + * lineage. Only those capabilities that have been modified through the setXX methods will be + * updated for the signer to prevent unset default values from being applied. + */ + public void updateSignerCapabilities(X509Certificate certificate, + SignerCapabilities capabilities) { + if (certificate == null) { + throw new NullPointerException("config == null"); + } + + for (int i = 0; i < mSigningLineage.size(); i++) { + SigningCertificateNode lineageNode = mSigningLineage.get(i); + if (lineageNode.signingCert.equals(certificate)) { + int flags = lineageNode.flags; + SignerCapabilities newCapabilities = new SignerCapabilities.Builder( + flags).setCallerConfiguredCapabilities(capabilities).build(); + lineageNode.flags = newCapabilities.getFlags(); + return; + } + } + + // the provided signer config was not found in the lineage + throw new IllegalArgumentException("Certificate (" + certificate.getSubjectDN() + + ") not found in the SigningCertificateLineage"); + } + + /** + * Returns a list containing all of the certificates in the lineage. + */ + public List<X509Certificate> getCertificatesInLineage() { + List<X509Certificate> certs = new ArrayList<>(); + for (int i = 0; i < mSigningLineage.size(); i++) { + X509Certificate cert = mSigningLineage.get(i).signingCert; + certs.add(cert); + } + return certs; + } + + /** + * Returns {@code true} if the specified config is in the lineage. + */ + public boolean isSignerInLineage(SignerConfig config) { + if (config == null) { + throw new NullPointerException("config == null"); + } + + X509Certificate cert = config.getCertificate(); + return isCertificateInLineage(cert); + } + + /** + * Returns {@code true} if the specified certificate is in the lineage. + */ + public boolean isCertificateInLineage(X509Certificate cert) { + if (cert == null) { + throw new NullPointerException("cert == null"); + } + + for (int i = 0; i < mSigningLineage.size(); i++) { + if (mSigningLineage.get(i).signingCert.equals(cert)) { + return true; + } + } + return false; + } + + /** + * Returns whether the provided {@code cert} is the latest signing certificate in the lineage. + * + * <p>This method will only compare the provided {@code cert} against the latest signing + * certificate in the lineage; if a certificate that is not in the lineage is provided, this + * method will return false. + */ + public boolean isCertificateLatestInLineage(X509Certificate cert) { + if (cert == null) { + throw new NullPointerException("cert == null"); + } + + return mSigningLineage.get(mSigningLineage.size() - 1).signingCert.equals(cert); + } + + private static int calculateDefaultFlags() { + return PAST_CERT_INSTALLED_DATA | PAST_CERT_PERMISSION + | PAST_CERT_SHARED_USER_ID | PAST_CERT_AUTH; + } + + /** + * Returns a new SigningCertificateLineage which terminates at the node corresponding to the + * given certificate. This is useful in the event of rotating to a new signing algorithm that + * is only supported on some platform versions. It enables a v3 signature to be generated using + * this signing certificate and the shortened proof-of-rotation record from this sub lineage in + * conjunction with the appropriate SDK version values. + * + * @param x509Certificate the signing certificate for which to search + * @return A new SigningCertificateLineage if the given certificate is present. + * + * @throws IllegalArgumentException if the provided certificate is not in the lineage. + */ + public SigningCertificateLineage getSubLineage(X509Certificate x509Certificate) { + if (x509Certificate == null) { + throw new NullPointerException("x509Certificate == null"); + } + for (int i = 0; i < mSigningLineage.size(); i++) { + if (mSigningLineage.get(i).signingCert.equals(x509Certificate)) { + return new SigningCertificateLineage( + mMinSdkVersion, new ArrayList<>(mSigningLineage.subList(0, i + 1))); + } + } + + // looks like we didn't find the cert, + throw new IllegalArgumentException("Certificate not found in SigningCertificateLineage"); + } + + /** + * Consolidates all of the lineages found in an APK into one lineage. In so doing, it also + * checks that all of the lineages are contained in one common lineage. + * + * An APK may contain multiple lineages, one for each signer, which correspond to different + * supported platform versions. In this event, the lineage(s) from the earlier platform + * version(s) should be present in the most recent, either directly or via a sublineage + * that would allow the earlier lineages to merge with the most recent. + * + * <note> This does not verify that the largest lineage corresponds to the most recent supported + * platform version. That check is performed during v3 verification. </note> + */ + public static SigningCertificateLineage consolidateLineages( + List<SigningCertificateLineage> lineages) { + if (lineages == null || lineages.isEmpty()) { + return null; + } + SigningCertificateLineage consolidatedLineage = lineages.get(0); + for (int i = 1; i < lineages.size(); i++) { + consolidatedLineage = consolidatedLineage.mergeLineageWith(lineages.get(i)); + } + return consolidatedLineage; + } + + /** + * Merges this lineage with the provided {@code otherLineage}. + * + * <p>The merged lineage does not currently handle merging capabilities of common signers and + * should only be used to determine the full signing history of a collection of lineages. + */ + public SigningCertificateLineage mergeLineageWith(SigningCertificateLineage otherLineage) { + // Determine the ancestor and descendant lineages; if the original signer is in the other + // lineage, then it is considered a descendant. + SigningCertificateLineage ancestorLineage; + SigningCertificateLineage descendantLineage; + X509Certificate signerCert = mSigningLineage.get(0).signingCert; + if (otherLineage.isCertificateInLineage(signerCert)) { + descendantLineage = this; + ancestorLineage = otherLineage; + } else { + descendantLineage = otherLineage; + ancestorLineage = this; + } + + int ancestorIndex = 0; + int descendantIndex = 0; + SigningCertificateNode ancestorNode; + SigningCertificateNode descendantNode = descendantLineage.mSigningLineage.get( + descendantIndex++); + List<SigningCertificateNode> mergedLineage = new ArrayList<>(); + // Iterate through the ancestor lineage and add the current node to the resulting lineage + // until the first node of the descendant is found. + while (ancestorIndex < ancestorLineage.size()) { + ancestorNode = ancestorLineage.mSigningLineage.get(ancestorIndex++); + if (ancestorNode.signingCert.equals(descendantNode.signingCert)) { + break; + } + mergedLineage.add(ancestorNode); + } + // If all of the nodes in the ancestor lineage have been added to the merged lineage, then + // there is no overlap between this and the provided lineage. + if (ancestorIndex == mergedLineage.size()) { + throw new IllegalArgumentException( + "The provided lineage is not a descendant or an ancestor of this lineage"); + } + // The descendant lineage's first node was in the ancestor's lineage above; add it to the + // merged lineage. + mergedLineage.add(descendantNode); + while (ancestorIndex < ancestorLineage.size() + && descendantIndex < descendantLineage.size()) { + ancestorNode = ancestorLineage.mSigningLineage.get(ancestorIndex++); + descendantNode = descendantLineage.mSigningLineage.get(descendantIndex++); + if (!ancestorNode.signingCert.equals(descendantNode.signingCert)) { + throw new IllegalArgumentException( + "The provided lineage diverges from this lineage"); + } + mergedLineage.add(descendantNode); + } + // At this point, one or both of the lineages have been exhausted and all signers to this + // point were a match between the two lineages; add any remaining elements from either + // lineage to the merged lineage. + while (ancestorIndex < ancestorLineage.size()) { + mergedLineage.add(ancestorLineage.mSigningLineage.get(ancestorIndex++)); + } + while (descendantIndex < descendantLineage.size()) { + mergedLineage.add(descendantLineage.mSigningLineage.get(descendantIndex++)); + } + return new SigningCertificateLineage(Math.min(mMinSdkVersion, otherLineage.mMinSdkVersion), + mergedLineage); + } + + /** + * Checks whether given lineages are compatible. Returns {@code true} if an installed APK with + * the oldLineage could be updated with an APK with the newLineage. + */ + public static boolean checkLineagesCompatibility( + SigningCertificateLineage oldLineage, SigningCertificateLineage newLineage) { + + final ArrayList<X509Certificate> oldCertificates = oldLineage == null ? + new ArrayList<X509Certificate>() + : new ArrayList(oldLineage.getCertificatesInLineage()); + final ArrayList<X509Certificate> newCertificates = newLineage == null ? + new ArrayList<X509Certificate>() + : new ArrayList(newLineage.getCertificatesInLineage()); + + if (oldCertificates.isEmpty()) { + return true; + } + if (newCertificates.isEmpty()) { + return false; + } + + // Both lineages contain exactly the same certificates or the new lineage extends + // the old one. The capabilities of particular certificates may have changed though but it + // does not matter in terms of current compatibility. + if (newCertificates.size() >= oldCertificates.size() + && newCertificates.subList(0, oldCertificates.size()).equals(oldCertificates)) { + return true; + } + + ArrayList<X509Certificate> newCertificatesArray = new ArrayList(newCertificates); + ArrayList<X509Certificate> oldCertificatesArray = new ArrayList(oldCertificates); + + int lastOldCertIndexInNew = newCertificatesArray.lastIndexOf( + oldCertificatesArray.get(oldCertificatesArray.size()-1)); + + // The new lineage trims some nodes from the beginning of the old lineage and possibly + // extends it at the end. The new lineage must contain the old signing certificate and + // the nodes up until the node with signing certificate must be in the same order. + // Good example 1: + // old: A -> B -> C + // new: B -> C -> D + // Good example 2: + // old: A -> B -> C + // new: C + // Bad example 1: + // old: A -> B -> C + // new: A -> C + // Bad example 1: + // old: A -> B + // new: C -> B + if (lastOldCertIndexInNew >= 0) { + return newCertificatesArray.subList(0, lastOldCertIndexInNew+1).equals( + oldCertificatesArray.subList( + oldCertificates.size()-1-lastOldCertIndexInNew, + oldCertificatesArray.size())); + } + + + // The new lineage can be shorter than the old one only if the last certificate of the new + // lineage exists in the old lineage and has a rollback capability there. + // Good example: + // old: A -> B_withRollbackCapability -> C + // new: A -> B + // Bad example 1: + // old: A -> B -> C + // new: A -> B + // Bad example 2: + // old: A -> B_withRollbackCapability -> C + // new: A -> B -> D + return oldCertificates.subList(0, newCertificates.size()).equals(newCertificates) + && oldLineage.getSignerCapabilities( + oldCertificates.get(newCertificates.size()-1)).hasRollback(); + } + + /** + * Representation of the capabilities the APK would like to grant to its old signing + * certificates. The {@code SigningCertificateLineage} provides two conceptual data structures. + * 1) proof of rotation - Evidence that other parties can trust an APK's current signing + * certificate if they trust an older one in this lineage + * 2) self-trust - certain capabilities may have been granted by an APK to other parties based + * on its own signing certificate. When it changes its signing certificate it may want to + * allow the other parties to retain those capabilities. + * {@code SignerCapabilties} provides a representation of the second structure. + * + * <p>Use {@link Builder} to obtain configuration instances. + */ + public static class SignerCapabilities { + private final int mFlags; + + private final int mCallerConfiguredFlags; + + private SignerCapabilities(int flags) { + this(flags, 0); + } + + private SignerCapabilities(int flags, int callerConfiguredFlags) { + mFlags = flags; + mCallerConfiguredFlags = callerConfiguredFlags; + } + + private int getFlags() { + return mFlags; + } + + /** + * Returns {@code true} if the capabilities of this object match those of the provided + * object. + */ + @Override + public boolean equals(Object other) { + if (this == other) return true; + if (!(other instanceof SignerCapabilities)) return false; + + return this.mFlags == ((SignerCapabilities) other).mFlags; + } + + @Override + public int hashCode() { + return 31 * mFlags; + } + + /** + * Returns {@code true} if this object has the installed data capability. + */ + public boolean hasInstalledData() { + return (mFlags & PAST_CERT_INSTALLED_DATA) != 0; + } + + /** + * Returns {@code true} if this object has the shared UID capability. + */ + public boolean hasSharedUid() { + return (mFlags & PAST_CERT_SHARED_USER_ID) != 0; + } + + /** + * Returns {@code true} if this object has the permission capability. + */ + public boolean hasPermission() { + return (mFlags & PAST_CERT_PERMISSION) != 0; + } + + /** + * Returns {@code true} if this object has the rollback capability. + */ + public boolean hasRollback() { + return (mFlags & PAST_CERT_ROLLBACK) != 0; + } + + /** + * Returns {@code true} if this object has the auth capability. + */ + public boolean hasAuth() { + return (mFlags & PAST_CERT_AUTH) != 0; + } + + /** + * Builder of {@link SignerCapabilities} instances. + */ + public static class Builder { + private int mFlags; + + private int mCallerConfiguredFlags; + + /** + * Constructs a new {@code Builder}. + */ + public Builder() { + mFlags = calculateDefaultFlags(); + } + + /** + * Constructs a new {@code Builder} with the initial capabilities set to the provided + * flags. + */ + public Builder(int flags) { + mFlags = flags; + } + + /** + * Set the {@code PAST_CERT_INSTALLED_DATA} flag in this capabilities object. This flag + * is used by the platform to determine if installed data associated with previous + * signing certificate should be trusted. In particular, this capability is required to + * perform signing certificate rotation during an upgrade on-device. Without it, the + * platform will not permit the app data from the old signing certificate to + * propagate to the new version. Typically, this flag should be set to enable signing + * certificate rotation, and may be unset later when the app developer is satisfied that + * their install base is as migrated as it will be. + */ + public Builder setInstalledData(boolean enabled) { + mCallerConfiguredFlags |= PAST_CERT_INSTALLED_DATA; + if (enabled) { + mFlags |= PAST_CERT_INSTALLED_DATA; + } else { + mFlags &= ~PAST_CERT_INSTALLED_DATA; + } + return this; + } + + /** + * Set the {@code PAST_CERT_SHARED_USER_ID} flag in this capabilities object. This flag + * is used by the platform to determine if this app is willing to be sharedUid with + * other apps which are still signed with the associated signing certificate. This is + * useful in situations where sharedUserId apps would like to change their signing + * certificate, but can't guarantee the order of updates to those apps. + */ + public Builder setSharedUid(boolean enabled) { + mCallerConfiguredFlags |= PAST_CERT_SHARED_USER_ID; + if (enabled) { + mFlags |= PAST_CERT_SHARED_USER_ID; + } else { + mFlags &= ~PAST_CERT_SHARED_USER_ID; + } + return this; + } + + /** + * Set the {@code PAST_CERT_PERMISSION} flag in this capabilities object. This flag + * is used by the platform to determine if this app is willing to grant SIGNATURE + * permissions to apps signed with the associated signing certificate. Without this + * capability, an application signed with the older certificate will not be granted the + * SIGNATURE permissions defined by this app. In addition, if multiple apps define the + * same SIGNATURE permission, the second one the platform sees will not be installable + * if this capability is not set and the signing certificates differ. + */ + public Builder setPermission(boolean enabled) { + mCallerConfiguredFlags |= PAST_CERT_PERMISSION; + if (enabled) { + mFlags |= PAST_CERT_PERMISSION; + } else { + mFlags &= ~PAST_CERT_PERMISSION; + } + return this; + } + + /** + * Set the {@code PAST_CERT_ROLLBACK} flag in this capabilities object. This flag + * is used by the platform to determine if this app is willing to upgrade to a new + * version that is signed by one of its past signing certificates. + * + * <note> WARNING: this effectively removes any benefit of signing certificate changes, + * since a compromised key could retake control of an app even after change, and should + * only be used if there is a problem encountered when trying to ditch an older cert + * </note> + */ + public Builder setRollback(boolean enabled) { + mCallerConfiguredFlags |= PAST_CERT_ROLLBACK; + if (enabled) { + mFlags |= PAST_CERT_ROLLBACK; + } else { + mFlags &= ~PAST_CERT_ROLLBACK; + } + return this; + } + + /** + * Set the {@code PAST_CERT_AUTH} flag in this capabilities object. This flag + * is used by the platform to determine whether or not privileged access based on + * authenticator module signing certificates should be granted. + */ + public Builder setAuth(boolean enabled) { + mCallerConfiguredFlags |= PAST_CERT_AUTH; + if (enabled) { + mFlags |= PAST_CERT_AUTH; + } else { + mFlags &= ~PAST_CERT_AUTH; + } + return this; + } + + /** + * Applies the capabilities that were explicitly set in the provided capabilities object + * to this builder. Any values that were not set will not be applied to this builder + * to prevent unintentinoally setting a capability back to a default value. + */ + public Builder setCallerConfiguredCapabilities(SignerCapabilities capabilities) { + // The mCallerConfiguredFlags should have a bit set for each capability that was + // set by a caller. If a capability was explicitly set then the corresponding bit + // in mCallerConfiguredFlags should be set. This allows the provided capabilities + // to take effect for those set by the caller while those that were not set will + // be cleared by the bitwise and and the initial value for the builder will remain. + mFlags = (mFlags & ~capabilities.mCallerConfiguredFlags) | + (capabilities.mFlags & capabilities.mCallerConfiguredFlags); + return this; + } + + /** + * Returns a new {@code SignerConfig} instance configured based on the configuration of + * this builder. + */ + public SignerCapabilities build() { + return new SignerCapabilities(mFlags, mCallerConfiguredFlags); + } + } + } + + /** + * Configuration of a signer. Used to add a new entry to the {@link SigningCertificateLineage} + * + * <p>Use {@link Builder} to obtain configuration instances. + */ + public static class SignerConfig { + private final PrivateKey mPrivateKey; + private final X509Certificate mCertificate; + + private SignerConfig( + PrivateKey privateKey, + X509Certificate certificate) { + mPrivateKey = privateKey; + mCertificate = certificate; + } + + /** + * Returns the signing key of this signer. + */ + public PrivateKey getPrivateKey() { + return mPrivateKey; + } + + /** + * Returns the certificate(s) of this signer. The first certificate's public key corresponds + * to this signer's private key. + */ + public X509Certificate getCertificate() { + return mCertificate; + } + + /** + * Builder of {@link SignerConfig} instances. + */ + public static class Builder { + private final PrivateKey mPrivateKey; + private final X509Certificate mCertificate; + + /** + * Constructs a new {@code Builder}. + * + * @param privateKey signing key + * @param certificate the X.509 certificate with a subject public key of the + * {@code privateKey}. + */ + public Builder( + PrivateKey privateKey, + X509Certificate certificate) { + mPrivateKey = privateKey; + mCertificate = certificate; + } + + /** + * Returns a new {@code SignerConfig} instance configured based on the configuration of + * this builder. + */ + public SignerConfig build() { + return new SignerConfig( + mPrivateKey, + mCertificate); + } + } + } + + /** + * Builder of {@link SigningCertificateLineage} instances. + */ + public static class Builder { + private final SignerConfig mOriginalSignerConfig; + private final SignerConfig mNewSignerConfig; + private SignerCapabilities mOriginalCapabilities; + private SignerCapabilities mNewCapabilities; + private int mMinSdkVersion; + /** + * Constructs a new {@code Builder}. + * + * @param originalSignerConfig first signer in this lineage, parent of the next + * @param newSignerConfig new signer in the lineage; the new signing key that the APK will + * use + */ + public Builder( + SignerConfig originalSignerConfig, + SignerConfig newSignerConfig) { + if (originalSignerConfig == null || newSignerConfig == null) { + throw new NullPointerException("Can't pass null SignerConfigs when constructing a " + + "new SigningCertificateLineage"); + } + mOriginalSignerConfig = originalSignerConfig; + mNewSignerConfig = newSignerConfig; + } + + /** + * Constructs a new {@code Builder} that is intended to create a {@code + * SigningCertificateLineage} with a single signer in the signing history. + * + * @param originalSignerConfig first signer in this lineage + */ + public Builder(SignerConfig originalSignerConfig) { + if (originalSignerConfig == null) { + throw new NullPointerException("Can't pass null SignerConfigs when constructing a " + + "new SigningCertificateLineage"); + } + mOriginalSignerConfig = originalSignerConfig; + mNewSignerConfig = null; + } + + /** + * Sets the minimum Android platform version (API Level) on which this lineage is expected + * to validate. It is possible that newer signers in the lineage may not be recognized on + * the given platform, but as long as an older signer is, the lineage can still be used to + * sign an APK for the given platform. + * + * <note> By default, this value is set to the value for the + * P release, since this structure was created for that release, and will also be set to + * that value if a smaller one is specified. </note> + */ + public Builder setMinSdkVersion(int minSdkVersion) { + mMinSdkVersion = minSdkVersion; + return this; + } + + /** + * Sets capabilities to give {@code mOriginalSignerConfig}. These capabilities allow an + * older signing certificate to still be used in some situations on the platform even though + * the APK is now being signed by a newer signing certificate. + */ + public Builder setOriginalCapabilities(SignerCapabilities signerCapabilities) { + if (signerCapabilities == null) { + throw new NullPointerException("signerCapabilities == null"); + } + mOriginalCapabilities = signerCapabilities; + return this; + } + + /** + * Sets capabilities to give {@code mNewSignerConfig}. These capabilities allow an + * older signing certificate to still be used in some situations on the platform even though + * the APK is now being signed by a newer signing certificate. By default, the new signer + * will have all capabilities, so when first switching to a new signing certificate, these + * capabilities have no effect, but they will act as the default level of trust when moving + * to a new signing certificate. + */ + public Builder setNewCapabilities(SignerCapabilities signerCapabilities) { + if (signerCapabilities == null) { + throw new NullPointerException("signerCapabilities == null"); + } + mNewCapabilities = signerCapabilities; + return this; + } + + public SigningCertificateLineage build() + throws CertificateEncodingException, InvalidKeyException, NoSuchAlgorithmException, + SignatureException { + if (mMinSdkVersion < AndroidSdkVersion.P) { + mMinSdkVersion = AndroidSdkVersion.P; + } + + if (mOriginalCapabilities == null) { + mOriginalCapabilities = new SignerCapabilities.Builder().build(); + } + + if (mNewSignerConfig == null) { + return createSigningLineage(mMinSdkVersion, mOriginalSignerConfig, + mOriginalCapabilities); + } + + if (mNewCapabilities == null) { + mNewCapabilities = new SignerCapabilities.Builder().build(); + } + + return createSigningLineage( + mMinSdkVersion, mOriginalSignerConfig, mOriginalCapabilities, + mNewSignerConfig, mNewCapabilities); + } + } +} diff --git a/platform/android/java/editor/src/main/java/com/android/apksig/SourceStampVerifier.java b/platform/android/java/editor/src/main/java/com/android/apksig/SourceStampVerifier.java new file mode 100644 index 0000000000..98da68ea8f --- /dev/null +++ b/platform/android/java/editor/src/main/java/com/android/apksig/SourceStampVerifier.java @@ -0,0 +1,911 @@ +/* + * Copyright (C) 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.apksig; + +import static com.android.apksig.Constants.VERSION_APK_SIGNATURE_SCHEME_V2; +import static com.android.apksig.Constants.VERSION_APK_SIGNATURE_SCHEME_V3; +import static com.android.apksig.Constants.VERSION_JAR_SIGNATURE_SCHEME; +import static com.android.apksig.apk.ApkUtilsLite.computeSha256DigestBytes; +import static com.android.apksig.internal.apk.stamp.SourceStampConstants.SOURCE_STAMP_CERTIFICATE_HASH_ZIP_ENTRY_NAME; +import static com.android.apksig.internal.apk.v1.V1SchemeConstants.MANIFEST_ENTRY_NAME; + +import com.android.apksig.apk.ApkFormatException; +import com.android.apksig.apk.ApkUtilsLite; +import com.android.apksig.internal.apk.ApkSigResult; +import com.android.apksig.internal.apk.ApkSignerInfo; +import com.android.apksig.internal.apk.ApkSigningBlockUtilsLite; +import com.android.apksig.internal.apk.ContentDigestAlgorithm; +import com.android.apksig.internal.apk.SignatureAlgorithm; +import com.android.apksig.internal.apk.SignatureInfo; +import com.android.apksig.internal.apk.SignatureNotFoundException; +import com.android.apksig.internal.apk.stamp.SourceStampConstants; +import com.android.apksig.internal.apk.stamp.V2SourceStampVerifier; +import com.android.apksig.internal.apk.v2.V2SchemeConstants; +import com.android.apksig.internal.apk.v3.V3SchemeConstants; +import com.android.apksig.internal.util.AndroidSdkVersion; +import com.android.apksig.internal.util.GuaranteedEncodedFormX509Certificate; +import com.android.apksig.internal.zip.CentralDirectoryRecord; +import com.android.apksig.internal.zip.LocalFileRecord; +import com.android.apksig.internal.zip.ZipUtils; +import com.android.apksig.util.DataSource; +import com.android.apksig.util.DataSources; +import com.android.apksig.zip.ZipFormatException; +import com.android.apksig.zip.ZipSections; + +import java.io.ByteArrayInputStream; +import java.io.Closeable; +import java.io.File; +import java.io.IOException; +import java.io.RandomAccessFile; +import java.nio.BufferUnderflowException; +import java.nio.ByteBuffer; +import java.security.NoSuchAlgorithmException; +import java.security.cert.Certificate; +import java.security.cert.CertificateException; +import java.security.cert.CertificateFactory; +import java.security.cert.X509Certificate; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.EnumMap; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * APK source stamp verifier intended only to verify the validity of the stamp signature. + * + * <p>Note, this verifier does not validate the signatures of the jar signing / APK signature blocks + * when obtaining the digests for verification. This verifier should only be used in cases where + * another mechanism has already been used to verify the APK signatures. + */ +public class SourceStampVerifier { + private final File mApkFile; + private final DataSource mApkDataSource; + + private final int mMinSdkVersion; + private final int mMaxSdkVersion; + + private SourceStampVerifier( + File apkFile, + DataSource apkDataSource, + int minSdkVersion, + int maxSdkVersion) { + mApkFile = apkFile; + mApkDataSource = apkDataSource; + mMinSdkVersion = minSdkVersion; + mMaxSdkVersion = maxSdkVersion; + } + + /** + * Verifies the APK's source stamp signature and returns the result of the verification. + * + * <p>The APK's source stamp can be considered verified if the result's {@link + * Result#isVerified()} returns {@code true}. If source stamp verification fails all of the + * resulting errors can be obtained from {@link Result#getAllErrors()}, or individual errors + * can be obtained as follows: + * <ul> + * <li>Obtain the generic errors via {@link Result#getErrors()} + * <li>Obtain the V2 signers via {@link Result#getV2SchemeSigners()}, then for each signer + * query for any errors with {@link Result.SignerInfo#getErrors()} + * <li>Obtain the V3 signers via {@link Result#getV3SchemeSigners()}, then for each signer + * query for any errors with {@link Result.SignerInfo#getErrors()} + * <li>Obtain the source stamp signer via {@link Result#getSourceStampInfo()}, then query + * for any stamp errors with {@link Result.SourceStampInfo#getErrors()} + * </ul> + */ + public SourceStampVerifier.Result verifySourceStamp() { + return verifySourceStamp(null); + } + + /** + * Verifies the APK's source stamp signature, including verification that the SHA-256 digest of + * the stamp signing certificate matches the {@code expectedCertDigest}, and returns the result + * of the verification. + * + * <p>A value of {@code null} for the {@code expectedCertDigest} will verify the source stamp, + * if present, without verifying the actual source stamp certificate used to sign the source + * stamp. This can be used to verify an APK contains a properly signed source stamp without + * verifying a particular signer. + * + * @see #verifySourceStamp() + */ + public SourceStampVerifier.Result verifySourceStamp(String expectedCertDigest) { + Closeable in = null; + try { + DataSource apk; + if (mApkDataSource != null) { + apk = mApkDataSource; + } else if (mApkFile != null) { + RandomAccessFile f = new RandomAccessFile(mApkFile, "r"); + in = f; + apk = DataSources.asDataSource(f, 0, f.length()); + } else { + throw new IllegalStateException("APK not provided"); + } + return verifySourceStamp(apk, expectedCertDigest); + } catch (IOException e) { + Result result = new Result(); + result.addVerificationError(ApkVerificationIssue.UNEXPECTED_EXCEPTION, e); + return result; + } finally { + if (in != null) { + try { + in.close(); + } catch (IOException ignored) { + } + } + } + } + + /** + * Verifies the provided {@code apk}'s source stamp signature, including verification of the + * SHA-256 digest of the stamp signing certificate matches the {@code expectedCertDigest}, and + * returns the result of the verification. + * + * @see #verifySourceStamp(String) + */ + private SourceStampVerifier.Result verifySourceStamp(DataSource apk, + String expectedCertDigest) { + Result result = new Result(); + try { + ZipSections zipSections = ApkUtilsLite.findZipSections(apk); + // Attempt to obtain the source stamp's certificate digest from the APK. + List<CentralDirectoryRecord> cdRecords = + ZipUtils.parseZipCentralDirectory(apk, zipSections); + CentralDirectoryRecord sourceStampCdRecord = null; + for (CentralDirectoryRecord cdRecord : cdRecords) { + if (SOURCE_STAMP_CERTIFICATE_HASH_ZIP_ENTRY_NAME.equals(cdRecord.getName())) { + sourceStampCdRecord = cdRecord; + break; + } + } + + // If the source stamp's certificate digest is not available within the APK then the + // source stamp cannot be verified; check if a source stamp signing block is in the + // APK's signature block to determine the appropriate status to return. + if (sourceStampCdRecord == null) { + boolean stampSigningBlockFound; + try { + ApkSigningBlockUtilsLite.findSignature(apk, zipSections, + SourceStampConstants.V2_SOURCE_STAMP_BLOCK_ID); + stampSigningBlockFound = true; + } catch (SignatureNotFoundException e) { + stampSigningBlockFound = false; + } + result.addVerificationError(stampSigningBlockFound + ? ApkVerificationIssue.SOURCE_STAMP_SIGNATURE_BLOCK_WITHOUT_CERT_DIGEST + : ApkVerificationIssue.SOURCE_STAMP_CERT_DIGEST_AND_SIG_BLOCK_MISSING); + return result; + } + + // Verify that the contents of the source stamp certificate digest match the expected + // value, if provided. + byte[] sourceStampCertificateDigest = + LocalFileRecord.getUncompressedData( + apk, + sourceStampCdRecord, + zipSections.getZipCentralDirectoryOffset()); + if (expectedCertDigest != null) { + String actualCertDigest = ApkSigningBlockUtilsLite.toHex( + sourceStampCertificateDigest); + if (!expectedCertDigest.equalsIgnoreCase(actualCertDigest)) { + result.addVerificationError( + ApkVerificationIssue.SOURCE_STAMP_EXPECTED_DIGEST_MISMATCH, + actualCertDigest, expectedCertDigest); + return result; + } + } + + Map<Integer, Map<ContentDigestAlgorithm, byte[]>> signatureSchemeApkContentDigests = + new HashMap<>(); + if (mMaxSdkVersion >= AndroidSdkVersion.P) { + SignatureInfo signatureInfo; + try { + signatureInfo = ApkSigningBlockUtilsLite.findSignature(apk, zipSections, + V3SchemeConstants.APK_SIGNATURE_SCHEME_V3_BLOCK_ID); + } catch (SignatureNotFoundException e) { + signatureInfo = null; + } + if (signatureInfo != null) { + Map<ContentDigestAlgorithm, byte[]> apkContentDigests = new EnumMap<>( + ContentDigestAlgorithm.class); + parseSigners(signatureInfo.signatureBlock, VERSION_APK_SIGNATURE_SCHEME_V3, + apkContentDigests, result); + signatureSchemeApkContentDigests.put( + VERSION_APK_SIGNATURE_SCHEME_V3, apkContentDigests); + } + } + + if (mMaxSdkVersion >= AndroidSdkVersion.N && (mMinSdkVersion < AndroidSdkVersion.P || + signatureSchemeApkContentDigests.isEmpty())) { + SignatureInfo signatureInfo; + try { + signatureInfo = ApkSigningBlockUtilsLite.findSignature(apk, zipSections, + V2SchemeConstants.APK_SIGNATURE_SCHEME_V2_BLOCK_ID); + } catch (SignatureNotFoundException e) { + signatureInfo = null; + } + if (signatureInfo != null) { + Map<ContentDigestAlgorithm, byte[]> apkContentDigests = new EnumMap<>( + ContentDigestAlgorithm.class); + parseSigners(signatureInfo.signatureBlock, VERSION_APK_SIGNATURE_SCHEME_V2, + apkContentDigests, result); + signatureSchemeApkContentDigests.put( + VERSION_APK_SIGNATURE_SCHEME_V2, apkContentDigests); + } + } + + if (mMinSdkVersion < AndroidSdkVersion.N + || signatureSchemeApkContentDigests.isEmpty()) { + Map<ContentDigestAlgorithm, byte[]> apkContentDigests = + getApkContentDigestFromV1SigningScheme(cdRecords, apk, zipSections, result); + signatureSchemeApkContentDigests.put(VERSION_JAR_SIGNATURE_SCHEME, + apkContentDigests); + } + + ApkSigResult sourceStampResult = + V2SourceStampVerifier.verify( + apk, + zipSections, + sourceStampCertificateDigest, + signatureSchemeApkContentDigests, + mMinSdkVersion, + mMaxSdkVersion); + result.mergeFrom(sourceStampResult); + return result; + } catch (ApkFormatException | IOException | ZipFormatException e) { + result.addVerificationError(ApkVerificationIssue.MALFORMED_APK, e); + } catch (NoSuchAlgorithmException e) { + result.addVerificationError(ApkVerificationIssue.UNEXPECTED_EXCEPTION, e); + } catch (SignatureNotFoundException e) { + result.addVerificationError(ApkVerificationIssue.SOURCE_STAMP_SIG_MISSING); + } + return result; + } + + /** + * Parses each signer in the provided APK V2 / V3 signature block and populates corresponding + * {@code SignerInfo} of the provided {@code result} and their {@code apkContentDigests}. + * + * <p>This method adds one or more errors to the {@code result} if a verification error is + * expected to be encountered on an Android platform version in the + * {@code [minSdkVersion, maxSdkVersion]} range. + */ + public static void parseSigners( + ByteBuffer apkSignatureSchemeBlock, + int apkSigSchemeVersion, + Map<ContentDigestAlgorithm, byte[]> apkContentDigests, + Result result) { + boolean isV2Block = apkSigSchemeVersion == VERSION_APK_SIGNATURE_SCHEME_V2; + // Both the V2 and V3 signature blocks contain the following: + // * length-prefixed sequence of length-prefixed signers + ByteBuffer signers; + try { + signers = ApkSigningBlockUtilsLite.getLengthPrefixedSlice(apkSignatureSchemeBlock); + } catch (ApkFormatException e) { + result.addVerificationWarning(isV2Block ? ApkVerificationIssue.V2_SIG_MALFORMED_SIGNERS + : ApkVerificationIssue.V3_SIG_MALFORMED_SIGNERS); + return; + } + if (!signers.hasRemaining()) { + result.addVerificationWarning(isV2Block ? ApkVerificationIssue.V2_SIG_NO_SIGNERS + : ApkVerificationIssue.V3_SIG_NO_SIGNERS); + return; + } + + CertificateFactory certFactory; + try { + certFactory = CertificateFactory.getInstance("X.509"); + } catch (CertificateException e) { + throw new RuntimeException("Failed to obtain X.509 CertificateFactory", e); + } + while (signers.hasRemaining()) { + Result.SignerInfo signerInfo = new Result.SignerInfo(); + if (isV2Block) { + result.addV2Signer(signerInfo); + } else { + result.addV3Signer(signerInfo); + } + try { + ByteBuffer signer = ApkSigningBlockUtilsLite.getLengthPrefixedSlice(signers); + parseSigner( + signer, + apkSigSchemeVersion, + certFactory, + apkContentDigests, + signerInfo); + } catch (ApkFormatException | BufferUnderflowException e) { + signerInfo.addVerificationWarning( + isV2Block ? ApkVerificationIssue.V2_SIG_MALFORMED_SIGNER + : ApkVerificationIssue.V3_SIG_MALFORMED_SIGNER); + return; + } + } + } + + /** + * Parses the provided signer block and populates the {@code result}. + * + * <p>This verifies signatures over {@code signed-data} contained in this block but does not + * verify the integrity of the rest of the APK. To facilitate APK integrity verification, this + * method adds the {@code contentDigestsToVerify}. These digests can then be used to verify the + * integrity of the APK. + * + * <p>This method adds one or more errors to the {@code result} if a verification error is + * expected to be encountered on an Android platform version in the + * {@code [minSdkVersion, maxSdkVersion]} range. + */ + private static void parseSigner( + ByteBuffer signerBlock, + int apkSigSchemeVersion, + CertificateFactory certFactory, + Map<ContentDigestAlgorithm, byte[]> apkContentDigests, + Result.SignerInfo signerInfo) + throws ApkFormatException { + boolean isV2Signer = apkSigSchemeVersion == VERSION_APK_SIGNATURE_SCHEME_V2; + // Both the V2 and V3 signer blocks contain the following: + // * length-prefixed signed data + // * length-prefixed sequence of length-prefixed digests: + // * uint32: signature algorithm ID + // * length-prefixed bytes: digest of contents + // * length-prefixed sequence of certificates: + // * length-prefixed bytes: X.509 certificate (ASN.1 DER encoded). + ByteBuffer signedData = ApkSigningBlockUtilsLite.getLengthPrefixedSlice(signerBlock); + ByteBuffer digests = ApkSigningBlockUtilsLite.getLengthPrefixedSlice(signedData); + ByteBuffer certificates = ApkSigningBlockUtilsLite.getLengthPrefixedSlice(signedData); + + // Parse the digests block + while (digests.hasRemaining()) { + try { + ByteBuffer digest = ApkSigningBlockUtilsLite.getLengthPrefixedSlice(digests); + int sigAlgorithmId = digest.getInt(); + byte[] digestBytes = ApkSigningBlockUtilsLite.readLengthPrefixedByteArray(digest); + SignatureAlgorithm signatureAlgorithm = SignatureAlgorithm.findById(sigAlgorithmId); + if (signatureAlgorithm == null) { + continue; + } + apkContentDigests.put(signatureAlgorithm.getContentDigestAlgorithm(), digestBytes); + } catch (ApkFormatException | BufferUnderflowException e) { + signerInfo.addVerificationWarning( + isV2Signer ? ApkVerificationIssue.V2_SIG_MALFORMED_DIGEST + : ApkVerificationIssue.V3_SIG_MALFORMED_DIGEST); + return; + } + } + + // Parse the certificates block + if (certificates.hasRemaining()) { + byte[] encodedCert = ApkSigningBlockUtilsLite.readLengthPrefixedByteArray(certificates); + X509Certificate certificate; + try { + certificate = (X509Certificate) certFactory.generateCertificate( + new ByteArrayInputStream(encodedCert)); + } catch (CertificateException e) { + signerInfo.addVerificationWarning( + isV2Signer ? ApkVerificationIssue.V2_SIG_MALFORMED_CERTIFICATE + : ApkVerificationIssue.V3_SIG_MALFORMED_CERTIFICATE); + return; + } + // Wrap the cert so that the result's getEncoded returns exactly the original encoded + // form. Without this, getEncoded may return a different form from what was stored in + // the signature. This is because some X509Certificate(Factory) implementations + // re-encode certificates. + certificate = new GuaranteedEncodedFormX509Certificate(certificate, encodedCert); + signerInfo.setSigningCertificate(certificate); + } + + if (signerInfo.getSigningCertificate() == null) { + signerInfo.addVerificationWarning( + isV2Signer ? ApkVerificationIssue.V2_SIG_NO_CERTIFICATES + : ApkVerificationIssue.V3_SIG_NO_CERTIFICATES); + return; + } + } + + /** + * Returns a mapping of the {@link ContentDigestAlgorithm} to the {@code byte[]} digest of the + * V1 / jar signing META-INF/MANIFEST.MF; if this file is not found then an empty {@code Map} is + * returned. + * + * <p>If any errors are encountered while parsing the V1 signers the provided {@code result} + * will be updated to include a warning, but the source stamp verification can still proceed. + */ + private static Map<ContentDigestAlgorithm, byte[]> getApkContentDigestFromV1SigningScheme( + List<CentralDirectoryRecord> cdRecords, + DataSource apk, + ZipSections zipSections, + Result result) + throws IOException, ApkFormatException { + CentralDirectoryRecord manifestCdRecord = null; + List<CentralDirectoryRecord> signatureBlockRecords = new ArrayList<>(1); + Map<ContentDigestAlgorithm, byte[]> v1ContentDigest = new EnumMap<>( + ContentDigestAlgorithm.class); + for (CentralDirectoryRecord cdRecord : cdRecords) { + String cdRecordName = cdRecord.getName(); + if (cdRecordName == null) { + continue; + } + if (manifestCdRecord == null && MANIFEST_ENTRY_NAME.equals(cdRecordName)) { + manifestCdRecord = cdRecord; + continue; + } + if (cdRecordName.startsWith("META-INF/") + && (cdRecordName.endsWith(".RSA") + || cdRecordName.endsWith(".DSA") + || cdRecordName.endsWith(".EC"))) { + signatureBlockRecords.add(cdRecord); + } + } + if (manifestCdRecord == null) { + // No JAR signing manifest file found. For SourceStamp verification, returning an empty + // digest is enough since this would affect the final digest signed by the stamp, and + // thus an empty digest will invalidate that signature. + return v1ContentDigest; + } + if (signatureBlockRecords.isEmpty()) { + result.addVerificationWarning(ApkVerificationIssue.JAR_SIG_NO_SIGNATURES); + } else { + for (CentralDirectoryRecord signatureBlockRecord : signatureBlockRecords) { + try { + CertificateFactory certFactory = CertificateFactory.getInstance("X.509"); + byte[] signatureBlockBytes = LocalFileRecord.getUncompressedData(apk, + signatureBlockRecord, zipSections.getZipCentralDirectoryOffset()); + for (Certificate certificate : certFactory.generateCertificates( + new ByteArrayInputStream(signatureBlockBytes))) { + // If multiple certificates are found within the signature block only the + // first is used as the signer of this block. + if (certificate instanceof X509Certificate) { + Result.SignerInfo signerInfo = new Result.SignerInfo(); + signerInfo.setSigningCertificate((X509Certificate) certificate); + result.addV1Signer(signerInfo); + break; + } + } + } catch (CertificateException e) { + // Log a warning for the parsing exception but still proceed with the stamp + // verification. + result.addVerificationWarning(ApkVerificationIssue.JAR_SIG_PARSE_EXCEPTION, + signatureBlockRecord.getName(), e); + break; + } catch (ZipFormatException e) { + throw new ApkFormatException("Failed to read APK", e); + } + } + } + try { + byte[] manifestBytes = + LocalFileRecord.getUncompressedData( + apk, manifestCdRecord, zipSections.getZipCentralDirectoryOffset()); + v1ContentDigest.put( + ContentDigestAlgorithm.SHA256, computeSha256DigestBytes(manifestBytes)); + return v1ContentDigest; + } catch (ZipFormatException e) { + throw new ApkFormatException("Failed to read APK", e); + } + } + + /** + * Result of verifying the APK's source stamp signature; this signature can only be considered + * verified if {@link #isVerified()} returns true. + */ + public static class Result { + private final List<SignerInfo> mV1SchemeSigners = new ArrayList<>(); + private final List<SignerInfo> mV2SchemeSigners = new ArrayList<>(); + private final List<SignerInfo> mV3SchemeSigners = new ArrayList<>(); + private final List<List<SignerInfo>> mAllSchemeSigners = Arrays.asList(mV1SchemeSigners, + mV2SchemeSigners, mV3SchemeSigners); + private SourceStampInfo mSourceStampInfo; + + private final List<ApkVerificationIssue> mErrors = new ArrayList<>(); + private final List<ApkVerificationIssue> mWarnings = new ArrayList<>(); + + private boolean mVerified; + + void addVerificationError(int errorId, Object... params) { + mErrors.add(new ApkVerificationIssue(errorId, params)); + } + + void addVerificationWarning(int warningId, Object... params) { + mWarnings.add(new ApkVerificationIssue(warningId, params)); + } + + private void addV1Signer(SignerInfo signerInfo) { + mV1SchemeSigners.add(signerInfo); + } + + private void addV2Signer(SignerInfo signerInfo) { + mV2SchemeSigners.add(signerInfo); + } + + private void addV3Signer(SignerInfo signerInfo) { + mV3SchemeSigners.add(signerInfo); + } + + /** + * Returns {@code true} if the APK's source stamp signature + */ + public boolean isVerified() { + return mVerified; + } + + private void mergeFrom(ApkSigResult source) { + switch (source.signatureSchemeVersion) { + case Constants.VERSION_SOURCE_STAMP: + mVerified = source.verified; + if (!source.mSigners.isEmpty()) { + mSourceStampInfo = new SourceStampInfo(source.mSigners.get(0)); + } + break; + default: + throw new IllegalArgumentException( + "Unknown ApkSigResult Signing Block Scheme Id " + + source.signatureSchemeVersion); + } + } + + /** + * Returns a {@code List} of {@link SignerInfo} objects representing the V1 signers of the + * provided APK. + */ + public List<SignerInfo> getV1SchemeSigners() { + return mV1SchemeSigners; + } + + /** + * Returns a {@code List} of {@link SignerInfo} objects representing the V2 signers of the + * provided APK. + */ + public List<SignerInfo> getV2SchemeSigners() { + return mV2SchemeSigners; + } + + /** + * Returns a {@code List} of {@link SignerInfo} objects representing the V3 signers of the + * provided APK. + */ + public List<SignerInfo> getV3SchemeSigners() { + return mV3SchemeSigners; + } + + /** + * Returns the {@link SourceStampInfo} instance representing the source stamp signer for the + * APK, or null if the source stamp signature verification failed before the stamp signature + * block could be fully parsed. + */ + public SourceStampInfo getSourceStampInfo() { + return mSourceStampInfo; + } + + /** + * Returns {@code true} if an error was encountered while verifying the APK. + * + * <p>Any error prevents the APK from being considered verified. + */ + public boolean containsErrors() { + if (!mErrors.isEmpty()) { + return true; + } + for (List<SignerInfo> signers : mAllSchemeSigners) { + for (SignerInfo signer : signers) { + if (signer.containsErrors()) { + return true; + } + } + } + if (mSourceStampInfo != null) { + if (mSourceStampInfo.containsErrors()) { + return true; + } + } + return false; + } + + /** + * Returns the errors encountered while verifying the APK's source stamp. + */ + public List<ApkVerificationIssue> getErrors() { + return mErrors; + } + + /** + * Returns the warnings encountered while verifying the APK's source stamp. + */ + public List<ApkVerificationIssue> getWarnings() { + return mWarnings; + } + + /** + * Returns all errors for this result, including any errors from signature scheme signers + * and the source stamp. + */ + public List<ApkVerificationIssue> getAllErrors() { + List<ApkVerificationIssue> errors = new ArrayList<>(); + errors.addAll(mErrors); + + for (List<SignerInfo> signers : mAllSchemeSigners) { + for (SignerInfo signer : signers) { + errors.addAll(signer.getErrors()); + } + } + if (mSourceStampInfo != null) { + errors.addAll(mSourceStampInfo.getErrors()); + } + return errors; + } + + /** + * Returns all warnings for this result, including any warnings from signature scheme + * signers and the source stamp. + */ + public List<ApkVerificationIssue> getAllWarnings() { + List<ApkVerificationIssue> warnings = new ArrayList<>(); + warnings.addAll(mWarnings); + + for (List<SignerInfo> signers : mAllSchemeSigners) { + for (SignerInfo signer : signers) { + warnings.addAll(signer.getWarnings()); + } + } + if (mSourceStampInfo != null) { + warnings.addAll(mSourceStampInfo.getWarnings()); + } + return warnings; + } + + /** + * Contains information about an APK's signer and any errors encountered while parsing the + * corresponding signature block. + */ + public static class SignerInfo { + private X509Certificate mSigningCertificate; + private final List<ApkVerificationIssue> mErrors = new ArrayList<>(); + private final List<ApkVerificationIssue> mWarnings = new ArrayList<>(); + + void setSigningCertificate(X509Certificate signingCertificate) { + mSigningCertificate = signingCertificate; + } + + void addVerificationError(int errorId, Object... params) { + mErrors.add(new ApkVerificationIssue(errorId, params)); + } + + void addVerificationWarning(int warningId, Object... params) { + mWarnings.add(new ApkVerificationIssue(warningId, params)); + } + + /** + * Returns the current signing certificate used by this signer. + */ + public X509Certificate getSigningCertificate() { + return mSigningCertificate; + } + + /** + * Returns a {@link List} of {@link ApkVerificationIssue} objects representing errors + * encountered during processing of this signer's signature block. + */ + public List<ApkVerificationIssue> getErrors() { + return mErrors; + } + + /** + * Returns a {@link List} of {@link ApkVerificationIssue} objects representing warnings + * encountered during processing of this signer's signature block. + */ + public List<ApkVerificationIssue> getWarnings() { + return mWarnings; + } + + /** + * Returns {@code true} if any errors were encountered while parsing this signer's + * signature block. + */ + public boolean containsErrors() { + return !mErrors.isEmpty(); + } + } + + /** + * Contains information about an APK's source stamp and any errors encountered while + * parsing the stamp signature block. + */ + public static class SourceStampInfo { + private final List<X509Certificate> mCertificates; + private final List<X509Certificate> mCertificateLineage; + + private final List<ApkVerificationIssue> mErrors = new ArrayList<>(); + private final List<ApkVerificationIssue> mWarnings = new ArrayList<>(); + private final List<ApkVerificationIssue> mInfoMessages = new ArrayList<>(); + + private final long mTimestamp; + + /* + * Since this utility is intended just to verify the source stamp, and the source stamp + * currently only logs warnings to prevent failing the APK signature verification, treat + * all warnings as errors. If the stamp verification is updated to log errors this + * should be set to false to ensure only errors trigger a failure verifying the source + * stamp. + */ + private static final boolean mWarningsAsErrors = true; + + private SourceStampInfo(ApkSignerInfo result) { + mCertificates = result.certs; + mCertificateLineage = result.certificateLineage; + mErrors.addAll(result.getErrors()); + mWarnings.addAll(result.getWarnings()); + mInfoMessages.addAll(result.getInfoMessages()); + mTimestamp = result.timestamp; + } + + /** + * Returns the SourceStamp's signing certificate or {@code null} if not available. The + * certificate is guaranteed to be available if no errors were encountered during + * verification (see {@link #containsErrors()}. + * + * <p>This certificate contains the SourceStamp's public key. + */ + public X509Certificate getCertificate() { + return mCertificates.isEmpty() ? null : mCertificates.get(0); + } + + /** + * Returns a {@code List} of {@link X509Certificate} instances representing the source + * stamp signer's lineage with the oldest signer at element 0, or an empty {@code List} + * if the stamp's signing certificate has not been rotated. + */ + public List<X509Certificate> getCertificatesInLineage() { + return mCertificateLineage; + } + + /** + * Returns whether any errors were encountered during the source stamp verification. + */ + public boolean containsErrors() { + return !mErrors.isEmpty() || (mWarningsAsErrors && !mWarnings.isEmpty()); + } + + /** + * Returns {@code true} if any info messages were encountered during verification of + * this source stamp. + */ + public boolean containsInfoMessages() { + return !mInfoMessages.isEmpty(); + } + + /** + * Returns a {@code List} of {@link ApkVerificationIssue} representing errors that were + * encountered during source stamp verification. + */ + public List<ApkVerificationIssue> getErrors() { + if (!mWarningsAsErrors) { + return mErrors; + } + List<ApkVerificationIssue> result = new ArrayList<>(); + result.addAll(mErrors); + result.addAll(mWarnings); + return result; + } + + /** + * Returns a {@code List} of {@link ApkVerificationIssue} representing warnings that + * were encountered during source stamp verification. + */ + public List<ApkVerificationIssue> getWarnings() { + return mWarnings; + } + + /** + * Returns a {@code List} of {@link ApkVerificationIssue} representing info messages + * that were encountered during source stamp verification. + */ + public List<ApkVerificationIssue> getInfoMessages() { + return mInfoMessages; + } + + /** + * Returns the epoch timestamp in seconds representing the time this source stamp block + * was signed, or 0 if the timestamp is not available. + */ + public long getTimestampEpochSeconds() { + return mTimestamp; + } + } + } + + /** + * Builder of {@link SourceStampVerifier} instances. + * + * <p> The resulting verifier, by default, checks whether the APK's source stamp signature will + * verify on all platform versions. The APK's {@code android:minSdkVersion} attribute is not + * queried to determine the APK's minimum supported level, so the caller should specify a lower + * bound with {@link #setMinCheckedPlatformVersion(int)}. + */ + public static class Builder { + private final File mApkFile; + private final DataSource mApkDataSource; + + private int mMinSdkVersion = 1; + private int mMaxSdkVersion = Integer.MAX_VALUE; + + /** + * Constructs a new {@code Builder} for source stamp verification of the provided {@code + * apk}. + */ + public Builder(File apk) { + if (apk == null) { + throw new NullPointerException("apk == null"); + } + mApkFile = apk; + mApkDataSource = null; + } + + /** + * Constructs a new {@code Builder} for source stamp verification of the provided {@code + * apk}. + */ + public Builder(DataSource apk) { + if (apk == null) { + throw new NullPointerException("apk == null"); + } + mApkDataSource = apk; + mApkFile = null; + } + + /** + * Sets the oldest Android platform version for which the APK's source stamp is verified. + * + * <p>APK source stamp verification will confirm that the APK's stamp is expected to verify + * on all Android platforms starting from the platform version with the provided {@code + * minSdkVersion}. The upper end of the platform versions range can be modified via + * {@link #setMaxCheckedPlatformVersion(int)}. + * + * @param minSdkVersion API Level of the oldest platform for which to verify the APK + */ + public SourceStampVerifier.Builder setMinCheckedPlatformVersion(int minSdkVersion) { + mMinSdkVersion = minSdkVersion; + return this; + } + + /** + * Sets the newest Android platform version for which the APK's source stamp is verified. + * + * <p>APK source stamp verification will confirm that the APK's stamp is expected to verify + * on all platform versions up to and including the proviced {@code maxSdkVersion}. The + * lower end of the platform versions range can be modified via {@link + * #setMinCheckedPlatformVersion(int)}. + * + * @param maxSdkVersion API Level of the newest platform for which to verify the APK + * @see #setMinCheckedPlatformVersion(int) + */ + public SourceStampVerifier.Builder setMaxCheckedPlatformVersion(int maxSdkVersion) { + mMaxSdkVersion = maxSdkVersion; + return this; + } + + /** + * Returns a {@link SourceStampVerifier} initialized according to the configuration of this + * builder. + */ + public SourceStampVerifier build() { + return new SourceStampVerifier( + mApkFile, + mApkDataSource, + mMinSdkVersion, + mMaxSdkVersion); + } + } +} diff --git a/platform/android/java/editor/src/main/java/com/android/apksig/apk/ApkFormatException.java b/platform/android/java/editor/src/main/java/com/android/apksig/apk/ApkFormatException.java new file mode 100644 index 0000000000..a780134498 --- /dev/null +++ b/platform/android/java/editor/src/main/java/com/android/apksig/apk/ApkFormatException.java @@ -0,0 +1,35 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.apksig.apk; + +/** + * Indicates that an APK is not well-formed. For example, this may indicate that the APK is not a + * well-formed ZIP archive, in which case {@link #getCause()} will return a + * {@link com.android.apksig.zip.ZipFormatException ZipFormatException}, or that the APK contains + * multiple ZIP entries with the same name. + */ +public class ApkFormatException extends Exception { + private static final long serialVersionUID = 1L; + + public ApkFormatException(String message) { + super(message); + } + + public ApkFormatException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/platform/android/java/editor/src/main/java/com/android/apksig/apk/ApkSigningBlockNotFoundException.java b/platform/android/java/editor/src/main/java/com/android/apksig/apk/ApkSigningBlockNotFoundException.java new file mode 100644 index 0000000000..fd961d5716 --- /dev/null +++ b/platform/android/java/editor/src/main/java/com/android/apksig/apk/ApkSigningBlockNotFoundException.java @@ -0,0 +1,32 @@ +/* + * Copyright (C) 2017 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.apksig.apk; + +/** + * Indicates that no APK Signing Block was found in an APK. + */ +public class ApkSigningBlockNotFoundException extends Exception { + private static final long serialVersionUID = 1L; + + public ApkSigningBlockNotFoundException(String message) { + super(message); + } + + public ApkSigningBlockNotFoundException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/platform/android/java/editor/src/main/java/com/android/apksig/apk/ApkUtils.java b/platform/android/java/editor/src/main/java/com/android/apksig/apk/ApkUtils.java new file mode 100644 index 0000000000..156ea17c00 --- /dev/null +++ b/platform/android/java/editor/src/main/java/com/android/apksig/apk/ApkUtils.java @@ -0,0 +1,670 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.apksig.apk; + +import com.android.apksig.internal.apk.AndroidBinXmlParser; +import com.android.apksig.internal.apk.stamp.SourceStampConstants; +import com.android.apksig.internal.apk.v1.V1SchemeVerifier; +import com.android.apksig.internal.util.Pair; +import com.android.apksig.internal.zip.CentralDirectoryRecord; +import com.android.apksig.internal.zip.LocalFileRecord; +import com.android.apksig.internal.zip.ZipUtils; +import com.android.apksig.util.DataSource; +import com.android.apksig.zip.ZipFormatException; + +import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.util.Arrays; +import java.util.Comparator; +import java.util.List; + +/** + * APK utilities. + */ +public abstract class ApkUtils { + + /** + * Name of the Android manifest ZIP entry in APKs. + */ + public static final String ANDROID_MANIFEST_ZIP_ENTRY_NAME = "AndroidManifest.xml"; + + /** Name of the SourceStamp certificate hash ZIP entry in APKs. */ + public static final String SOURCE_STAMP_CERTIFICATE_HASH_ZIP_ENTRY_NAME = + SourceStampConstants.SOURCE_STAMP_CERTIFICATE_HASH_ZIP_ENTRY_NAME; + + private ApkUtils() {} + + /** + * Finds the main ZIP sections of the provided APK. + * + * @throws IOException if an I/O error occurred while reading the APK + * @throws ZipFormatException if the APK is malformed + */ + public static ZipSections findZipSections(DataSource apk) + throws IOException, ZipFormatException { + com.android.apksig.zip.ZipSections zipSections = ApkUtilsLite.findZipSections(apk); + return new ZipSections( + zipSections.getZipCentralDirectoryOffset(), + zipSections.getZipCentralDirectorySizeBytes(), + zipSections.getZipCentralDirectoryRecordCount(), + zipSections.getZipEndOfCentralDirectoryOffset(), + zipSections.getZipEndOfCentralDirectory()); + } + + /** + * Information about the ZIP sections of an APK. + */ + public static class ZipSections extends com.android.apksig.zip.ZipSections { + public ZipSections( + long centralDirectoryOffset, + long centralDirectorySizeBytes, + int centralDirectoryRecordCount, + long eocdOffset, + ByteBuffer eocd) { + super(centralDirectoryOffset, centralDirectorySizeBytes, centralDirectoryRecordCount, + eocdOffset, eocd); + } + } + + /** + * Sets the offset of the start of the ZIP Central Directory in the APK's ZIP End of Central + * Directory record. + * + * @param zipEndOfCentralDirectory APK's ZIP End of Central Directory record + * @param offset offset of the ZIP Central Directory relative to the start of the archive. Must + * be between {@code 0} and {@code 2^32 - 1} inclusive. + */ + public static void setZipEocdCentralDirectoryOffset( + ByteBuffer zipEndOfCentralDirectory, long offset) { + ByteBuffer eocd = zipEndOfCentralDirectory.slice(); + eocd.order(ByteOrder.LITTLE_ENDIAN); + ZipUtils.setZipEocdCentralDirectoryOffset(eocd, offset); + } + + /** + * Updates the length of EOCD comment. + * + * @param zipEndOfCentralDirectory APK's ZIP End of Central Directory record + */ + public static void updateZipEocdCommentLen(ByteBuffer zipEndOfCentralDirectory) { + ByteBuffer eocd = zipEndOfCentralDirectory.slice(); + eocd.order(ByteOrder.LITTLE_ENDIAN); + ZipUtils.updateZipEocdCommentLen(eocd); + } + + /** + * Returns the APK Signing Block of the provided {@code apk}. + * + * @throws ApkFormatException if the APK is not a valid ZIP archive + * @throws IOException if an I/O error occurs + * @throws ApkSigningBlockNotFoundException if there is no APK Signing Block in the APK + * + * @see <a href="https://source.android.com/security/apksigning/v2.html">APK Signature Scheme v2 + * </a> + */ + public static ApkSigningBlock findApkSigningBlock(DataSource apk) + throws ApkFormatException, IOException, ApkSigningBlockNotFoundException { + ApkUtils.ZipSections inputZipSections; + try { + inputZipSections = ApkUtils.findZipSections(apk); + } catch (ZipFormatException e) { + throw new ApkFormatException("Malformed APK: not a ZIP archive", e); + } + return findApkSigningBlock(apk, inputZipSections); + } + + /** + * Returns the APK Signing Block of the provided APK. + * + * @throws IOException if an I/O error occurs + * @throws ApkSigningBlockNotFoundException if there is no APK Signing Block in the APK + * + * @see <a href="https://source.android.com/security/apksigning/v2.html">APK Signature Scheme v2 + * </a> + */ + public static ApkSigningBlock findApkSigningBlock(DataSource apk, ZipSections zipSections) + throws IOException, ApkSigningBlockNotFoundException { + ApkUtilsLite.ApkSigningBlock apkSigningBlock = ApkUtilsLite.findApkSigningBlock(apk, + zipSections); + return new ApkSigningBlock(apkSigningBlock.getStartOffset(), apkSigningBlock.getContents()); + } + + /** + * Information about the location of the APK Signing Block inside an APK. + */ + public static class ApkSigningBlock extends ApkUtilsLite.ApkSigningBlock { + /** + * Constructs a new {@code ApkSigningBlock}. + * + * @param startOffsetInApk start offset (in bytes, relative to start of file) of the APK + * Signing Block inside the APK file + * @param contents contents of the APK Signing Block + */ + public ApkSigningBlock(long startOffsetInApk, DataSource contents) { + super(startOffsetInApk, contents); + } + } + + /** + * Returns the contents of the APK's {@code AndroidManifest.xml}. + * + * @throws IOException if an I/O error occurs while reading the APK + * @throws ApkFormatException if the APK is malformed + */ + public static ByteBuffer getAndroidManifest(DataSource apk) + throws IOException, ApkFormatException { + ZipSections zipSections; + try { + zipSections = findZipSections(apk); + } catch (ZipFormatException e) { + throw new ApkFormatException("Not a valid ZIP archive", e); + } + List<CentralDirectoryRecord> cdRecords = + V1SchemeVerifier.parseZipCentralDirectory(apk, zipSections); + CentralDirectoryRecord androidManifestCdRecord = null; + for (CentralDirectoryRecord cdRecord : cdRecords) { + if (ANDROID_MANIFEST_ZIP_ENTRY_NAME.equals(cdRecord.getName())) { + androidManifestCdRecord = cdRecord; + break; + } + } + if (androidManifestCdRecord == null) { + throw new ApkFormatException("Missing " + ANDROID_MANIFEST_ZIP_ENTRY_NAME); + } + DataSource lfhSection = apk.slice(0, zipSections.getZipCentralDirectoryOffset()); + + try { + return ByteBuffer.wrap( + LocalFileRecord.getUncompressedData( + lfhSection, androidManifestCdRecord, lfhSection.size())); + } catch (ZipFormatException e) { + throw new ApkFormatException("Failed to read " + ANDROID_MANIFEST_ZIP_ENTRY_NAME, e); + } + } + + /** + * Android resource ID of the {@code android:minSdkVersion} attribute in AndroidManifest.xml. + */ + private static final int MIN_SDK_VERSION_ATTR_ID = 0x0101020c; + + /** + * Android resource ID of the {@code android:debuggable} attribute in AndroidManifest.xml. + */ + private static final int DEBUGGABLE_ATTR_ID = 0x0101000f; + + /** + * Android resource ID of the {@code android:targetSandboxVersion} attribute in + * AndroidManifest.xml. + */ + private static final int TARGET_SANDBOX_VERSION_ATTR_ID = 0x0101054c; + + /** + * Android resource ID of the {@code android:targetSdkVersion} attribute in + * AndroidManifest.xml. + */ + private static final int TARGET_SDK_VERSION_ATTR_ID = 0x01010270; + private static final String USES_SDK_ELEMENT_TAG = "uses-sdk"; + + /** + * Android resource ID of the {@code android:versionCode} attribute in AndroidManifest.xml. + */ + private static final int VERSION_CODE_ATTR_ID = 0x0101021b; + private static final String MANIFEST_ELEMENT_TAG = "manifest"; + + /** + * Android resource ID of the {@code android:versionCodeMajor} attribute in AndroidManifest.xml. + */ + private static final int VERSION_CODE_MAJOR_ATTR_ID = 0x01010576; + + /** + * Returns the lowest Android platform version (API Level) supported by an APK with the + * provided {@code AndroidManifest.xml}. + * + * @param androidManifestContents contents of {@code AndroidManifest.xml} in binary Android + * resource format + * + * @throws MinSdkVersionException if an error occurred while determining the API Level + */ + public static int getMinSdkVersionFromBinaryAndroidManifest( + ByteBuffer androidManifestContents) throws MinSdkVersionException { + // IMPLEMENTATION NOTE: Minimum supported Android platform version number is declared using + // uses-sdk elements which are children of the top-level manifest element. uses-sdk element + // declares the minimum supported platform version using the android:minSdkVersion attribute + // whose default value is 1. + // For each encountered uses-sdk element, the Android runtime checks that its minSdkVersion + // is not higher than the runtime's API Level and rejects APKs if it is higher. Thus, the + // effective minSdkVersion value is the maximum over the encountered minSdkVersion values. + + try { + // If no uses-sdk elements are encountered, Android accepts the APK. We treat this + // scenario as though the minimum supported API Level is 1. + int result = 1; + + AndroidBinXmlParser parser = new AndroidBinXmlParser(androidManifestContents); + int eventType = parser.getEventType(); + while (eventType != AndroidBinXmlParser.EVENT_END_DOCUMENT) { + if ((eventType == AndroidBinXmlParser.EVENT_START_ELEMENT) + && (parser.getDepth() == 2) + && ("uses-sdk".equals(parser.getName())) + && (parser.getNamespace().isEmpty())) { + // In each uses-sdk element, minSdkVersion defaults to 1 + int minSdkVersion = 1; + for (int i = 0; i < parser.getAttributeCount(); i++) { + if (parser.getAttributeNameResourceId(i) == MIN_SDK_VERSION_ATTR_ID) { + int valueType = parser.getAttributeValueType(i); + switch (valueType) { + case AndroidBinXmlParser.VALUE_TYPE_INT: + minSdkVersion = parser.getAttributeIntValue(i); + break; + case AndroidBinXmlParser.VALUE_TYPE_STRING: + minSdkVersion = + getMinSdkVersionForCodename( + parser.getAttributeStringValue(i)); + break; + default: + throw new MinSdkVersionException( + "Unable to determine APK's minimum supported Android" + + ": unsupported value type in " + + ANDROID_MANIFEST_ZIP_ENTRY_NAME + "'s" + + " minSdkVersion" + + ". Only integer values supported."); + } + break; + } + } + result = Math.max(result, minSdkVersion); + } + eventType = parser.next(); + } + + return result; + } catch (AndroidBinXmlParser.XmlParserException e) { + throw new MinSdkVersionException( + "Unable to determine APK's minimum supported Android platform version" + + ": malformed binary resource: " + ANDROID_MANIFEST_ZIP_ENTRY_NAME, + e); + } + } + + private static class CodenamesLazyInitializer { + + /** + * List of platform codename (first letter of) to API Level mappings. The list must be + * sorted by the first letter. For codenames not in the list, the assumption is that the API + * Level is incremented by one for every increase in the codename's first letter. + */ + @SuppressWarnings({"rawtypes", "unchecked"}) + private static final Pair<Character, Integer>[] SORTED_CODENAMES_FIRST_CHAR_TO_API_LEVEL = + new Pair[] { + Pair.of('C', 2), + Pair.of('D', 3), + Pair.of('E', 4), + Pair.of('F', 7), + Pair.of('G', 8), + Pair.of('H', 10), + Pair.of('I', 13), + Pair.of('J', 15), + Pair.of('K', 18), + Pair.of('L', 20), + Pair.of('M', 22), + Pair.of('N', 23), + Pair.of('O', 25), + }; + + private static final Comparator<Pair<Character, Integer>> CODENAME_FIRST_CHAR_COMPARATOR = + new ByFirstComparator(); + + private static class ByFirstComparator implements Comparator<Pair<Character, Integer>> { + @Override + public int compare(Pair<Character, Integer> o1, Pair<Character, Integer> o2) { + char c1 = o1.getFirst(); + char c2 = o2.getFirst(); + return c1 - c2; + } + } + } + + /** + * Returns the API Level corresponding to the provided platform codename. + * + * <p>This method is pessimistic. It returns a value one lower than the API Level with which the + * platform is actually released (e.g., 23 for N which was released as API Level 24). This is + * because new features which first appear in an API Level are not available in the early days + * of that platform version's existence, when the platform only has a codename. Moreover, this + * method currently doesn't differentiate between initial and MR releases, meaning API Level + * returned for MR releases may be more than one lower than the API Level with which the + * platform version is actually released. + * + * @throws CodenameMinSdkVersionException if the {@code codename} is not supported + */ + static int getMinSdkVersionForCodename(String codename) throws CodenameMinSdkVersionException { + char firstChar = codename.isEmpty() ? ' ' : codename.charAt(0); + // Codenames are case-sensitive. Only codenames starting with A-Z are supported for now. + // We only look at the first letter of the codename as this is the most important letter. + if ((firstChar >= 'A') && (firstChar <= 'Z')) { + Pair<Character, Integer>[] sortedCodenamesFirstCharToApiLevel = + CodenamesLazyInitializer.SORTED_CODENAMES_FIRST_CHAR_TO_API_LEVEL; + int searchResult = + Arrays.binarySearch( + sortedCodenamesFirstCharToApiLevel, + Pair.of(firstChar, null), // second element of the pair is ignored here + CodenamesLazyInitializer.CODENAME_FIRST_CHAR_COMPARATOR); + if (searchResult >= 0) { + // Exact match -- searchResult is the index of the matching element + return sortedCodenamesFirstCharToApiLevel[searchResult].getSecond(); + } + // Not an exact match -- searchResult is negative and is -(insertion index) - 1. + // The element at insertionIndex - 1 (if present) is smaller than firstChar and the + // element at insertionIndex (if present) is greater than firstChar. + int insertionIndex = -1 - searchResult; // insertionIndex is in [0; array length] + if (insertionIndex == 0) { + // 'A' or 'B' -- never released to public + return 1; + } else { + // The element at insertionIndex - 1 is the newest older codename. + // API Level bumped by at least 1 for every change in the first letter of codename + Pair<Character, Integer> newestOlderCodenameMapping = + sortedCodenamesFirstCharToApiLevel[insertionIndex - 1]; + char newestOlderCodenameFirstChar = newestOlderCodenameMapping.getFirst(); + int newestOlderCodenameApiLevel = newestOlderCodenameMapping.getSecond(); + return newestOlderCodenameApiLevel + (firstChar - newestOlderCodenameFirstChar); + } + } + + throw new CodenameMinSdkVersionException( + "Unable to determine APK's minimum supported Android platform version" + + " : Unsupported codename in " + ANDROID_MANIFEST_ZIP_ENTRY_NAME + + "'s minSdkVersion: \"" + codename + "\"", + codename); + } + + /** + * Returns {@code true} if the APK is debuggable according to its {@code AndroidManifest.xml}. + * See the {@code android:debuggable} attribute of the {@code application} element. + * + * @param androidManifestContents contents of {@code AndroidManifest.xml} in binary Android + * resource format + * + * @throws ApkFormatException if the manifest is malformed + */ + public static boolean getDebuggableFromBinaryAndroidManifest( + ByteBuffer androidManifestContents) throws ApkFormatException { + // IMPLEMENTATION NOTE: Whether the package is debuggable is declared using the first + // "application" element which is a child of the top-level manifest element. The debuggable + // attribute of this application element is coerced to a boolean value. If there is no + // application element or if it doesn't declare the debuggable attribute, the package is + // considered not debuggable. + + try { + AndroidBinXmlParser parser = new AndroidBinXmlParser(androidManifestContents); + int eventType = parser.getEventType(); + while (eventType != AndroidBinXmlParser.EVENT_END_DOCUMENT) { + if ((eventType == AndroidBinXmlParser.EVENT_START_ELEMENT) + && (parser.getDepth() == 2) + && ("application".equals(parser.getName())) + && (parser.getNamespace().isEmpty())) { + for (int i = 0; i < parser.getAttributeCount(); i++) { + if (parser.getAttributeNameResourceId(i) == DEBUGGABLE_ATTR_ID) { + int valueType = parser.getAttributeValueType(i); + switch (valueType) { + case AndroidBinXmlParser.VALUE_TYPE_BOOLEAN: + case AndroidBinXmlParser.VALUE_TYPE_STRING: + case AndroidBinXmlParser.VALUE_TYPE_INT: + String value = parser.getAttributeStringValue(i); + return ("true".equals(value)) + || ("TRUE".equals(value)) + || ("1".equals(value)); + case AndroidBinXmlParser.VALUE_TYPE_REFERENCE: + // References to resources are not supported on purpose. The + // reason is that the resolved value depends on the resource + // configuration (e.g, MNC/MCC, locale, screen density) used + // at resolution time. As a result, the same APK may appear as + // debuggable in one situation and as non-debuggable in another + // situation. Such APKs may put users at risk. + throw new ApkFormatException( + "Unable to determine whether APK is debuggable" + + ": " + ANDROID_MANIFEST_ZIP_ENTRY_NAME + "'s" + + " android:debuggable attribute references a" + + " resource. References are not supported for" + + " security reasons. Only constant boolean," + + " string and int values are supported."); + default: + throw new ApkFormatException( + "Unable to determine whether APK is debuggable" + + ": " + ANDROID_MANIFEST_ZIP_ENTRY_NAME + "'s" + + " android:debuggable attribute uses" + + " unsupported value type. Only boolean," + + " string and int values are supported."); + } + } + } + // This application element does not declare the debuggable attribute + return false; + } + eventType = parser.next(); + } + + // No application element found + return false; + } catch (AndroidBinXmlParser.XmlParserException e) { + throw new ApkFormatException( + "Unable to determine whether APK is debuggable: malformed binary resource: " + + ANDROID_MANIFEST_ZIP_ENTRY_NAME, + e); + } + } + + /** + * Returns the package name of the APK according to its {@code AndroidManifest.xml} or + * {@code null} if package name is not declared. See the {@code package} attribute of the + * {@code manifest} element. + * + * @param androidManifestContents contents of {@code AndroidManifest.xml} in binary Android + * resource format + * + * @throws ApkFormatException if the manifest is malformed + */ + public static String getPackageNameFromBinaryAndroidManifest( + ByteBuffer androidManifestContents) throws ApkFormatException { + // IMPLEMENTATION NOTE: Package name is declared as the "package" attribute of the top-level + // manifest element. Interestingly, as opposed to most other attributes, Android Package + // Manager looks up this attribute by its name rather than by its resource ID. + + try { + AndroidBinXmlParser parser = new AndroidBinXmlParser(androidManifestContents); + int eventType = parser.getEventType(); + while (eventType != AndroidBinXmlParser.EVENT_END_DOCUMENT) { + if ((eventType == AndroidBinXmlParser.EVENT_START_ELEMENT) + && (parser.getDepth() == 1) + && ("manifest".equals(parser.getName())) + && (parser.getNamespace().isEmpty())) { + for (int i = 0; i < parser.getAttributeCount(); i++) { + if ("package".equals(parser.getAttributeName(i)) + && (parser.getNamespace().isEmpty())) { + return parser.getAttributeStringValue(i); + } + } + // No "package" attribute found + return null; + } + eventType = parser.next(); + } + + // No manifest element found + return null; + } catch (AndroidBinXmlParser.XmlParserException e) { + throw new ApkFormatException( + "Unable to determine APK package name: malformed binary resource: " + + ANDROID_MANIFEST_ZIP_ENTRY_NAME, + e); + } + } + + /** + * Returns the security sandbox version targeted by an APK with the provided + * {@code AndroidManifest.xml}. + * + * <p>If the security sandbox version is not specified in the manifest a default value of 1 is + * returned. + * + * @param androidManifestContents contents of {@code AndroidManifest.xml} in binary Android + * resource format + */ + public static int getTargetSandboxVersionFromBinaryAndroidManifest( + ByteBuffer androidManifestContents) { + try { + return getAttributeValueFromBinaryAndroidManifest(androidManifestContents, + MANIFEST_ELEMENT_TAG, TARGET_SANDBOX_VERSION_ATTR_ID); + } catch (ApkFormatException e) { + // An ApkFormatException indicates the target sandbox is not specified in the manifest; + // return a default value of 1. + return 1; + } + } + + /** + * Returns the SDK version targeted by an APK with the provided {@code AndroidManifest.xml}. + * + * <p>If the targetSdkVersion is not specified the minimumSdkVersion is returned. If neither + * value is specified then a value of 1 is returned. + * + * @param androidManifestContents contents of {@code AndroidManifest.xml} in binary Android + * resource format + */ + public static int getTargetSdkVersionFromBinaryAndroidManifest( + ByteBuffer androidManifestContents) { + // If the targetSdkVersion is not specified then the platform will use the value of the + // minSdkVersion; if neither is specified then the platform will use a value of 1. + int minSdkVersion = 1; + try { + return getAttributeValueFromBinaryAndroidManifest(androidManifestContents, + USES_SDK_ELEMENT_TAG, TARGET_SDK_VERSION_ATTR_ID); + } catch (ApkFormatException e) { + // Expected if the APK does not contain a targetSdkVersion attribute or the uses-sdk + // element is not specified at all. + } + androidManifestContents.rewind(); + try { + minSdkVersion = getMinSdkVersionFromBinaryAndroidManifest(androidManifestContents); + } catch (ApkFormatException e) { + // Similar to above, expected if the APK does not contain a minSdkVersion attribute, or + // the uses-sdk element is not specified at all. + } + return minSdkVersion; + } + + /** + * Returns the versionCode of the APK according to its {@code AndroidManifest.xml}. + * + * <p>If the versionCode is not specified in the {@code AndroidManifest.xml} or is not a valid + * integer an ApkFormatException is thrown. + * + * @param androidManifestContents contents of {@code AndroidManifest.xml} in binary Android + * resource format + * @throws ApkFormatException if an error occurred while determining the versionCode, or if the + * versionCode attribute value is not available. + */ + public static int getVersionCodeFromBinaryAndroidManifest(ByteBuffer androidManifestContents) + throws ApkFormatException { + return getAttributeValueFromBinaryAndroidManifest(androidManifestContents, + MANIFEST_ELEMENT_TAG, VERSION_CODE_ATTR_ID); + } + + /** + * Returns the versionCode and versionCodeMajor of the APK according to its {@code + * AndroidManifest.xml} combined together as a single long value. + * + * <p>The versionCodeMajor is placed in the upper 32 bits, and the versionCode is in the lower + * 32 bits. If the versionCodeMajor is not specified then the versionCode is returned. + * + * @param androidManifestContents contents of {@code AndroidManifest.xml} in binary Android + * resource format + * @throws ApkFormatException if an error occurred while determining the version, or if the + * versionCode attribute value is not available. + */ + public static long getLongVersionCodeFromBinaryAndroidManifest( + ByteBuffer androidManifestContents) throws ApkFormatException { + // If the versionCode is not found then allow the ApkFormatException to be thrown to notify + // the caller that the versionCode is not available. + int versionCode = getVersionCodeFromBinaryAndroidManifest(androidManifestContents); + long versionCodeMajor = 0; + try { + androidManifestContents.rewind(); + versionCodeMajor = getAttributeValueFromBinaryAndroidManifest(androidManifestContents, + MANIFEST_ELEMENT_TAG, VERSION_CODE_MAJOR_ATTR_ID); + } catch (ApkFormatException e) { + // This is expected if the versionCodeMajor has not been defined for the APK; in this + // case the return value is just the versionCode. + } + return (versionCodeMajor << 32) | versionCode; + } + + /** + * Returns the integer value of the requested {@code attributeId} in the specified {@code + * elementName} from the provided {@code androidManifestContents} in binary Android resource + * format. + * + * @throws ApkFormatException if an error occurred while attempting to obtain the attribute, or + * if the requested attribute is not found. + */ + private static int getAttributeValueFromBinaryAndroidManifest( + ByteBuffer androidManifestContents, String elementName, int attributeId) + throws ApkFormatException { + if (elementName == null) { + throw new NullPointerException("elementName cannot be null"); + } + try { + AndroidBinXmlParser parser = new AndroidBinXmlParser(androidManifestContents); + int eventType = parser.getEventType(); + while (eventType != AndroidBinXmlParser.EVENT_END_DOCUMENT) { + if ((eventType == AndroidBinXmlParser.EVENT_START_ELEMENT) + && (elementName.equals(parser.getName()))) { + for (int i = 0; i < parser.getAttributeCount(); i++) { + if (parser.getAttributeNameResourceId(i) == attributeId) { + int valueType = parser.getAttributeValueType(i); + switch (valueType) { + case AndroidBinXmlParser.VALUE_TYPE_INT: + case AndroidBinXmlParser.VALUE_TYPE_STRING: + return parser.getAttributeIntValue(i); + default: + throw new ApkFormatException( + "Unsupported value type, " + valueType + + ", for attribute " + String.format("0x%08X", + attributeId) + " under element " + elementName); + + } + } + } + } + eventType = parser.next(); + } + throw new ApkFormatException( + "Failed to determine APK's " + elementName + " attribute " + + String.format("0x%08X", attributeId) + " value"); + } catch (AndroidBinXmlParser.XmlParserException e) { + throw new ApkFormatException( + "Unable to determine value for attribute " + String.format("0x%08X", + attributeId) + " under element " + elementName + + "; malformed binary resource: " + ANDROID_MANIFEST_ZIP_ENTRY_NAME, e); + } + } + + public static byte[] computeSha256DigestBytes(byte[] data) { + return ApkUtilsLite.computeSha256DigestBytes(data); + } +} diff --git a/platform/android/java/editor/src/main/java/com/android/apksig/apk/ApkUtilsLite.java b/platform/android/java/editor/src/main/java/com/android/apksig/apk/ApkUtilsLite.java new file mode 100644 index 0000000000..13f230119d --- /dev/null +++ b/platform/android/java/editor/src/main/java/com/android/apksig/apk/ApkUtilsLite.java @@ -0,0 +1,199 @@ +/* + * Copyright (C) 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.apksig.apk; + +import com.android.apksig.internal.util.Pair; +import com.android.apksig.internal.zip.ZipUtils; +import com.android.apksig.util.DataSource; +import com.android.apksig.zip.ZipFormatException; +import com.android.apksig.zip.ZipSections; + +import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; + +/** + * Lightweight version of the ApkUtils for clients that only require a subset of the utility + * functionality. + */ +public class ApkUtilsLite { + private ApkUtilsLite() {} + + /** + * Finds the main ZIP sections of the provided APK. + * + * @throws IOException if an I/O error occurred while reading the APK + * @throws ZipFormatException if the APK is malformed + */ + public static ZipSections findZipSections(DataSource apk) + throws IOException, ZipFormatException { + Pair<ByteBuffer, Long> eocdAndOffsetInFile = + ZipUtils.findZipEndOfCentralDirectoryRecord(apk); + if (eocdAndOffsetInFile == null) { + throw new ZipFormatException("ZIP End of Central Directory record not found"); + } + + ByteBuffer eocdBuf = eocdAndOffsetInFile.getFirst(); + long eocdOffset = eocdAndOffsetInFile.getSecond(); + eocdBuf.order(ByteOrder.LITTLE_ENDIAN); + long cdStartOffset = ZipUtils.getZipEocdCentralDirectoryOffset(eocdBuf); + if (cdStartOffset > eocdOffset) { + throw new ZipFormatException( + "ZIP Central Directory start offset out of range: " + cdStartOffset + + ". ZIP End of Central Directory offset: " + eocdOffset); + } + + long cdSizeBytes = ZipUtils.getZipEocdCentralDirectorySizeBytes(eocdBuf); + long cdEndOffset = cdStartOffset + cdSizeBytes; + if (cdEndOffset > eocdOffset) { + throw new ZipFormatException( + "ZIP Central Directory overlaps with End of Central Directory" + + ". CD end: " + cdEndOffset + + ", EoCD start: " + eocdOffset); + } + + int cdRecordCount = ZipUtils.getZipEocdCentralDirectoryTotalRecordCount(eocdBuf); + + return new ZipSections( + cdStartOffset, + cdSizeBytes, + cdRecordCount, + eocdOffset, + eocdBuf); + } + + // See https://source.android.com/security/apksigning/v2.html + private static final long APK_SIG_BLOCK_MAGIC_HI = 0x3234206b636f6c42L; + private static final long APK_SIG_BLOCK_MAGIC_LO = 0x20676953204b5041L; + private static final int APK_SIG_BLOCK_MIN_SIZE = 32; + + /** + * Returns the APK Signing Block of the provided APK. + * + * @throws IOException if an I/O error occurs + * @throws ApkSigningBlockNotFoundException if there is no APK Signing Block in the APK + * + * @see <a href="https://source.android.com/security/apksigning/v2.html">APK Signature Scheme v2 + * </a> + */ + public static ApkSigningBlock findApkSigningBlock(DataSource apk, ZipSections zipSections) + throws IOException, ApkSigningBlockNotFoundException { + // FORMAT (see https://source.android.com/security/apksigning/v2.html): + // OFFSET DATA TYPE DESCRIPTION + // * @+0 bytes uint64: size in bytes (excluding this field) + // * @+8 bytes payload + // * @-24 bytes uint64: size in bytes (same as the one above) + // * @-16 bytes uint128: magic + + long centralDirStartOffset = zipSections.getZipCentralDirectoryOffset(); + long centralDirEndOffset = + centralDirStartOffset + zipSections.getZipCentralDirectorySizeBytes(); + long eocdStartOffset = zipSections.getZipEndOfCentralDirectoryOffset(); + if (centralDirEndOffset != eocdStartOffset) { + throw new ApkSigningBlockNotFoundException( + "ZIP Central Directory is not immediately followed by End of Central Directory" + + ". CD end: " + centralDirEndOffset + + ", EoCD start: " + eocdStartOffset); + } + + if (centralDirStartOffset < APK_SIG_BLOCK_MIN_SIZE) { + throw new ApkSigningBlockNotFoundException( + "APK too small for APK Signing Block. ZIP Central Directory offset: " + + centralDirStartOffset); + } + // Read the magic and offset in file from the footer section of the block: + // * uint64: size of block + // * 16 bytes: magic + ByteBuffer footer = apk.getByteBuffer(centralDirStartOffset - 24, 24); + footer.order(ByteOrder.LITTLE_ENDIAN); + if ((footer.getLong(8) != APK_SIG_BLOCK_MAGIC_LO) + || (footer.getLong(16) != APK_SIG_BLOCK_MAGIC_HI)) { + throw new ApkSigningBlockNotFoundException( + "No APK Signing Block before ZIP Central Directory"); + } + // Read and compare size fields + long apkSigBlockSizeInFooter = footer.getLong(0); + if ((apkSigBlockSizeInFooter < footer.capacity()) + || (apkSigBlockSizeInFooter > Integer.MAX_VALUE - 8)) { + throw new ApkSigningBlockNotFoundException( + "APK Signing Block size out of range: " + apkSigBlockSizeInFooter); + } + int totalSize = (int) (apkSigBlockSizeInFooter + 8); + long apkSigBlockOffset = centralDirStartOffset - totalSize; + if (apkSigBlockOffset < 0) { + throw new ApkSigningBlockNotFoundException( + "APK Signing Block offset out of range: " + apkSigBlockOffset); + } + ByteBuffer apkSigBlock = apk.getByteBuffer(apkSigBlockOffset, 8); + apkSigBlock.order(ByteOrder.LITTLE_ENDIAN); + long apkSigBlockSizeInHeader = apkSigBlock.getLong(0); + if (apkSigBlockSizeInHeader != apkSigBlockSizeInFooter) { + throw new ApkSigningBlockNotFoundException( + "APK Signing Block sizes in header and footer do not match: " + + apkSigBlockSizeInHeader + " vs " + apkSigBlockSizeInFooter); + } + return new ApkSigningBlock(apkSigBlockOffset, apk.slice(apkSigBlockOffset, totalSize)); + } + + /** + * Information about the location of the APK Signing Block inside an APK. + */ + public static class ApkSigningBlock { + private final long mStartOffsetInApk; + private final DataSource mContents; + + /** + * Constructs a new {@code ApkSigningBlock}. + * + * @param startOffsetInApk start offset (in bytes, relative to start of file) of the APK + * Signing Block inside the APK file + * @param contents contents of the APK Signing Block + */ + public ApkSigningBlock(long startOffsetInApk, DataSource contents) { + mStartOffsetInApk = startOffsetInApk; + mContents = contents; + } + + /** + * Returns the start offset (in bytes, relative to start of file) of the APK Signing Block. + */ + public long getStartOffset() { + return mStartOffsetInApk; + } + + /** + * Returns the data source which provides the full contents of the APK Signing Block, + * including its footer. + */ + public DataSource getContents() { + return mContents; + } + } + + public static byte[] computeSha256DigestBytes(byte[] data) { + MessageDigest messageDigest; + try { + messageDigest = MessageDigest.getInstance("SHA-256"); + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException("SHA-256 is not found", e); + } + messageDigest.update(data); + return messageDigest.digest(); + } +} diff --git a/platform/android/java/editor/src/main/java/com/android/apksig/apk/CodenameMinSdkVersionException.java b/platform/android/java/editor/src/main/java/com/android/apksig/apk/CodenameMinSdkVersionException.java new file mode 100644 index 0000000000..e30bc359a6 --- /dev/null +++ b/platform/android/java/editor/src/main/java/com/android/apksig/apk/CodenameMinSdkVersionException.java @@ -0,0 +1,46 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.apksig.apk; + +/** + * Indicates that there was an issue determining the minimum Android platform version supported by + * an APK because the version is specified as a codename, rather than as API Level number, and the + * codename is in an unexpected format. + */ +public class CodenameMinSdkVersionException extends MinSdkVersionException { + + private static final long serialVersionUID = 1L; + + /** Encountered codename. */ + private final String mCodename; + + /** + * Constructs a new {@code MinSdkVersionCodenameException} with the provided message and + * codename. + */ + public CodenameMinSdkVersionException(String message, String codename) { + super(message); + mCodename = codename; + } + + /** + * Returns the codename. + */ + public String getCodename() { + return mCodename; + } +} diff --git a/platform/android/java/editor/src/main/java/com/android/apksig/apk/MinSdkVersionException.java b/platform/android/java/editor/src/main/java/com/android/apksig/apk/MinSdkVersionException.java new file mode 100644 index 0000000000..c4aad08067 --- /dev/null +++ b/platform/android/java/editor/src/main/java/com/android/apksig/apk/MinSdkVersionException.java @@ -0,0 +1,40 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.apksig.apk; + +/** + * Indicates that there was an issue determining the minimum Android platform version supported by + * an APK. + */ +public class MinSdkVersionException extends ApkFormatException { + + private static final long serialVersionUID = 1L; + + /** + * Constructs a new {@code MinSdkVersionException} with the provided message. + */ + public MinSdkVersionException(String message) { + super(message); + } + + /** + * Constructs a new {@code MinSdkVersionException} with the provided message and cause. + */ + public MinSdkVersionException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/platform/android/java/editor/src/main/java/com/android/apksig/internal/apk/AndroidBinXmlParser.java b/platform/android/java/editor/src/main/java/com/android/apksig/internal/apk/AndroidBinXmlParser.java new file mode 100644 index 0000000000..bc5a45738b --- /dev/null +++ b/platform/android/java/editor/src/main/java/com/android/apksig/internal/apk/AndroidBinXmlParser.java @@ -0,0 +1,869 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.apksig.internal.apk; + +import java.io.UnsupportedEncodingException; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * XML pull style parser of Android binary XML resources, such as {@code AndroidManifest.xml}. + * + * <p>For an input document, the parser outputs an event stream (see {@code EVENT_... constants} via + * {@link #getEventType()} and {@link #next()} methods. Additional information about the current + * event can be obtained via an assortment of getters, for example, {@link #getName()} or + * {@link #getAttributeNameResourceId(int)}. + */ +public class AndroidBinXmlParser { + + /** Event: start of document. */ + public static final int EVENT_START_DOCUMENT = 1; + + /** Event: end of document. */ + public static final int EVENT_END_DOCUMENT = 2; + + /** Event: start of an element. */ + public static final int EVENT_START_ELEMENT = 3; + + /** Event: end of an document. */ + public static final int EVENT_END_ELEMENT = 4; + + /** Attribute value type is not supported by this parser. */ + public static final int VALUE_TYPE_UNSUPPORTED = 0; + + /** Attribute value is a string. Use {@link #getAttributeStringValue(int)} to obtain it. */ + public static final int VALUE_TYPE_STRING = 1; + + /** Attribute value is an integer. Use {@link #getAttributeIntValue(int)} to obtain it. */ + public static final int VALUE_TYPE_INT = 2; + + /** + * Attribute value is a resource reference. Use {@link #getAttributeIntValue(int)} to obtain it. + */ + public static final int VALUE_TYPE_REFERENCE = 3; + + /** Attribute value is a boolean. Use {@link #getAttributeBooleanValue(int)} to obtain it. */ + public static final int VALUE_TYPE_BOOLEAN = 4; + + private static final long NO_NAMESPACE = 0xffffffffL; + + private final ByteBuffer mXml; + + private StringPool mStringPool; + private ResourceMap mResourceMap; + private int mDepth; + private int mCurrentEvent = EVENT_START_DOCUMENT; + + private String mCurrentElementName; + private String mCurrentElementNamespace; + private int mCurrentElementAttributeCount; + private List<Attribute> mCurrentElementAttributes; + private ByteBuffer mCurrentElementAttributesContents; + private int mCurrentElementAttrSizeBytes; + + /** + * Constructs a new parser for the provided document. + */ + public AndroidBinXmlParser(ByteBuffer xml) throws XmlParserException { + xml.order(ByteOrder.LITTLE_ENDIAN); + + Chunk resXmlChunk = null; + while (xml.hasRemaining()) { + Chunk chunk = Chunk.get(xml); + if (chunk == null) { + break; + } + if (chunk.getType() == Chunk.TYPE_RES_XML) { + resXmlChunk = chunk; + break; + } + } + + if (resXmlChunk == null) { + throw new XmlParserException("No XML chunk in file"); + } + mXml = resXmlChunk.getContents(); + } + + /** + * Returns the depth of the current element. Outside of the root of the document the depth is + * {@code 0}. The depth is incremented by {@code 1} before each {@code start element} event and + * is decremented by {@code 1} after each {@code end element} event. + */ + public int getDepth() { + return mDepth; + } + + /** + * Returns the type of the current event. See {@code EVENT_...} constants. + */ + public int getEventType() { + return mCurrentEvent; + } + + /** + * Returns the local name of the current element or {@code null} if the current event does not + * pertain to an element. + */ + public String getName() { + if ((mCurrentEvent != EVENT_START_ELEMENT) && (mCurrentEvent != EVENT_END_ELEMENT)) { + return null; + } + return mCurrentElementName; + } + + /** + * Returns the namespace of the current element or {@code null} if the current event does not + * pertain to an element. Returns an empty string if the element is not associated with a + * namespace. + */ + public String getNamespace() { + if ((mCurrentEvent != EVENT_START_ELEMENT) && (mCurrentEvent != EVENT_END_ELEMENT)) { + return null; + } + return mCurrentElementNamespace; + } + + /** + * Returns the number of attributes of the element associated with the current event or + * {@code -1} if no element is associated with the current event. + */ + public int getAttributeCount() { + if (mCurrentEvent != EVENT_START_ELEMENT) { + return -1; + } + + return mCurrentElementAttributeCount; + } + + /** + * Returns the resource ID corresponding to the name of the specified attribute of the current + * element or {@code 0} if the name is not associated with a resource ID. + * + * @throws IndexOutOfBoundsException if the index is out of range or the current event is not a + * {@code start element} event + * @throws XmlParserException if a parsing error is occurred + */ + public int getAttributeNameResourceId(int index) throws XmlParserException { + return getAttribute(index).getNameResourceId(); + } + + /** + * Returns the name of the specified attribute of the current element. + * + * @throws IndexOutOfBoundsException if the index is out of range or the current event is not a + * {@code start element} event + * @throws XmlParserException if a parsing error is occurred + */ + public String getAttributeName(int index) throws XmlParserException { + return getAttribute(index).getName(); + } + + /** + * Returns the name of the specified attribute of the current element or an empty string if + * the attribute is not associated with a namespace. + * + * @throws IndexOutOfBoundsException if the index is out of range or the current event is not a + * {@code start element} event + * @throws XmlParserException if a parsing error is occurred + */ + public String getAttributeNamespace(int index) throws XmlParserException { + return getAttribute(index).getNamespace(); + } + + /** + * Returns the value type of the specified attribute of the current element. See + * {@code VALUE_TYPE_...} constants. + * + * @throws IndexOutOfBoundsException if the index is out of range or the current event is not a + * {@code start element} event + * @throws XmlParserException if a parsing error is occurred + */ + public int getAttributeValueType(int index) throws XmlParserException { + int type = getAttribute(index).getValueType(); + switch (type) { + case Attribute.TYPE_STRING: + return VALUE_TYPE_STRING; + case Attribute.TYPE_INT_DEC: + case Attribute.TYPE_INT_HEX: + return VALUE_TYPE_INT; + case Attribute.TYPE_REFERENCE: + return VALUE_TYPE_REFERENCE; + case Attribute.TYPE_INT_BOOLEAN: + return VALUE_TYPE_BOOLEAN; + default: + return VALUE_TYPE_UNSUPPORTED; + } + } + + /** + * Returns the integer value of the specified attribute of the current element. See + * {@code VALUE_TYPE_...} constants. + * + * @throws IndexOutOfBoundsException if the index is out of range or the current event is not a + * {@code start element} event. + * @throws XmlParserException if a parsing error is occurred + */ + public int getAttributeIntValue(int index) throws XmlParserException { + return getAttribute(index).getIntValue(); + } + + /** + * Returns the boolean value of the specified attribute of the current element. See + * {@code VALUE_TYPE_...} constants. + * + * @throws IndexOutOfBoundsException if the index is out of range or the current event is not a + * {@code start element} event. + * @throws XmlParserException if a parsing error is occurred + */ + public boolean getAttributeBooleanValue(int index) throws XmlParserException { + return getAttribute(index).getBooleanValue(); + } + + /** + * Returns the string value of the specified attribute of the current element. See + * {@code VALUE_TYPE_...} constants. + * + * @throws IndexOutOfBoundsException if the index is out of range or the current event is not a + * {@code start element} event. + * @throws XmlParserException if a parsing error is occurred + */ + public String getAttributeStringValue(int index) throws XmlParserException { + return getAttribute(index).getStringValue(); + } + + private Attribute getAttribute(int index) { + if (mCurrentEvent != EVENT_START_ELEMENT) { + throw new IndexOutOfBoundsException("Current event not a START_ELEMENT"); + } + if (index < 0) { + throw new IndexOutOfBoundsException("index must be >= 0"); + } + if (index >= mCurrentElementAttributeCount) { + throw new IndexOutOfBoundsException( + "index must be <= attr count (" + mCurrentElementAttributeCount + ")"); + } + parseCurrentElementAttributesIfNotParsed(); + return mCurrentElementAttributes.get(index); + } + + /** + * Advances to the next parsing event and returns its type. See {@code EVENT_...} constants. + */ + public int next() throws XmlParserException { + // Decrement depth if the previous event was "end element". + if (mCurrentEvent == EVENT_END_ELEMENT) { + mDepth--; + } + + // Read events from document, ignoring events that we don't report to caller. Stop at the + // earliest event which we report to caller. + while (mXml.hasRemaining()) { + Chunk chunk = Chunk.get(mXml); + if (chunk == null) { + break; + } + switch (chunk.getType()) { + case Chunk.TYPE_STRING_POOL: + if (mStringPool != null) { + throw new XmlParserException("Multiple string pools not supported"); + } + mStringPool = new StringPool(chunk); + break; + + case Chunk.RES_XML_TYPE_START_ELEMENT: + { + if (mStringPool == null) { + throw new XmlParserException( + "Named element encountered before string pool"); + } + ByteBuffer contents = chunk.getContents(); + if (contents.remaining() < 20) { + throw new XmlParserException( + "Start element chunk too short. Need at least 20 bytes. Available: " + + contents.remaining() + " bytes"); + } + long nsId = getUnsignedInt32(contents); + long nameId = getUnsignedInt32(contents); + int attrStartOffset = getUnsignedInt16(contents); + int attrSizeBytes = getUnsignedInt16(contents); + int attrCount = getUnsignedInt16(contents); + long attrEndOffset = attrStartOffset + ((long) attrCount) * attrSizeBytes; + contents.position(0); + if (attrStartOffset > contents.remaining()) { + throw new XmlParserException( + "Attributes start offset out of bounds: " + attrStartOffset + + ", max: " + contents.remaining()); + } + if (attrEndOffset > contents.remaining()) { + throw new XmlParserException( + "Attributes end offset out of bounds: " + attrEndOffset + + ", max: " + contents.remaining()); + } + + mCurrentElementName = mStringPool.getString(nameId); + mCurrentElementNamespace = + (nsId == NO_NAMESPACE) ? "" : mStringPool.getString(nsId); + mCurrentElementAttributeCount = attrCount; + mCurrentElementAttributes = null; + mCurrentElementAttrSizeBytes = attrSizeBytes; + mCurrentElementAttributesContents = + sliceFromTo(contents, attrStartOffset, attrEndOffset); + + mDepth++; + mCurrentEvent = EVENT_START_ELEMENT; + return mCurrentEvent; + } + + case Chunk.RES_XML_TYPE_END_ELEMENT: + { + if (mStringPool == null) { + throw new XmlParserException( + "Named element encountered before string pool"); + } + ByteBuffer contents = chunk.getContents(); + if (contents.remaining() < 8) { + throw new XmlParserException( + "End element chunk too short. Need at least 8 bytes. Available: " + + contents.remaining() + " bytes"); + } + long nsId = getUnsignedInt32(contents); + long nameId = getUnsignedInt32(contents); + mCurrentElementName = mStringPool.getString(nameId); + mCurrentElementNamespace = + (nsId == NO_NAMESPACE) ? "" : mStringPool.getString(nsId); + mCurrentEvent = EVENT_END_ELEMENT; + mCurrentElementAttributes = null; + mCurrentElementAttributesContents = null; + return mCurrentEvent; + } + case Chunk.RES_XML_TYPE_RESOURCE_MAP: + if (mResourceMap != null) { + throw new XmlParserException("Multiple resource maps not supported"); + } + mResourceMap = new ResourceMap(chunk); + break; + default: + // Unknown chunk type -- ignore + break; + } + } + + mCurrentEvent = EVENT_END_DOCUMENT; + return mCurrentEvent; + } + + private void parseCurrentElementAttributesIfNotParsed() { + if (mCurrentElementAttributes != null) { + return; + } + mCurrentElementAttributes = new ArrayList<>(mCurrentElementAttributeCount); + for (int i = 0; i < mCurrentElementAttributeCount; i++) { + int startPosition = i * mCurrentElementAttrSizeBytes; + ByteBuffer attr = + sliceFromTo( + mCurrentElementAttributesContents, + startPosition, + startPosition + mCurrentElementAttrSizeBytes); + long nsId = getUnsignedInt32(attr); + long nameId = getUnsignedInt32(attr); + attr.position(attr.position() + 7); // skip ignored fields + int valueType = getUnsignedInt8(attr); + long valueData = getUnsignedInt32(attr); + mCurrentElementAttributes.add( + new Attribute( + nsId, + nameId, + valueType, + (int) valueData, + mStringPool, + mResourceMap)); + } + } + + private static class Attribute { + private static final int TYPE_REFERENCE = 1; + private static final int TYPE_STRING = 3; + private static final int TYPE_INT_DEC = 0x10; + private static final int TYPE_INT_HEX = 0x11; + private static final int TYPE_INT_BOOLEAN = 0x12; + + private final long mNsId; + private final long mNameId; + private final int mValueType; + private final int mValueData; + private final StringPool mStringPool; + private final ResourceMap mResourceMap; + + private Attribute( + long nsId, + long nameId, + int valueType, + int valueData, + StringPool stringPool, + ResourceMap resourceMap) { + mNsId = nsId; + mNameId = nameId; + mValueType = valueType; + mValueData = valueData; + mStringPool = stringPool; + mResourceMap = resourceMap; + } + + public int getNameResourceId() { + return (mResourceMap != null) ? mResourceMap.getResourceId(mNameId) : 0; + } + + public String getName() throws XmlParserException { + return mStringPool.getString(mNameId); + } + + public String getNamespace() throws XmlParserException { + return (mNsId != NO_NAMESPACE) ? mStringPool.getString(mNsId) : ""; + } + + public int getValueType() { + return mValueType; + } + + public int getIntValue() throws XmlParserException { + switch (mValueType) { + case TYPE_REFERENCE: + case TYPE_INT_DEC: + case TYPE_INT_HEX: + case TYPE_INT_BOOLEAN: + return mValueData; + default: + throw new XmlParserException("Cannot coerce to int: value type " + mValueType); + } + } + + public boolean getBooleanValue() throws XmlParserException { + switch (mValueType) { + case TYPE_INT_BOOLEAN: + return mValueData != 0; + default: + throw new XmlParserException( + "Cannot coerce to boolean: value type " + mValueType); + } + } + + public String getStringValue() throws XmlParserException { + switch (mValueType) { + case TYPE_STRING: + return mStringPool.getString(mValueData & 0xffffffffL); + case TYPE_INT_DEC: + return Integer.toString(mValueData); + case TYPE_INT_HEX: + return "0x" + Integer.toHexString(mValueData); + case TYPE_INT_BOOLEAN: + return Boolean.toString(mValueData != 0); + case TYPE_REFERENCE: + return "@" + Integer.toHexString(mValueData); + default: + throw new XmlParserException( + "Cannot coerce to string: value type " + mValueType); + } + } + } + + /** + * Chunk of a document. Each chunk is tagged with a type and consists of a header followed by + * contents. + */ + private static class Chunk { + public static final int TYPE_STRING_POOL = 1; + public static final int TYPE_RES_XML = 3; + public static final int RES_XML_TYPE_START_ELEMENT = 0x0102; + public static final int RES_XML_TYPE_END_ELEMENT = 0x0103; + public static final int RES_XML_TYPE_RESOURCE_MAP = 0x0180; + + static final int HEADER_MIN_SIZE_BYTES = 8; + + private final int mType; + private final ByteBuffer mHeader; + private final ByteBuffer mContents; + + public Chunk(int type, ByteBuffer header, ByteBuffer contents) { + mType = type; + mHeader = header; + mContents = contents; + } + + public ByteBuffer getContents() { + ByteBuffer result = mContents.slice(); + result.order(mContents.order()); + return result; + } + + public ByteBuffer getHeader() { + ByteBuffer result = mHeader.slice(); + result.order(mHeader.order()); + return result; + } + + public int getType() { + return mType; + } + + /** + * Consumes the chunk located at the current position of the input and returns the chunk + * or {@code null} if there is no chunk left in the input. + * + * @throws XmlParserException if the chunk is malformed + */ + public static Chunk get(ByteBuffer input) throws XmlParserException { + if (input.remaining() < HEADER_MIN_SIZE_BYTES) { + // Android ignores the last chunk if its header is too big to fit into the file + input.position(input.limit()); + return null; + } + + int originalPosition = input.position(); + int type = getUnsignedInt16(input); + int headerSize = getUnsignedInt16(input); + long chunkSize = getUnsignedInt32(input); + long chunkRemaining = chunkSize - 8; + if (chunkRemaining > input.remaining()) { + // Android ignores the last chunk if it's too big to fit into the file + input.position(input.limit()); + return null; + } + if (headerSize < HEADER_MIN_SIZE_BYTES) { + throw new XmlParserException( + "Malformed chunk: header too short: " + headerSize + " bytes"); + } else if (headerSize > chunkSize) { + throw new XmlParserException( + "Malformed chunk: header too long: " + headerSize + " bytes. Chunk size: " + + chunkSize + " bytes"); + } + int contentStartPosition = originalPosition + headerSize; + long chunkEndPosition = originalPosition + chunkSize; + Chunk chunk = + new Chunk( + type, + sliceFromTo(input, originalPosition, contentStartPosition), + sliceFromTo(input, contentStartPosition, chunkEndPosition)); + input.position((int) chunkEndPosition); + return chunk; + } + } + + /** + * String pool of a document. Strings are referenced by their {@code 0}-based index in the pool. + */ + private static class StringPool { + private static final int FLAG_UTF8 = 1 << 8; + + private final ByteBuffer mChunkContents; + private final ByteBuffer mStringsSection; + private final int mStringCount; + private final boolean mUtf8Encoded; + private final Map<Integer, String> mCachedStrings = new HashMap<>(); + + /** + * Constructs a new string pool from the provided chunk. + * + * @throws XmlParserException if a parsing error occurred + */ + public StringPool(Chunk chunk) throws XmlParserException { + ByteBuffer header = chunk.getHeader(); + int headerSizeBytes = header.remaining(); + header.position(Chunk.HEADER_MIN_SIZE_BYTES); + if (header.remaining() < 20) { + throw new XmlParserException( + "XML chunk's header too short. Required at least 20 bytes. Available: " + + header.remaining() + " bytes"); + } + long stringCount = getUnsignedInt32(header); + if (stringCount > Integer.MAX_VALUE) { + throw new XmlParserException("Too many strings: " + stringCount); + } + mStringCount = (int) stringCount; + long styleCount = getUnsignedInt32(header); + if (styleCount > Integer.MAX_VALUE) { + throw new XmlParserException("Too many styles: " + styleCount); + } + long flags = getUnsignedInt32(header); + long stringsStartOffset = getUnsignedInt32(header); + long stylesStartOffset = getUnsignedInt32(header); + + ByteBuffer contents = chunk.getContents(); + if (mStringCount > 0) { + int stringsSectionStartOffsetInContents = + (int) (stringsStartOffset - headerSizeBytes); + int stringsSectionEndOffsetInContents; + if (styleCount > 0) { + // Styles section follows the strings section + if (stylesStartOffset < stringsStartOffset) { + throw new XmlParserException( + "Styles offset (" + stylesStartOffset + ") < strings offset (" + + stringsStartOffset + ")"); + } + stringsSectionEndOffsetInContents = (int) (stylesStartOffset - headerSizeBytes); + } else { + stringsSectionEndOffsetInContents = contents.remaining(); + } + mStringsSection = + sliceFromTo( + contents, + stringsSectionStartOffsetInContents, + stringsSectionEndOffsetInContents); + } else { + mStringsSection = ByteBuffer.allocate(0); + } + + mUtf8Encoded = (flags & FLAG_UTF8) != 0; + mChunkContents = contents; + } + + /** + * Returns the string located at the specified {@code 0}-based index in this pool. + * + * @throws XmlParserException if the string does not exist or cannot be decoded + */ + public String getString(long index) throws XmlParserException { + if (index < 0) { + throw new XmlParserException("Unsuported string index: " + index); + } else if (index >= mStringCount) { + throw new XmlParserException( + "Unsuported string index: " + index + ", max: " + (mStringCount - 1)); + } + + int idx = (int) index; + String result = mCachedStrings.get(idx); + if (result != null) { + return result; + } + + long offsetInStringsSection = getUnsignedInt32(mChunkContents, idx * 4); + if (offsetInStringsSection >= mStringsSection.capacity()) { + throw new XmlParserException( + "Offset of string idx " + idx + " out of bounds: " + offsetInStringsSection + + ", max: " + (mStringsSection.capacity() - 1)); + } + mStringsSection.position((int) offsetInStringsSection); + result = + (mUtf8Encoded) + ? getLengthPrefixedUtf8EncodedString(mStringsSection) + : getLengthPrefixedUtf16EncodedString(mStringsSection); + mCachedStrings.put(idx, result); + return result; + } + + private static String getLengthPrefixedUtf16EncodedString(ByteBuffer encoded) + throws XmlParserException { + // If the length (in uint16s) is 0x7fff or lower, it is stored as a single uint16. + // Otherwise, it is stored as a big-endian uint32 with highest bit set. Thus, the range + // of supported values is 0 to 0x7fffffff inclusive. + int lengthChars = getUnsignedInt16(encoded); + if ((lengthChars & 0x8000) != 0) { + lengthChars = ((lengthChars & 0x7fff) << 16) | getUnsignedInt16(encoded); + } + if (lengthChars > Integer.MAX_VALUE / 2) { + throw new XmlParserException("String too long: " + lengthChars + " uint16s"); + } + int lengthBytes = lengthChars * 2; + + byte[] arr; + int arrOffset; + if (encoded.hasArray()) { + arr = encoded.array(); + arrOffset = encoded.arrayOffset() + encoded.position(); + encoded.position(encoded.position() + lengthBytes); + } else { + arr = new byte[lengthBytes]; + arrOffset = 0; + encoded.get(arr); + } + // Reproduce the behavior of Android runtime which requires that the UTF-16 encoded + // array of bytes is NULL terminated. + if ((arr[arrOffset + lengthBytes] != 0) + || (arr[arrOffset + lengthBytes + 1] != 0)) { + throw new XmlParserException("UTF-16 encoded form of string not NULL terminated"); + } + try { + return new String(arr, arrOffset, lengthBytes, "UTF-16LE"); + } catch (UnsupportedEncodingException e) { + throw new RuntimeException("UTF-16LE character encoding not supported", e); + } + } + + private static String getLengthPrefixedUtf8EncodedString(ByteBuffer encoded) + throws XmlParserException { + // If the length (in bytes) is 0x7f or lower, it is stored as a single uint8. Otherwise, + // it is stored as a big-endian uint16 with highest bit set. Thus, the range of + // supported values is 0 to 0x7fff inclusive. + + // Skip UTF-16 encoded length (in uint16s) + int lengthBytes = getUnsignedInt8(encoded); + if ((lengthBytes & 0x80) != 0) { + lengthBytes = ((lengthBytes & 0x7f) << 8) | getUnsignedInt8(encoded); + } + + // Read UTF-8 encoded length (in bytes) + lengthBytes = getUnsignedInt8(encoded); + if ((lengthBytes & 0x80) != 0) { + lengthBytes = ((lengthBytes & 0x7f) << 8) | getUnsignedInt8(encoded); + } + + byte[] arr; + int arrOffset; + if (encoded.hasArray()) { + arr = encoded.array(); + arrOffset = encoded.arrayOffset() + encoded.position(); + encoded.position(encoded.position() + lengthBytes); + } else { + arr = new byte[lengthBytes]; + arrOffset = 0; + encoded.get(arr); + } + // Reproduce the behavior of Android runtime which requires that the UTF-8 encoded array + // of bytes is NULL terminated. + if (arr[arrOffset + lengthBytes] != 0) { + throw new XmlParserException("UTF-8 encoded form of string not NULL terminated"); + } + try { + return new String(arr, arrOffset, lengthBytes, "UTF-8"); + } catch (UnsupportedEncodingException e) { + throw new RuntimeException("UTF-8 character encoding not supported", e); + } + } + } + + /** + * Resource map of a document. Resource IDs are referenced by their {@code 0}-based index in the + * map. + */ + private static class ResourceMap { + private final ByteBuffer mChunkContents; + private final int mEntryCount; + + /** + * Constructs a new resource map from the provided chunk. + * + * @throws XmlParserException if a parsing error occurred + */ + public ResourceMap(Chunk chunk) throws XmlParserException { + mChunkContents = chunk.getContents().slice(); + mChunkContents.order(chunk.getContents().order()); + // Each entry of the map is four bytes long, containing the int32 resource ID. + mEntryCount = mChunkContents.remaining() / 4; + } + + /** + * Returns the resource ID located at the specified {@code 0}-based index in this pool or + * {@code 0} if the index is out of range. + */ + public int getResourceId(long index) { + if ((index < 0) || (index >= mEntryCount)) { + return 0; + } + int idx = (int) index; + // Each entry of the map is four bytes long, containing the int32 resource ID. + return mChunkContents.getInt(idx * 4); + } + } + + /** + * Returns new byte buffer whose content is a shared subsequence of this buffer's content + * between the specified start (inclusive) and end (exclusive) positions. As opposed to + * {@link ByteBuffer#slice()}, the returned buffer's byte order is the same as the source + * buffer's byte order. + */ + private static ByteBuffer sliceFromTo(ByteBuffer source, long start, long end) { + if (start < 0) { + throw new IllegalArgumentException("start: " + start); + } + if (end < start) { + throw new IllegalArgumentException("end < start: " + end + " < " + start); + } + int capacity = source.capacity(); + if (end > source.capacity()) { + throw new IllegalArgumentException("end > capacity: " + end + " > " + capacity); + } + return sliceFromTo(source, (int) start, (int) end); + } + + /** + * Returns new byte buffer whose content is a shared subsequence of this buffer's content + * between the specified start (inclusive) and end (exclusive) positions. As opposed to + * {@link ByteBuffer#slice()}, the returned buffer's byte order is the same as the source + * buffer's byte order. + */ + private static ByteBuffer sliceFromTo(ByteBuffer source, int start, int end) { + if (start < 0) { + throw new IllegalArgumentException("start: " + start); + } + if (end < start) { + throw new IllegalArgumentException("end < start: " + end + " < " + start); + } + int capacity = source.capacity(); + if (end > source.capacity()) { + throw new IllegalArgumentException("end > capacity: " + end + " > " + capacity); + } + int originalLimit = source.limit(); + int originalPosition = source.position(); + try { + source.position(0); + source.limit(end); + source.position(start); + ByteBuffer result = source.slice(); + result.order(source.order()); + return result; + } finally { + source.position(0); + source.limit(originalLimit); + source.position(originalPosition); + } + } + + private static int getUnsignedInt8(ByteBuffer buffer) { + return buffer.get() & 0xff; + } + + private static int getUnsignedInt16(ByteBuffer buffer) { + return buffer.getShort() & 0xffff; + } + + private static long getUnsignedInt32(ByteBuffer buffer) { + return buffer.getInt() & 0xffffffffL; + } + + private static long getUnsignedInt32(ByteBuffer buffer, int position) { + return buffer.getInt(position) & 0xffffffffL; + } + + /** + * Indicates that an error occurred while parsing a document. + */ + public static class XmlParserException extends Exception { + private static final long serialVersionUID = 1L; + + public XmlParserException(String message) { + super(message); + } + + public XmlParserException(String message, Throwable cause) { + super(message, cause); + } + } +} diff --git a/platform/android/java/editor/src/main/java/com/android/apksig/internal/apk/ApkSigResult.java b/platform/android/java/editor/src/main/java/com/android/apksig/internal/apk/ApkSigResult.java new file mode 100644 index 0000000000..6151351b2b --- /dev/null +++ b/platform/android/java/editor/src/main/java/com/android/apksig/internal/apk/ApkSigResult.java @@ -0,0 +1,104 @@ +/* + * Copyright (C) 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.apksig.internal.apk; + +import com.android.apksig.ApkVerificationIssue; + +import java.util.ArrayList; +import java.util.List; + +/** + * Base implementation of an APK signature verification result. + */ +public class ApkSigResult { + public final int signatureSchemeVersion; + + /** Whether the APK's Signature Scheme signature verifies. */ + public boolean verified; + + public final List<ApkSignerInfo> mSigners = new ArrayList<>(); + private final List<ApkVerificationIssue> mWarnings = new ArrayList<>(); + private final List<ApkVerificationIssue> mErrors = new ArrayList<>(); + + public ApkSigResult(int signatureSchemeVersion) { + this.signatureSchemeVersion = signatureSchemeVersion; + } + + /** + * Returns {@code true} if this result encountered errors during verification. + */ + public boolean containsErrors() { + if (!mErrors.isEmpty()) { + return true; + } + if (!mSigners.isEmpty()) { + for (ApkSignerInfo signer : mSigners) { + if (signer.containsErrors()) { + return true; + } + } + } + return false; + } + + /** + * Returns {@code true} if this result encountered warnings during verification. + */ + public boolean containsWarnings() { + if (!mWarnings.isEmpty()) { + return true; + } + if (!mSigners.isEmpty()) { + for (ApkSignerInfo signer : mSigners) { + if (signer.containsWarnings()) { + return true; + } + } + } + return false; + } + + /** + * Adds a new {@link ApkVerificationIssue} as an error to this result using the provided {@code + * issueId} and {@code params}. + */ + public void addError(int issueId, Object... parameters) { + mErrors.add(new ApkVerificationIssue(issueId, parameters)); + } + + /** + * Adds a new {@link ApkVerificationIssue} as a warning to this result using the provided {@code + * issueId} and {@code params}. + */ + public void addWarning(int issueId, Object... parameters) { + mWarnings.add(new ApkVerificationIssue(issueId, parameters)); + } + + /** + * Returns the errors encountered during verification. + */ + public List<? extends ApkVerificationIssue> getErrors() { + return mErrors; + } + + /** + * Returns the warnings encountered during verification. + */ + public List<? extends ApkVerificationIssue> getWarnings() { + return mWarnings; + } +} diff --git a/platform/android/java/editor/src/main/java/com/android/apksig/internal/apk/ApkSignerInfo.java b/platform/android/java/editor/src/main/java/com/android/apksig/internal/apk/ApkSignerInfo.java new file mode 100644 index 0000000000..3e7934195f --- /dev/null +++ b/platform/android/java/editor/src/main/java/com/android/apksig/internal/apk/ApkSignerInfo.java @@ -0,0 +1,104 @@ +/* + * Copyright (C) 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.apksig.internal.apk; + +import com.android.apksig.ApkVerificationIssue; + +import java.security.cert.X509Certificate; +import java.util.ArrayList; +import java.util.List; + +/** + * Base implementation of an APK signer. + */ +public class ApkSignerInfo { + public int index; + public long timestamp; + public List<X509Certificate> certs = new ArrayList<>(); + public List<X509Certificate> certificateLineage = new ArrayList<>(); + + private final List<ApkVerificationIssue> mInfoMessages = new ArrayList<>(); + private final List<ApkVerificationIssue> mWarnings = new ArrayList<>(); + private final List<ApkVerificationIssue> mErrors = new ArrayList<>(); + + /** + * Adds a new {@link ApkVerificationIssue} as an error to this signer using the provided {@code + * issueId} and {@code params}. + */ + public void addError(int issueId, Object... params) { + mErrors.add(new ApkVerificationIssue(issueId, params)); + } + + /** + * Adds a new {@link ApkVerificationIssue} as a warning to this signer using the provided {@code + * issueId} and {@code params}. + */ + public void addWarning(int issueId, Object... params) { + mWarnings.add(new ApkVerificationIssue(issueId, params)); + } + + /** + * Adds a new {@link ApkVerificationIssue} as an info message to this signer config using the + * provided {@code issueId} and {@code params}. + */ + public void addInfoMessage(int issueId, Object... params) { + mInfoMessages.add(new ApkVerificationIssue(issueId, params)); + } + + /** + * Returns {@code true} if any errors were encountered during verification for this signer. + */ + public boolean containsErrors() { + return !mErrors.isEmpty(); + } + + /** + * Returns {@code true} if any warnings were encountered during verification for this signer. + */ + public boolean containsWarnings() { + return !mWarnings.isEmpty(); + } + + /** + * Returns {@code true} if any info messages were encountered during verification of this + * signer. + */ + public boolean containsInfoMessages() { + return !mInfoMessages.isEmpty(); + } + + /** + * Returns the errors encountered during verification for this signer. + */ + public List<? extends ApkVerificationIssue> getErrors() { + return mErrors; + } + + /** + * Returns the warnings encountered during verification for this signer. + */ + public List<? extends ApkVerificationIssue> getWarnings() { + return mWarnings; + } + + /** + * Returns the info messages encountered during verification of this signer. + */ + public List<? extends ApkVerificationIssue> getInfoMessages() { + return mInfoMessages; + } +} diff --git a/platform/android/java/editor/src/main/java/com/android/apksig/internal/apk/ApkSigningBlockUtils.java b/platform/android/java/editor/src/main/java/com/android/apksig/internal/apk/ApkSigningBlockUtils.java new file mode 100644 index 0000000000..127ac24d3f --- /dev/null +++ b/platform/android/java/editor/src/main/java/com/android/apksig/internal/apk/ApkSigningBlockUtils.java @@ -0,0 +1,1444 @@ +/* + * Copyright (C) 2018 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.apksig.internal.apk; + +import static com.android.apksig.Constants.OID_RSA_ENCRYPTION; +import static com.android.apksig.internal.apk.ContentDigestAlgorithm.CHUNKED_SHA256; +import static com.android.apksig.internal.apk.ContentDigestAlgorithm.CHUNKED_SHA512; +import static com.android.apksig.internal.apk.ContentDigestAlgorithm.VERITY_CHUNKED_SHA256; + +import com.android.apksig.ApkVerifier; +import com.android.apksig.SigningCertificateLineage; +import com.android.apksig.apk.ApkFormatException; +import com.android.apksig.apk.ApkUtils; +import com.android.apksig.internal.asn1.Asn1BerParser; +import com.android.apksig.internal.asn1.Asn1DecodingException; +import com.android.apksig.internal.asn1.Asn1DerEncoder; +import com.android.apksig.internal.asn1.Asn1EncodingException; +import com.android.apksig.internal.asn1.Asn1OpaqueObject; +import com.android.apksig.internal.pkcs7.AlgorithmIdentifier; +import com.android.apksig.internal.pkcs7.ContentInfo; +import com.android.apksig.internal.pkcs7.EncapsulatedContentInfo; +import com.android.apksig.internal.pkcs7.IssuerAndSerialNumber; +import com.android.apksig.internal.pkcs7.Pkcs7Constants; +import com.android.apksig.internal.pkcs7.SignedData; +import com.android.apksig.internal.pkcs7.SignerIdentifier; +import com.android.apksig.internal.pkcs7.SignerInfo; +import com.android.apksig.internal.util.ByteBufferDataSource; +import com.android.apksig.internal.util.ChainedDataSource; +import com.android.apksig.internal.util.GuaranteedEncodedFormX509Certificate; +import com.android.apksig.internal.util.Pair; +import com.android.apksig.internal.util.VerityTreeBuilder; +import com.android.apksig.internal.util.X509CertificateUtils; +import com.android.apksig.internal.x509.RSAPublicKey; +import com.android.apksig.internal.x509.SubjectPublicKeyInfo; +import com.android.apksig.internal.zip.ZipUtils; +import com.android.apksig.util.DataSink; +import com.android.apksig.util.DataSinks; +import com.android.apksig.util.DataSource; +import com.android.apksig.util.DataSources; +import com.android.apksig.util.RunnablesExecutor; + +import java.io.IOException; +import java.math.BigInteger; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.security.DigestException; +import java.security.InvalidAlgorithmParameterException; +import java.security.InvalidKeyException; +import java.security.KeyFactory; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.security.PrivateKey; +import java.security.PublicKey; +import java.security.Signature; +import java.security.SignatureException; +import java.security.cert.CertificateEncodingException; +import java.security.cert.CertificateException; +import java.security.cert.X509Certificate; +import java.security.spec.AlgorithmParameterSpec; +import java.security.spec.InvalidKeySpecException; +import java.security.spec.X509EncodedKeySpec; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.Supplier; + +import javax.security.auth.x500.X500Principal; + +public class ApkSigningBlockUtils { + + private static final long CONTENT_DIGESTED_CHUNK_MAX_SIZE_BYTES = 1024 * 1024; + public static final int ANDROID_COMMON_PAGE_ALIGNMENT_BYTES = 4096; + private static final byte[] APK_SIGNING_BLOCK_MAGIC = + new byte[] { + 0x41, 0x50, 0x4b, 0x20, 0x53, 0x69, 0x67, 0x20, + 0x42, 0x6c, 0x6f, 0x63, 0x6b, 0x20, 0x34, 0x32, + }; + public static final int VERITY_PADDING_BLOCK_ID = 0x42726577; + + private static final ContentDigestAlgorithm[] V4_CONTENT_DIGEST_ALGORITHMS = + {CHUNKED_SHA512, VERITY_CHUNKED_SHA256, CHUNKED_SHA256}; + + public static final int VERSION_SOURCE_STAMP = 0; + public static final int VERSION_JAR_SIGNATURE_SCHEME = 1; + public static final int VERSION_APK_SIGNATURE_SCHEME_V2 = 2; + public static final int VERSION_APK_SIGNATURE_SCHEME_V3 = 3; + public static final int VERSION_APK_SIGNATURE_SCHEME_V31 = 31; + public static final int VERSION_APK_SIGNATURE_SCHEME_V4 = 4; + + /** + * Returns positive number if {@code alg1} is preferred over {@code alg2}, {@code -1} if + * {@code alg2} is preferred over {@code alg1}, and {@code 0} if there is no preference. + */ + public static int compareSignatureAlgorithm(SignatureAlgorithm alg1, SignatureAlgorithm alg2) { + return ApkSigningBlockUtilsLite.compareSignatureAlgorithm(alg1, alg2); + } + + /** + * Verifies integrity of the APK outside of the APK Signing Block by computing digests of the + * APK and comparing them against the digests listed in APK Signing Block. The expected digests + * are taken from {@code SignerInfos} of the provided {@code result}. + * + * <p>This method adds one or more errors to the {@code result} if a verification error is + * expected to be encountered on Android. No errors are added to the {@code result} if the APK's + * integrity is expected to verify on Android for each algorithm in + * {@code contentDigestAlgorithms}. + * + * <p>The reason this method is currently not parameterized by a + * {@code [minSdkVersion, maxSdkVersion]} range is that up until now content digest algorithms + * exhibit the same behavior on all Android platform versions. + */ + public static void verifyIntegrity( + RunnablesExecutor executor, + DataSource beforeApkSigningBlock, + DataSource centralDir, + ByteBuffer eocd, + Set<ContentDigestAlgorithm> contentDigestAlgorithms, + Result result) throws IOException, NoSuchAlgorithmException { + if (contentDigestAlgorithms.isEmpty()) { + // This should never occur because this method is invoked once at least one signature + // is verified, meaning at least one content digest is known. + throw new RuntimeException("No content digests found"); + } + + // For the purposes of verifying integrity, ZIP End of Central Directory (EoCD) must be + // treated as though its Central Directory offset points to the start of APK Signing Block. + // We thus modify the EoCD accordingly. + ByteBuffer modifiedEocd = ByteBuffer.allocate(eocd.remaining()); + int eocdSavedPos = eocd.position(); + modifiedEocd.order(ByteOrder.LITTLE_ENDIAN); + modifiedEocd.put(eocd); + modifiedEocd.flip(); + + // restore eocd to position prior to modification in case it is to be used elsewhere + eocd.position(eocdSavedPos); + ZipUtils.setZipEocdCentralDirectoryOffset(modifiedEocd, beforeApkSigningBlock.size()); + Map<ContentDigestAlgorithm, byte[]> actualContentDigests; + try { + actualContentDigests = + computeContentDigests( + executor, + contentDigestAlgorithms, + beforeApkSigningBlock, + centralDir, + new ByteBufferDataSource(modifiedEocd)); + // Special checks for the verity algorithm requirements. + if (actualContentDigests.containsKey(VERITY_CHUNKED_SHA256)) { + if ((beforeApkSigningBlock.size() % ANDROID_COMMON_PAGE_ALIGNMENT_BYTES != 0)) { + throw new RuntimeException( + "APK Signing Block is not aligned on 4k boundary: " + + beforeApkSigningBlock.size()); + } + + long centralDirOffset = ZipUtils.getZipEocdCentralDirectoryOffset(eocd); + long signingBlockSize = centralDirOffset - beforeApkSigningBlock.size(); + if (signingBlockSize % ANDROID_COMMON_PAGE_ALIGNMENT_BYTES != 0) { + throw new RuntimeException( + "APK Signing Block size is not multiple of page size: " + + signingBlockSize); + } + } + } catch (DigestException e) { + throw new RuntimeException("Failed to compute content digests", e); + } + if (!contentDigestAlgorithms.equals(actualContentDigests.keySet())) { + throw new RuntimeException( + "Mismatch between sets of requested and computed content digests" + + " . Requested: " + contentDigestAlgorithms + + ", computed: " + actualContentDigests.keySet()); + } + + // Compare digests computed over the rest of APK against the corresponding expected digests + // in signer blocks. + for (Result.SignerInfo signerInfo : result.signers) { + for (Result.SignerInfo.ContentDigest expected : signerInfo.contentDigests) { + SignatureAlgorithm signatureAlgorithm = + SignatureAlgorithm.findById(expected.getSignatureAlgorithmId()); + if (signatureAlgorithm == null) { + continue; + } + ContentDigestAlgorithm contentDigestAlgorithm = + signatureAlgorithm.getContentDigestAlgorithm(); + // if the current digest algorithm is not in the list provided by the caller then + // ignore it; the signer may contain digests not recognized by the specified SDK + // range. + if (!contentDigestAlgorithms.contains(contentDigestAlgorithm)) { + continue; + } + byte[] expectedDigest = expected.getValue(); + byte[] actualDigest = actualContentDigests.get(contentDigestAlgorithm); + if (!Arrays.equals(expectedDigest, actualDigest)) { + if (result.signatureSchemeVersion == VERSION_APK_SIGNATURE_SCHEME_V2) { + signerInfo.addError( + ApkVerifier.Issue.V2_SIG_APK_DIGEST_DID_NOT_VERIFY, + contentDigestAlgorithm, + toHex(expectedDigest), + toHex(actualDigest)); + } else if (result.signatureSchemeVersion == VERSION_APK_SIGNATURE_SCHEME_V3) { + signerInfo.addError( + ApkVerifier.Issue.V3_SIG_APK_DIGEST_DID_NOT_VERIFY, + contentDigestAlgorithm, + toHex(expectedDigest), + toHex(actualDigest)); + } + continue; + } + signerInfo.verifiedContentDigests.put(contentDigestAlgorithm, actualDigest); + } + } + } + + public static ByteBuffer findApkSignatureSchemeBlock( + ByteBuffer apkSigningBlock, + int blockId, + Result result) throws SignatureNotFoundException { + try { + return ApkSigningBlockUtilsLite.findApkSignatureSchemeBlock(apkSigningBlock, blockId); + } catch (com.android.apksig.internal.apk.SignatureNotFoundException e) { + throw new SignatureNotFoundException(e.getMessage()); + } + } + + public static void checkByteOrderLittleEndian(ByteBuffer buffer) { + ApkSigningBlockUtilsLite.checkByteOrderLittleEndian(buffer); + } + + public static ByteBuffer getLengthPrefixedSlice(ByteBuffer source) throws ApkFormatException { + return ApkSigningBlockUtilsLite.getLengthPrefixedSlice(source); + } + + public static byte[] readLengthPrefixedByteArray(ByteBuffer buf) throws ApkFormatException { + return ApkSigningBlockUtilsLite.readLengthPrefixedByteArray(buf); + } + + public static String toHex(byte[] value) { + return ApkSigningBlockUtilsLite.toHex(value); + } + + public static Map<ContentDigestAlgorithm, byte[]> computeContentDigests( + RunnablesExecutor executor, + Set<ContentDigestAlgorithm> digestAlgorithms, + DataSource beforeCentralDir, + DataSource centralDir, + DataSource eocd) throws IOException, NoSuchAlgorithmException, DigestException { + Map<ContentDigestAlgorithm, byte[]> contentDigests = new HashMap<>(); + Set<ContentDigestAlgorithm> oneMbChunkBasedAlgorithm = new HashSet<>(); + for (ContentDigestAlgorithm digestAlgorithm : digestAlgorithms) { + if (digestAlgorithm == ContentDigestAlgorithm.CHUNKED_SHA256 + || digestAlgorithm == ContentDigestAlgorithm.CHUNKED_SHA512) { + oneMbChunkBasedAlgorithm.add(digestAlgorithm); + } + } + computeOneMbChunkContentDigests( + executor, + oneMbChunkBasedAlgorithm, + new DataSource[] { beforeCentralDir, centralDir, eocd }, + contentDigests); + + if (digestAlgorithms.contains(VERITY_CHUNKED_SHA256)) { + computeApkVerityDigest(beforeCentralDir, centralDir, eocd, contentDigests); + } + return contentDigests; + } + + static void computeOneMbChunkContentDigests( + Set<ContentDigestAlgorithm> digestAlgorithms, + DataSource[] contents, + Map<ContentDigestAlgorithm, byte[]> outputContentDigests) + throws IOException, NoSuchAlgorithmException, DigestException { + // For each digest algorithm the result is computed as follows: + // 1. Each segment of contents is split into consecutive chunks of 1 MB in size. + // The final chunk will be shorter iff the length of segment is not a multiple of 1 MB. + // No chunks are produced for empty (zero length) segments. + // 2. The digest of each chunk is computed over the concatenation of byte 0xa5, the chunk's + // length in bytes (uint32 little-endian) and the chunk's contents. + // 3. The output digest is computed over the concatenation of the byte 0x5a, the number of + // chunks (uint32 little-endian) and the concatenation of digests of chunks of all + // segments in-order. + + long chunkCountLong = 0; + for (DataSource input : contents) { + chunkCountLong += + getChunkCount(input.size(), CONTENT_DIGESTED_CHUNK_MAX_SIZE_BYTES); + } + if (chunkCountLong > Integer.MAX_VALUE) { + throw new DigestException("Input too long: " + chunkCountLong + " chunks"); + } + int chunkCount = (int) chunkCountLong; + + ContentDigestAlgorithm[] digestAlgorithmsArray = + digestAlgorithms.toArray(new ContentDigestAlgorithm[digestAlgorithms.size()]); + MessageDigest[] mds = new MessageDigest[digestAlgorithmsArray.length]; + byte[][] digestsOfChunks = new byte[digestAlgorithmsArray.length][]; + int[] digestOutputSizes = new int[digestAlgorithmsArray.length]; + for (int i = 0; i < digestAlgorithmsArray.length; i++) { + ContentDigestAlgorithm digestAlgorithm = digestAlgorithmsArray[i]; + int digestOutputSizeBytes = digestAlgorithm.getChunkDigestOutputSizeBytes(); + digestOutputSizes[i] = digestOutputSizeBytes; + byte[] concatenationOfChunkCountAndChunkDigests = + new byte[5 + chunkCount * digestOutputSizeBytes]; + concatenationOfChunkCountAndChunkDigests[0] = 0x5a; + setUnsignedInt32LittleEndian( + chunkCount, concatenationOfChunkCountAndChunkDigests, 1); + digestsOfChunks[i] = concatenationOfChunkCountAndChunkDigests; + String jcaAlgorithm = digestAlgorithm.getJcaMessageDigestAlgorithm(); + mds[i] = MessageDigest.getInstance(jcaAlgorithm); + } + + DataSink mdSink = DataSinks.asDataSink(mds); + byte[] chunkContentPrefix = new byte[5]; + chunkContentPrefix[0] = (byte) 0xa5; + int chunkIndex = 0; + // Optimization opportunity: digests of chunks can be computed in parallel. However, + // determining the number of computations to be performed in parallel is non-trivial. This + // depends on a wide range of factors, such as data source type (e.g., in-memory or fetched + // from file), CPU/memory/disk cache bandwidth and latency, interconnect architecture of CPU + // cores, load on the system from other threads of execution and other processes, size of + // input. + // For now, we compute these digests sequentially and thus have the luxury of improving + // performance by writing the digest of each chunk into a pre-allocated buffer at exactly + // the right position. This avoids unnecessary allocations, copying, and enables the final + // digest to be more efficient because it's presented with all of its input in one go. + for (DataSource input : contents) { + long inputOffset = 0; + long inputRemaining = input.size(); + while (inputRemaining > 0) { + int chunkSize = + (int) Math.min(inputRemaining, CONTENT_DIGESTED_CHUNK_MAX_SIZE_BYTES); + setUnsignedInt32LittleEndian(chunkSize, chunkContentPrefix, 1); + for (int i = 0; i < mds.length; i++) { + mds[i].update(chunkContentPrefix); + } + try { + input.feed(inputOffset, chunkSize, mdSink); + } catch (IOException e) { + throw new IOException("Failed to read chunk #" + chunkIndex, e); + } + for (int i = 0; i < digestAlgorithmsArray.length; i++) { + MessageDigest md = mds[i]; + byte[] concatenationOfChunkCountAndChunkDigests = digestsOfChunks[i]; + int expectedDigestSizeBytes = digestOutputSizes[i]; + int actualDigestSizeBytes = + md.digest( + concatenationOfChunkCountAndChunkDigests, + 5 + chunkIndex * expectedDigestSizeBytes, + expectedDigestSizeBytes); + if (actualDigestSizeBytes != expectedDigestSizeBytes) { + throw new RuntimeException( + "Unexpected output size of " + md.getAlgorithm() + + " digest: " + actualDigestSizeBytes); + } + } + inputOffset += chunkSize; + inputRemaining -= chunkSize; + chunkIndex++; + } + } + + for (int i = 0; i < digestAlgorithmsArray.length; i++) { + ContentDigestAlgorithm digestAlgorithm = digestAlgorithmsArray[i]; + byte[] concatenationOfChunkCountAndChunkDigests = digestsOfChunks[i]; + MessageDigest md = mds[i]; + byte[] digest = md.digest(concatenationOfChunkCountAndChunkDigests); + outputContentDigests.put(digestAlgorithm, digest); + } + } + + static void computeOneMbChunkContentDigests( + RunnablesExecutor executor, + Set<ContentDigestAlgorithm> digestAlgorithms, + DataSource[] contents, + Map<ContentDigestAlgorithm, byte[]> outputContentDigests) + throws NoSuchAlgorithmException, DigestException { + long chunkCountLong = 0; + for (DataSource input : contents) { + chunkCountLong += + getChunkCount(input.size(), CONTENT_DIGESTED_CHUNK_MAX_SIZE_BYTES); + } + if (chunkCountLong > Integer.MAX_VALUE) { + throw new DigestException("Input too long: " + chunkCountLong + " chunks"); + } + int chunkCount = (int) chunkCountLong; + + List<ChunkDigests> chunkDigestsList = new ArrayList<>(digestAlgorithms.size()); + for (ContentDigestAlgorithm algorithms : digestAlgorithms) { + chunkDigestsList.add(new ChunkDigests(algorithms, chunkCount)); + } + + ChunkSupplier chunkSupplier = new ChunkSupplier(contents); + executor.execute(() -> new ChunkDigester(chunkSupplier, chunkDigestsList)); + + // Compute and write out final digest for each algorithm. + for (ChunkDigests chunkDigests : chunkDigestsList) { + MessageDigest messageDigest = chunkDigests.createMessageDigest(); + outputContentDigests.put( + chunkDigests.algorithm, + messageDigest.digest(chunkDigests.concatOfDigestsOfChunks)); + } + } + + private static class ChunkDigests { + private final ContentDigestAlgorithm algorithm; + private final int digestOutputSize; + private final byte[] concatOfDigestsOfChunks; + + private ChunkDigests(ContentDigestAlgorithm algorithm, int chunkCount) { + this.algorithm = algorithm; + digestOutputSize = this.algorithm.getChunkDigestOutputSizeBytes(); + concatOfDigestsOfChunks = new byte[1 + 4 + chunkCount * digestOutputSize]; + + // Fill the initial values of the concatenated digests of chunks, which is + // {0x5a, 4-bytes-of-little-endian-chunk-count, digests*...}. + concatOfDigestsOfChunks[0] = 0x5a; + setUnsignedInt32LittleEndian(chunkCount, concatOfDigestsOfChunks, 1); + } + + private MessageDigest createMessageDigest() throws NoSuchAlgorithmException { + return MessageDigest.getInstance(algorithm.getJcaMessageDigestAlgorithm()); + } + + private int getOffset(int chunkIndex) { + return 1 + 4 + chunkIndex * digestOutputSize; + } + } + + /** + * A per-thread digest worker. + */ + private static class ChunkDigester implements Runnable { + private final ChunkSupplier dataSupplier; + private final List<ChunkDigests> chunkDigests; + private final List<MessageDigest> messageDigests; + private final DataSink mdSink; + + private ChunkDigester(ChunkSupplier dataSupplier, List<ChunkDigests> chunkDigests) { + this.dataSupplier = dataSupplier; + this.chunkDigests = chunkDigests; + messageDigests = new ArrayList<>(chunkDigests.size()); + for (ChunkDigests chunkDigest : chunkDigests) { + try { + messageDigests.add(chunkDigest.createMessageDigest()); + } catch (NoSuchAlgorithmException ex) { + throw new RuntimeException(ex); + } + } + mdSink = DataSinks.asDataSink(messageDigests.toArray(new MessageDigest[0])); + } + + @Override + public void run() { + byte[] chunkContentPrefix = new byte[5]; + chunkContentPrefix[0] = (byte) 0xa5; + + try { + for (ChunkSupplier.Chunk chunk = dataSupplier.get(); + chunk != null; + chunk = dataSupplier.get()) { + int size = chunk.size; + if (size > CONTENT_DIGESTED_CHUNK_MAX_SIZE_BYTES) { + throw new RuntimeException("Chunk size greater than expected: " + size); + } + + // First update with the chunk prefix. + setUnsignedInt32LittleEndian(size, chunkContentPrefix, 1); + mdSink.consume(chunkContentPrefix, 0, chunkContentPrefix.length); + + // Then update with the chunk data. + mdSink.consume(chunk.data); + + // Now finalize chunk for all algorithms. + for (int i = 0; i < chunkDigests.size(); i++) { + ChunkDigests chunkDigest = chunkDigests.get(i); + int actualDigestSize = messageDigests.get(i).digest( + chunkDigest.concatOfDigestsOfChunks, + chunkDigest.getOffset(chunk.chunkIndex), + chunkDigest.digestOutputSize); + if (actualDigestSize != chunkDigest.digestOutputSize) { + throw new RuntimeException( + "Unexpected output size of " + chunkDigest.algorithm + + " digest: " + actualDigestSize); + } + } + } + } catch (IOException | DigestException e) { + throw new RuntimeException(e); + } + } + } + + /** + * Thread-safe 1MB DataSource chunk supplier. When bounds are met in a + * supplied {@link DataSource}, the data from the next {@link DataSource} + * are NOT concatenated. Only the next call to get() will fetch from the + * next {@link DataSource} in the input {@link DataSource} array. + */ + private static class ChunkSupplier implements Supplier<ChunkSupplier.Chunk> { + private final DataSource[] dataSources; + private final int[] chunkCounts; + private final int totalChunkCount; + private final AtomicInteger nextIndex; + + private ChunkSupplier(DataSource[] dataSources) { + this.dataSources = dataSources; + chunkCounts = new int[dataSources.length]; + int totalChunkCount = 0; + for (int i = 0; i < dataSources.length; i++) { + long chunkCount = getChunkCount(dataSources[i].size(), + CONTENT_DIGESTED_CHUNK_MAX_SIZE_BYTES); + if (chunkCount > Integer.MAX_VALUE) { + throw new RuntimeException( + String.format( + "Number of chunks in dataSource[%d] is greater than max int.", + i)); + } + chunkCounts[i] = (int)chunkCount; + totalChunkCount = (int) (totalChunkCount + chunkCount); + } + this.totalChunkCount = totalChunkCount; + nextIndex = new AtomicInteger(0); + } + + /** + * We map an integer index to the termination-adjusted dataSources 1MB chunks. + * Note that {@link Chunk}s could be less than 1MB, namely the last 1MB-aligned + * blocks in each input {@link DataSource} (unless the DataSource itself is + * 1MB-aligned). + */ + @Override + public ChunkSupplier.Chunk get() { + int index = nextIndex.getAndIncrement(); + if (index < 0 || index >= totalChunkCount) { + return null; + } + + int dataSourceIndex = 0; + long dataSourceChunkOffset = index; + for (; dataSourceIndex < dataSources.length; dataSourceIndex++) { + if (dataSourceChunkOffset < chunkCounts[dataSourceIndex]) { + break; + } + dataSourceChunkOffset -= chunkCounts[dataSourceIndex]; + } + + long remainingSize = Math.min( + dataSources[dataSourceIndex].size() - + dataSourceChunkOffset * CONTENT_DIGESTED_CHUNK_MAX_SIZE_BYTES, + CONTENT_DIGESTED_CHUNK_MAX_SIZE_BYTES); + + final int size = (int)remainingSize; + final ByteBuffer buffer = ByteBuffer.allocate(size); + try { + dataSources[dataSourceIndex].copyTo( + dataSourceChunkOffset * CONTENT_DIGESTED_CHUNK_MAX_SIZE_BYTES, size, + buffer); + } catch (IOException e) { + throw new IllegalStateException("Failed to read chunk", e); + } + buffer.rewind(); + + return new Chunk(index, buffer, size); + } + + static class Chunk { + private final int chunkIndex; + private final ByteBuffer data; + private final int size; + + private Chunk(int chunkIndex, ByteBuffer data, int size) { + this.chunkIndex = chunkIndex; + this.data = data; + this.size = size; + } + } + } + + @SuppressWarnings("ByteBufferBackingArray") + private static void computeApkVerityDigest(DataSource beforeCentralDir, DataSource centralDir, + DataSource eocd, Map<ContentDigestAlgorithm, byte[]> outputContentDigests) + throws IOException, NoSuchAlgorithmException { + ByteBuffer encoded = createVerityDigestBuffer(true); + // Use 0s as salt for now. This also needs to be consistent in the fsverify header for + // kernel to use. + try (VerityTreeBuilder builder = new VerityTreeBuilder(new byte[8])) { + byte[] rootHash = builder.generateVerityTreeRootHash(beforeCentralDir, centralDir, + eocd); + encoded.put(rootHash); + encoded.putLong(beforeCentralDir.size() + centralDir.size() + eocd.size()); + outputContentDigests.put(VERITY_CHUNKED_SHA256, encoded.array()); + } + } + + private static ByteBuffer createVerityDigestBuffer(boolean includeSourceDataSize) { + // FORMAT: + // OFFSET DATA TYPE DESCRIPTION + // * @+0 bytes uint8[32] Merkle tree root hash of SHA-256 + // * @+32 bytes int64 (optional) Length of source data + int backBufferSize = + VERITY_CHUNKED_SHA256.getChunkDigestOutputSizeBytes(); + if (includeSourceDataSize) { + backBufferSize += Long.SIZE / Byte.SIZE; + } + ByteBuffer encoded = ByteBuffer.allocate(backBufferSize); + encoded.order(ByteOrder.LITTLE_ENDIAN); + return encoded; + } + + public static class VerityTreeAndDigest { + public final ContentDigestAlgorithm contentDigestAlgorithm; + public final byte[] rootHash; + public final byte[] tree; + + VerityTreeAndDigest(ContentDigestAlgorithm contentDigestAlgorithm, byte[] rootHash, + byte[] tree) { + this.contentDigestAlgorithm = contentDigestAlgorithm; + this.rootHash = rootHash; + this.tree = tree; + } + } + + @SuppressWarnings("ByteBufferBackingArray") + public static VerityTreeAndDigest computeChunkVerityTreeAndDigest(DataSource dataSource) + throws IOException, NoSuchAlgorithmException { + ByteBuffer encoded = createVerityDigestBuffer(false); + // Use 0s as salt for now. This also needs to be consistent in the fsverify header for + // kernel to use. + try (VerityTreeBuilder builder = new VerityTreeBuilder(null)) { + ByteBuffer tree = builder.generateVerityTree(dataSource); + byte[] rootHash = builder.getRootHashFromTree(tree); + encoded.put(rootHash); + return new VerityTreeAndDigest(VERITY_CHUNKED_SHA256, encoded.array(), tree.array()); + } + } + + private static long getChunkCount(long inputSize, long chunkSize) { + return (inputSize + chunkSize - 1) / chunkSize; + } + + private static void setUnsignedInt32LittleEndian(int value, byte[] result, int offset) { + result[offset] = (byte) (value & 0xff); + result[offset + 1] = (byte) ((value >> 8) & 0xff); + result[offset + 2] = (byte) ((value >> 16) & 0xff); + result[offset + 3] = (byte) ((value >> 24) & 0xff); + } + + public static byte[] encodePublicKey(PublicKey publicKey) + throws InvalidKeyException, NoSuchAlgorithmException { + byte[] encodedPublicKey = null; + if ("X.509".equals(publicKey.getFormat())) { + encodedPublicKey = publicKey.getEncoded(); + // if the key is an RSA key check for a negative modulus + String keyAlgorithm = publicKey.getAlgorithm(); + if ("RSA".equals(keyAlgorithm) || OID_RSA_ENCRYPTION.equals(keyAlgorithm)) { + try { + // Parse the encoded public key into the separate elements of the + // SubjectPublicKeyInfo to obtain the SubjectPublicKey. + ByteBuffer encodedPublicKeyBuffer = ByteBuffer.wrap(encodedPublicKey); + SubjectPublicKeyInfo subjectPublicKeyInfo = Asn1BerParser.parse( + encodedPublicKeyBuffer, SubjectPublicKeyInfo.class); + // The SubjectPublicKey is encoded as a bit string within the + // SubjectPublicKeyInfo. The first byte of the encoding is the number of padding + // bits; store this and decode the rest of the bit string into the RSA modulus + // and exponent. + ByteBuffer subjectPublicKeyBuffer = subjectPublicKeyInfo.subjectPublicKey; + byte padding = subjectPublicKeyBuffer.get(); + RSAPublicKey rsaPublicKey = Asn1BerParser.parse(subjectPublicKeyBuffer, + RSAPublicKey.class); + // if the modulus is negative then attempt to reencode it with a leading 0 sign + // byte. + if (rsaPublicKey.modulus.compareTo(BigInteger.ZERO) < 0) { + // A negative modulus indicates the leading bit in the integer is 1. Per + // ASN.1 encoding rules to encode a positive integer with the leading bit + // set to 1 a byte containing all zeros should precede the integer encoding. + byte[] encodedModulus = rsaPublicKey.modulus.toByteArray(); + byte[] reencodedModulus = new byte[encodedModulus.length + 1]; + reencodedModulus[0] = 0; + System.arraycopy(encodedModulus, 0, reencodedModulus, 1, + encodedModulus.length); + rsaPublicKey.modulus = new BigInteger(reencodedModulus); + // Once the modulus has been corrected reencode the RSAPublicKey, then + // restore the padding value in the bit string and reencode the entire + // SubjectPublicKeyInfo to be returned to the caller. + byte[] reencodedRSAPublicKey = Asn1DerEncoder.encode(rsaPublicKey); + byte[] reencodedSubjectPublicKey = + new byte[reencodedRSAPublicKey.length + 1]; + reencodedSubjectPublicKey[0] = padding; + System.arraycopy(reencodedRSAPublicKey, 0, reencodedSubjectPublicKey, 1, + reencodedRSAPublicKey.length); + subjectPublicKeyInfo.subjectPublicKey = ByteBuffer.wrap( + reencodedSubjectPublicKey); + encodedPublicKey = Asn1DerEncoder.encode(subjectPublicKeyInfo); + } + } catch (Asn1DecodingException | Asn1EncodingException e) { + System.out.println("Caught a exception encoding the public key: " + e); + e.printStackTrace(); + encodedPublicKey = null; + } + } + } + if (encodedPublicKey == null) { + try { + encodedPublicKey = + KeyFactory.getInstance(publicKey.getAlgorithm()) + .getKeySpec(publicKey, X509EncodedKeySpec.class) + .getEncoded(); + } catch (InvalidKeySpecException e) { + throw new InvalidKeyException( + "Failed to obtain X.509 encoded form of public key " + publicKey + + " of class " + publicKey.getClass().getName(), + e); + } + } + if ((encodedPublicKey == null) || (encodedPublicKey.length == 0)) { + throw new InvalidKeyException( + "Failed to obtain X.509 encoded form of public key " + publicKey + + " of class " + publicKey.getClass().getName()); + } + return encodedPublicKey; + } + + public static List<byte[]> encodeCertificates(List<X509Certificate> certificates) + throws CertificateEncodingException { + List<byte[]> result = new ArrayList<>(certificates.size()); + for (X509Certificate certificate : certificates) { + result.add(certificate.getEncoded()); + } + return result; + } + + public static byte[] encodeAsLengthPrefixedElement(byte[] bytes) { + byte[][] adapterBytes = new byte[1][]; + adapterBytes[0] = bytes; + return encodeAsSequenceOfLengthPrefixedElements(adapterBytes); + } + + public static byte[] encodeAsSequenceOfLengthPrefixedElements(List<byte[]> sequence) { + return encodeAsSequenceOfLengthPrefixedElements( + sequence.toArray(new byte[sequence.size()][])); + } + + public static byte[] encodeAsSequenceOfLengthPrefixedElements(byte[][] sequence) { + int payloadSize = 0; + for (byte[] element : sequence) { + payloadSize += 4 + element.length; + } + ByteBuffer result = ByteBuffer.allocate(payloadSize); + result.order(ByteOrder.LITTLE_ENDIAN); + for (byte[] element : sequence) { + result.putInt(element.length); + result.put(element); + } + return result.array(); + } + + public static byte[] encodeAsSequenceOfLengthPrefixedPairsOfIntAndLengthPrefixedBytes( + List<Pair<Integer, byte[]>> sequence) { + return ApkSigningBlockUtilsLite + .encodeAsSequenceOfLengthPrefixedPairsOfIntAndLengthPrefixedBytes(sequence); + } + + /** + * Returns the APK Signature Scheme block contained in the provided APK file for the given ID + * and the additional information relevant for verifying the block against the file. + * + * @param blockId the ID value in the APK Signing Block's sequence of ID-value pairs + * identifying the appropriate block to find, e.g. the APK Signature Scheme v2 + * block ID. + * + * @throws SignatureNotFoundException if the APK is not signed using given APK Signature Scheme + * @throws IOException if an I/O error occurs while reading the APK + */ + public static SignatureInfo findSignature( + DataSource apk, ApkUtils.ZipSections zipSections, int blockId, Result result) + throws IOException, SignatureNotFoundException { + try { + return ApkSigningBlockUtilsLite.findSignature(apk, zipSections, blockId); + } catch (com.android.apksig.internal.apk.SignatureNotFoundException e) { + throw new SignatureNotFoundException(e.getMessage()); + } + } + + /** + * Generates a new DataSource representing the APK contents before the Central Directory with + * padding, if padding is requested. If the existing data entries before the Central Directory + * are already aligned, or no padding is requested, the original DataSource is used. This + * padding is used to allow for verity-based APK verification. + * + * @return {@code Pair} containing the potentially new {@code DataSource} and the amount of + * padding used. + */ + public static Pair<DataSource, Integer> generateApkSigningBlockPadding( + DataSource beforeCentralDir, + boolean apkSigningBlockPaddingSupported) { + + // Ensure APK Signing Block starts from page boundary. + int padSizeBeforeSigningBlock = 0; + if (apkSigningBlockPaddingSupported && + (beforeCentralDir.size() % ANDROID_COMMON_PAGE_ALIGNMENT_BYTES != 0)) { + padSizeBeforeSigningBlock = (int) ( + ANDROID_COMMON_PAGE_ALIGNMENT_BYTES - + beforeCentralDir.size() % ANDROID_COMMON_PAGE_ALIGNMENT_BYTES); + beforeCentralDir = new ChainedDataSource( + beforeCentralDir, + DataSources.asDataSource( + ByteBuffer.allocate(padSizeBeforeSigningBlock))); + } + return Pair.of(beforeCentralDir, padSizeBeforeSigningBlock); + } + + public static DataSource copyWithModifiedCDOffset( + DataSource beforeCentralDir, DataSource eocd) throws IOException { + + // Ensure that, when digesting, ZIP End of Central Directory record's Central Directory + // offset field is treated as pointing to the offset at which the APK Signing Block will + // start. + long centralDirOffsetForDigesting = beforeCentralDir.size(); + ByteBuffer eocdBuf = ByteBuffer.allocate((int) eocd.size()); + eocdBuf.order(ByteOrder.LITTLE_ENDIAN); + eocd.copyTo(0, (int) eocd.size(), eocdBuf); + eocdBuf.flip(); + ZipUtils.setZipEocdCentralDirectoryOffset(eocdBuf, centralDirOffsetForDigesting); + return DataSources.asDataSource(eocdBuf); + } + + public static byte[] generateApkSigningBlock( + List<Pair<byte[], Integer>> apkSignatureSchemeBlockPairs) { + // FORMAT: + // uint64: size (excluding this field) + // repeated ID-value pairs: + // uint64: size (excluding this field) + // uint32: ID + // (size - 4) bytes: value + // (extra verity ID-value for padding to make block size a multiple of 4096 bytes) + // uint64: size (same as the one above) + // uint128: magic + + int blocksSize = 0; + for (Pair<byte[], Integer> schemeBlockPair : apkSignatureSchemeBlockPairs) { + blocksSize += 8 + 4 + schemeBlockPair.getFirst().length; // size + id + value + } + + int resultSize = + 8 // size + + blocksSize + + 8 // size + + 16 // magic + ; + ByteBuffer paddingPair = null; + if (resultSize % ANDROID_COMMON_PAGE_ALIGNMENT_BYTES != 0) { + int padding = ANDROID_COMMON_PAGE_ALIGNMENT_BYTES - + (resultSize % ANDROID_COMMON_PAGE_ALIGNMENT_BYTES); + if (padding < 12) { // minimum size of an ID-value pair + padding += ANDROID_COMMON_PAGE_ALIGNMENT_BYTES; + } + paddingPair = ByteBuffer.allocate(padding).order(ByteOrder.LITTLE_ENDIAN); + paddingPair.putLong(padding - 8); + paddingPair.putInt(VERITY_PADDING_BLOCK_ID); + paddingPair.rewind(); + resultSize += padding; + } + + ByteBuffer result = ByteBuffer.allocate(resultSize); + result.order(ByteOrder.LITTLE_ENDIAN); + long blockSizeFieldValue = resultSize - 8L; + result.putLong(blockSizeFieldValue); + + for (Pair<byte[], Integer> schemeBlockPair : apkSignatureSchemeBlockPairs) { + byte[] apkSignatureSchemeBlock = schemeBlockPair.getFirst(); + int apkSignatureSchemeId = schemeBlockPair.getSecond(); + long pairSizeFieldValue = 4L + apkSignatureSchemeBlock.length; + result.putLong(pairSizeFieldValue); + result.putInt(apkSignatureSchemeId); + result.put(apkSignatureSchemeBlock); + } + + if (paddingPair != null) { + result.put(paddingPair); + } + + result.putLong(blockSizeFieldValue); + result.put(APK_SIGNING_BLOCK_MAGIC); + + return result.array(); + } + + /** + * Returns the individual APK signature blocks within the provided {@code apkSigningBlock} in a + * {@code List} of {@code Pair} instances where the first element in the {@code Pair} is the + * contents / value of the signature block and the second element is the ID of the block. + * + * @throws IOException if an error is encountered reading the provided {@code apkSigningBlock} + */ + public static List<Pair<byte[], Integer>> getApkSignatureBlocks( + DataSource apkSigningBlock) throws IOException { + // FORMAT: + // uint64: size (excluding this field) + // repeated ID-value pairs: + // uint64: size (excluding this field) + // uint32: ID + // (size - 4) bytes: value + // (extra verity ID-value for padding to make block size a multiple of 4096 bytes) + // uint64: size (same as the one above) + // uint128: magic + long apkSigningBlockSize = apkSigningBlock.size(); + if (apkSigningBlock.size() > Integer.MAX_VALUE || apkSigningBlockSize < 32) { + throw new IllegalArgumentException( + "APK signing block size out of range: " + apkSigningBlockSize); + } + // Remove the header and footer from the signing block to iterate over only the repeated + // ID-value pairs. + ByteBuffer apkSigningBlockBuffer = apkSigningBlock.getByteBuffer(8, + (int) apkSigningBlock.size() - 32); + apkSigningBlockBuffer.order(ByteOrder.LITTLE_ENDIAN); + List<Pair<byte[], Integer>> signatureBlocks = new ArrayList<>(); + while (apkSigningBlockBuffer.hasRemaining()) { + long blockLength = apkSigningBlockBuffer.getLong(); + if (blockLength > Integer.MAX_VALUE || blockLength < 4) { + throw new IllegalArgumentException( + "Block index " + (signatureBlocks.size() + 1) + " size out of range: " + + blockLength); + } + int blockId = apkSigningBlockBuffer.getInt(); + // Since the block ID has already been read from the signature block read the next + // blockLength - 4 bytes as the value. + byte[] blockValue = new byte[(int) blockLength - 4]; + apkSigningBlockBuffer.get(blockValue); + signatureBlocks.add(Pair.of(blockValue, blockId)); + } + return signatureBlocks; + } + + /** + * Returns the individual APK signers within the provided {@code signatureBlock} in a {@code + * List} of {@code Pair} instances where the first element is a {@code List} of {@link + * X509Certificate}s and the second element is a byte array of the individual signer's block. + * + * <p>This method supports any signature block that adheres to the following format up to the + * signing certificate(s): + * <pre> + * * length-prefixed sequence of length-prefixed signers + * * length-prefixed signed data + * * length-prefixed sequence of length-prefixed digests: + * * uint32: signature algorithm ID + * * length-prefixed bytes: digest of contents + * * length-prefixed sequence of certificates: + * * length-prefixed bytes: X.509 certificate (ASN.1 DER encoded). + * </pre> + * + * <p>Note, this is a convenience method to obtain any signers from an existing signature block; + * the signature of each signer will not be verified. + * + * @throws ApkFormatException if an error is encountered while parsing the provided {@code + * signatureBlock} + * @throws CertificateException if the signing certificate(s) within an individual signer block + * cannot be parsed + */ + public static List<Pair<List<X509Certificate>, byte[]>> getApkSignatureBlockSigners( + byte[] signatureBlock) throws ApkFormatException, CertificateException { + ByteBuffer signatureBlockBuffer = ByteBuffer.wrap(signatureBlock); + signatureBlockBuffer.order(ByteOrder.LITTLE_ENDIAN); + ByteBuffer signersBuffer = getLengthPrefixedSlice(signatureBlockBuffer); + List<Pair<List<X509Certificate>, byte[]>> signers = new ArrayList<>(); + while (signersBuffer.hasRemaining()) { + // Parse the next signer block, save all of its bytes for the resulting List, and + // rewind the buffer to allow the signing certificate(s) to be parsed. + ByteBuffer signer = getLengthPrefixedSlice(signersBuffer); + byte[] signerBytes = new byte[signer.remaining()]; + signer.get(signerBytes); + signer.rewind(); + + ByteBuffer signedData = getLengthPrefixedSlice(signer); + // The first length prefixed slice is the sequence of digests which are not required + // when obtaining the signing certificate(s). + getLengthPrefixedSlice(signedData); + ByteBuffer certificatesBuffer = getLengthPrefixedSlice(signedData); + List<X509Certificate> certificates = new ArrayList<>(); + while (certificatesBuffer.hasRemaining()) { + int certLength = certificatesBuffer.getInt(); + byte[] certBytes = new byte[certLength]; + if (certLength > certificatesBuffer.remaining()) { + throw new IllegalArgumentException( + "Cert index " + (certificates.size() + 1) + " under signer index " + + (signers.size() + 1) + " size out of range: " + certLength); + } + certificatesBuffer.get(certBytes); + GuaranteedEncodedFormX509Certificate signerCert = + new GuaranteedEncodedFormX509Certificate( + X509CertificateUtils.generateCertificate(certBytes), certBytes); + certificates.add(signerCert); + } + signers.add(Pair.of(certificates, signerBytes)); + } + return signers; + } + + /** + * Computes the digests of the given APK components according to the algorithms specified in the + * given SignerConfigs. + * + * @param signerConfigs signer configurations, one for each signer At least one signer config + * must be provided. + * + * @throws IOException if an I/O error occurs + * @throws NoSuchAlgorithmException if a required cryptographic algorithm implementation is + * missing + * @throws SignatureException if an error occurs when computing digests of generating + * signatures + */ + public static Pair<List<SignerConfig>, Map<ContentDigestAlgorithm, byte[]>> + computeContentDigests( + RunnablesExecutor executor, + DataSource beforeCentralDir, + DataSource centralDir, + DataSource eocd, + List<SignerConfig> signerConfigs) + throws IOException, NoSuchAlgorithmException, SignatureException { + if (signerConfigs.isEmpty()) { + throw new IllegalArgumentException( + "No signer configs provided. At least one is required"); + } + + // Figure out which digest(s) to use for APK contents. + Set<ContentDigestAlgorithm> contentDigestAlgorithms = new HashSet<>(1); + for (SignerConfig signerConfig : signerConfigs) { + for (SignatureAlgorithm signatureAlgorithm : signerConfig.signatureAlgorithms) { + contentDigestAlgorithms.add(signatureAlgorithm.getContentDigestAlgorithm()); + } + } + + // Compute digests of APK contents. + Map<ContentDigestAlgorithm, byte[]> contentDigests; // digest algorithm ID -> digest + try { + contentDigests = + computeContentDigests( + executor, + contentDigestAlgorithms, + beforeCentralDir, + centralDir, + eocd); + } catch (IOException e) { + throw new IOException("Failed to read APK being signed", e); + } catch (DigestException e) { + throw new SignatureException("Failed to compute digests of APK", e); + } + + // Sign the digests and wrap the signatures and signer info into an APK Signing Block. + return Pair.of(signerConfigs, contentDigests); + } + + /** + * Returns the subset of signatures which are expected to be verified by at least one Android + * platform version in the {@code [minSdkVersion, maxSdkVersion]} range. The returned result is + * guaranteed to contain at least one signature. + * + * <p>Each Android platform version typically verifies exactly one signature from the provided + * {@code signatures} set. This method returns the set of these signatures collected over all + * requested platform versions. As a result, the result may contain more than one signature. + * + * @throws NoSupportedSignaturesException if no supported signatures were + * found for an Android platform version in the range. + */ + public static <T extends ApkSupportedSignature> List<T> getSignaturesToVerify( + List<T> signatures, int minSdkVersion, int maxSdkVersion) + throws NoSupportedSignaturesException { + return getSignaturesToVerify(signatures, minSdkVersion, maxSdkVersion, false); + } + + /** + * Returns the subset of signatures which are expected to be verified by at least one Android + * platform version in the {@code [minSdkVersion, maxSdkVersion]} range. The returned result is + * guaranteed to contain at least one signature. + * + * <p>{@code onlyRequireJcaSupport} can be set to true for cases that only require verifying a + * signature within the signing block using the standard JCA. + * + * <p>Each Android platform version typically verifies exactly one signature from the provided + * {@code signatures} set. This method returns the set of these signatures collected over all + * requested platform versions. As a result, the result may contain more than one signature. + * + * @throws NoSupportedSignaturesException if no supported signatures were + * found for an Android platform version in the range. + */ + public static <T extends ApkSupportedSignature> List<T> getSignaturesToVerify( + List<T> signatures, int minSdkVersion, int maxSdkVersion, + boolean onlyRequireJcaSupport) throws NoSupportedSignaturesException { + try { + return ApkSigningBlockUtilsLite.getSignaturesToVerify(signatures, minSdkVersion, + maxSdkVersion, onlyRequireJcaSupport); + } catch (NoApkSupportedSignaturesException e) { + throw new NoSupportedSignaturesException(e.getMessage()); + } + } + + public static class NoSupportedSignaturesException extends NoApkSupportedSignaturesException { + public NoSupportedSignaturesException(String message) { + super(message); + } + } + + public static class SignatureNotFoundException extends Exception { + private static final long serialVersionUID = 1L; + + public SignatureNotFoundException(String message) { + super(message); + } + + public SignatureNotFoundException(String message, Throwable cause) { + super(message, cause); + } + } + + /** + * uses the SignatureAlgorithms in the provided signerConfig to sign the provided data + * + * @return list of signature algorithm IDs and their corresponding signatures over the data. + */ + public static List<Pair<Integer, byte[]>> generateSignaturesOverData( + SignerConfig signerConfig, byte[] data) + throws InvalidKeyException, NoSuchAlgorithmException, SignatureException { + List<Pair<Integer, byte[]>> signatures = + new ArrayList<>(signerConfig.signatureAlgorithms.size()); + PublicKey publicKey = signerConfig.certificates.get(0).getPublicKey(); + for (SignatureAlgorithm signatureAlgorithm : signerConfig.signatureAlgorithms) { + Pair<String, ? extends AlgorithmParameterSpec> sigAlgAndParams = + signatureAlgorithm.getJcaSignatureAlgorithmAndParams(); + String jcaSignatureAlgorithm = sigAlgAndParams.getFirst(); + AlgorithmParameterSpec jcaSignatureAlgorithmParams = sigAlgAndParams.getSecond(); + byte[] signatureBytes; + try { + Signature signature = Signature.getInstance(jcaSignatureAlgorithm); + signature.initSign(signerConfig.privateKey); + if (jcaSignatureAlgorithmParams != null) { + signature.setParameter(jcaSignatureAlgorithmParams); + } + signature.update(data); + signatureBytes = signature.sign(); + } catch (InvalidKeyException e) { + throw new InvalidKeyException("Failed to sign using " + jcaSignatureAlgorithm, e); + } catch (InvalidAlgorithmParameterException | SignatureException e) { + throw new SignatureException("Failed to sign using " + jcaSignatureAlgorithm, e); + } + + try { + Signature signature = Signature.getInstance(jcaSignatureAlgorithm); + signature.initVerify(publicKey); + if (jcaSignatureAlgorithmParams != null) { + signature.setParameter(jcaSignatureAlgorithmParams); + } + signature.update(data); + if (!signature.verify(signatureBytes)) { + throw new SignatureException("Failed to verify generated " + + jcaSignatureAlgorithm + + " signature using public key from certificate"); + } + } catch (InvalidKeyException e) { + throw new InvalidKeyException( + "Failed to verify generated " + jcaSignatureAlgorithm + " signature using" + + " public key from certificate", e); + } catch (InvalidAlgorithmParameterException | SignatureException e) { + throw new SignatureException( + "Failed to verify generated " + jcaSignatureAlgorithm + " signature using" + + " public key from certificate", e); + } + + signatures.add(Pair.of(signatureAlgorithm.getId(), signatureBytes)); + } + return signatures; + } + + /** + * Wrap the signature according to CMS PKCS #7 RFC 5652. + * The high-level simplified structure is as follows: + * // ContentInfo + * // digestAlgorithm + * // SignedData + * // bag of certificates + * // SignerInfo + * // signing cert issuer and serial number (for locating the cert in the above bag) + * // digestAlgorithm + * // signatureAlgorithm + * // signature + * + * @throws Asn1EncodingException if the ASN.1 structure could not be encoded + */ + public static byte[] generatePkcs7DerEncodedMessage( + byte[] signatureBytes, ByteBuffer data, List<X509Certificate> signerCerts, + AlgorithmIdentifier digestAlgorithmId, AlgorithmIdentifier signatureAlgorithmId) + throws Asn1EncodingException, CertificateEncodingException { + SignerInfo signerInfo = new SignerInfo(); + signerInfo.version = 1; + X509Certificate signingCert = signerCerts.get(0); + X500Principal signerCertIssuer = signingCert.getIssuerX500Principal(); + signerInfo.sid = + new SignerIdentifier( + new IssuerAndSerialNumber( + new Asn1OpaqueObject(signerCertIssuer.getEncoded()), + signingCert.getSerialNumber())); + + signerInfo.digestAlgorithm = digestAlgorithmId; + signerInfo.signatureAlgorithm = signatureAlgorithmId; + signerInfo.signature = ByteBuffer.wrap(signatureBytes); + + SignedData signedData = new SignedData(); + signedData.certificates = new ArrayList<>(signerCerts.size()); + for (X509Certificate cert : signerCerts) { + signedData.certificates.add(new Asn1OpaqueObject(cert.getEncoded())); + } + signedData.version = 1; + signedData.digestAlgorithms = Collections.singletonList(digestAlgorithmId); + signedData.encapContentInfo = new EncapsulatedContentInfo(Pkcs7Constants.OID_DATA); + // If data is not null, data will be embedded as is in the result -- an attached pcsk7 + signedData.encapContentInfo.content = data; + signedData.signerInfos = Collections.singletonList(signerInfo); + ContentInfo contentInfo = new ContentInfo(); + contentInfo.contentType = Pkcs7Constants.OID_SIGNED_DATA; + contentInfo.content = new Asn1OpaqueObject(Asn1DerEncoder.encode(signedData)); + return Asn1DerEncoder.encode(contentInfo); + } + + /** + * Picks the correct v2/v3 digest for v4 signature verification. + * + * Keep in sync with pickBestDigestForV4 in framework's ApkSigningBlockUtils. + */ + public static byte[] pickBestDigestForV4(Map<ContentDigestAlgorithm, byte[]> contentDigests) { + for (ContentDigestAlgorithm algo : V4_CONTENT_DIGEST_ALGORITHMS) { + if (contentDigests.containsKey(algo)) { + return contentDigests.get(algo); + } + } + return null; + } + + /** + * Signer configuration. + */ + public static class SignerConfig { + /** Private key. */ + public PrivateKey privateKey; + + /** + * Certificates, with the first certificate containing the public key corresponding to + * {@link #privateKey}. + */ + public List<X509Certificate> certificates; + + /** + * List of signature algorithms with which to sign. + */ + public List<SignatureAlgorithm> signatureAlgorithms; + + public int minSdkVersion; + public int maxSdkVersion; + public boolean signerTargetsDevRelease; + public SigningCertificateLineage signingCertificateLineage; + } + + public static class Result extends ApkSigResult { + public SigningCertificateLineage signingCertificateLineage = null; + public final List<Result.SignerInfo> signers = new ArrayList<>(); + private final List<ApkVerifier.IssueWithParams> mWarnings = new ArrayList<>(); + private final List<ApkVerifier.IssueWithParams> mErrors = new ArrayList<>(); + + public Result(int signatureSchemeVersion) { + super(signatureSchemeVersion); + } + + public boolean containsErrors() { + if (!mErrors.isEmpty()) { + return true; + } + if (!signers.isEmpty()) { + for (Result.SignerInfo signer : signers) { + if (signer.containsErrors()) { + return true; + } + } + } + return false; + } + + public boolean containsWarnings() { + if (!mWarnings.isEmpty()) { + return true; + } + if (!signers.isEmpty()) { + for (Result.SignerInfo signer : signers) { + if (signer.containsWarnings()) { + return true; + } + } + } + return false; + } + + public void addError(ApkVerifier.Issue msg, Object... parameters) { + mErrors.add(new ApkVerifier.IssueWithParams(msg, parameters)); + } + + public void addWarning(ApkVerifier.Issue msg, Object... parameters) { + mWarnings.add(new ApkVerifier.IssueWithParams(msg, parameters)); + } + + @Override + public List<ApkVerifier.IssueWithParams> getErrors() { + return mErrors; + } + + @Override + public List<ApkVerifier.IssueWithParams> getWarnings() { + return mWarnings; + } + + public static class SignerInfo extends ApkSignerInfo { + public List<ContentDigest> contentDigests = new ArrayList<>(); + public Map<ContentDigestAlgorithm, byte[]> verifiedContentDigests = new HashMap<>(); + public List<Signature> signatures = new ArrayList<>(); + public Map<SignatureAlgorithm, byte[]> verifiedSignatures = new HashMap<>(); + public List<AdditionalAttribute> additionalAttributes = new ArrayList<>(); + public byte[] signedData; + public int minSdkVersion; + public int maxSdkVersion; + public SigningCertificateLineage signingCertificateLineage; + + private final List<ApkVerifier.IssueWithParams> mWarnings = new ArrayList<>(); + private final List<ApkVerifier.IssueWithParams> mErrors = new ArrayList<>(); + + public void addError(ApkVerifier.Issue msg, Object... parameters) { + mErrors.add(new ApkVerifier.IssueWithParams(msg, parameters)); + } + + public void addWarning(ApkVerifier.Issue msg, Object... parameters) { + mWarnings.add(new ApkVerifier.IssueWithParams(msg, parameters)); + } + + public boolean containsErrors() { + return !mErrors.isEmpty(); + } + + public boolean containsWarnings() { + return !mWarnings.isEmpty(); + } + + public List<ApkVerifier.IssueWithParams> getErrors() { + return mErrors; + } + + public List<ApkVerifier.IssueWithParams> getWarnings() { + return mWarnings; + } + + public static class ContentDigest { + private final int mSignatureAlgorithmId; + private final byte[] mValue; + + public ContentDigest(int signatureAlgorithmId, byte[] value) { + mSignatureAlgorithmId = signatureAlgorithmId; + mValue = value; + } + + public int getSignatureAlgorithmId() { + return mSignatureAlgorithmId; + } + + public byte[] getValue() { + return mValue; + } + } + + public static class Signature { + private final int mAlgorithmId; + private final byte[] mValue; + + public Signature(int algorithmId, byte[] value) { + mAlgorithmId = algorithmId; + mValue = value; + } + + public int getAlgorithmId() { + return mAlgorithmId; + } + + public byte[] getValue() { + return mValue; + } + } + + public static class AdditionalAttribute { + private final int mId; + private final byte[] mValue; + + public AdditionalAttribute(int id, byte[] value) { + mId = id; + mValue = value.clone(); + } + + public int getId() { + return mId; + } + + public byte[] getValue() { + return mValue.clone(); + } + } + } + } + + public static class SupportedSignature extends ApkSupportedSignature { + public SupportedSignature(SignatureAlgorithm algorithm, byte[] signature) { + super(algorithm, signature); + } + } + + public static class SigningSchemeBlockAndDigests { + public final Pair<byte[], Integer> signingSchemeBlock; + public final Map<ContentDigestAlgorithm, byte[]> digestInfo; + + public SigningSchemeBlockAndDigests( + Pair<byte[], Integer> signingSchemeBlock, + Map<ContentDigestAlgorithm, byte[]> digestInfo) { + this.signingSchemeBlock = signingSchemeBlock; + this.digestInfo = digestInfo; + } + } +} diff --git a/platform/android/java/editor/src/main/java/com/android/apksig/internal/apk/ApkSigningBlockUtilsLite.java b/platform/android/java/editor/src/main/java/com/android/apksig/internal/apk/ApkSigningBlockUtilsLite.java new file mode 100644 index 0000000000..40ae94798a --- /dev/null +++ b/platform/android/java/editor/src/main/java/com/android/apksig/internal/apk/ApkSigningBlockUtilsLite.java @@ -0,0 +1,393 @@ +/* + * Copyright (C) 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.apksig.internal.apk; + +import com.android.apksig.apk.ApkFormatException; +import com.android.apksig.apk.ApkSigningBlockNotFoundException; +import com.android.apksig.apk.ApkUtilsLite; +import com.android.apksig.internal.util.Pair; +import com.android.apksig.util.DataSource; +import com.android.apksig.zip.ZipSections; + +import java.io.IOException; +import java.nio.BufferUnderflowException; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * Lightweight version of the ApkSigningBlockUtils for clients that only require a subset of the + * utility functionality. + */ +public class ApkSigningBlockUtilsLite { + private ApkSigningBlockUtilsLite() {} + + private static final char[] HEX_DIGITS = "0123456789abcdef".toCharArray(); + /** + * Returns the APK Signature Scheme block contained in the provided APK file for the given ID + * and the additional information relevant for verifying the block against the file. + * + * @param blockId the ID value in the APK Signing Block's sequence of ID-value pairs + * identifying the appropriate block to find, e.g. the APK Signature Scheme v2 + * block ID. + * + * @throws SignatureNotFoundException if the APK is not signed using given APK Signature Scheme + * @throws IOException if an I/O error occurs while reading the APK + */ + public static SignatureInfo findSignature( + DataSource apk, ZipSections zipSections, int blockId) + throws IOException, SignatureNotFoundException { + // Find the APK Signing Block. + DataSource apkSigningBlock; + long apkSigningBlockOffset; + try { + ApkUtilsLite.ApkSigningBlock apkSigningBlockInfo = + ApkUtilsLite.findApkSigningBlock(apk, zipSections); + apkSigningBlockOffset = apkSigningBlockInfo.getStartOffset(); + apkSigningBlock = apkSigningBlockInfo.getContents(); + } catch (ApkSigningBlockNotFoundException e) { + throw new SignatureNotFoundException(e.getMessage(), e); + } + ByteBuffer apkSigningBlockBuf = + apkSigningBlock.getByteBuffer(0, (int) apkSigningBlock.size()); + apkSigningBlockBuf.order(ByteOrder.LITTLE_ENDIAN); + + // Find the APK Signature Scheme Block inside the APK Signing Block. + ByteBuffer apkSignatureSchemeBlock = + findApkSignatureSchemeBlock(apkSigningBlockBuf, blockId); + return new SignatureInfo( + apkSignatureSchemeBlock, + apkSigningBlockOffset, + zipSections.getZipCentralDirectoryOffset(), + zipSections.getZipEndOfCentralDirectoryOffset(), + zipSections.getZipEndOfCentralDirectory()); + } + + public static ByteBuffer findApkSignatureSchemeBlock( + ByteBuffer apkSigningBlock, + int blockId) throws SignatureNotFoundException { + checkByteOrderLittleEndian(apkSigningBlock); + // FORMAT: + // OFFSET DATA TYPE DESCRIPTION + // * @+0 bytes uint64: size in bytes (excluding this field) + // * @+8 bytes pairs + // * @-24 bytes uint64: size in bytes (same as the one above) + // * @-16 bytes uint128: magic + ByteBuffer pairs = sliceFromTo(apkSigningBlock, 8, apkSigningBlock.capacity() - 24); + + int entryCount = 0; + while (pairs.hasRemaining()) { + entryCount++; + if (pairs.remaining() < 8) { + throw new SignatureNotFoundException( + "Insufficient data to read size of APK Signing Block entry #" + entryCount); + } + long lenLong = pairs.getLong(); + if ((lenLong < 4) || (lenLong > Integer.MAX_VALUE)) { + throw new SignatureNotFoundException( + "APK Signing Block entry #" + entryCount + + " size out of range: " + lenLong); + } + int len = (int) lenLong; + int nextEntryPos = pairs.position() + len; + if (len > pairs.remaining()) { + throw new SignatureNotFoundException( + "APK Signing Block entry #" + entryCount + " size out of range: " + len + + ", available: " + pairs.remaining()); + } + int id = pairs.getInt(); + if (id == blockId) { + return getByteBuffer(pairs, len - 4); + } + pairs.position(nextEntryPos); + } + + throw new SignatureNotFoundException( + "No APK Signature Scheme block in APK Signing Block with ID: " + blockId); + } + + public static void checkByteOrderLittleEndian(ByteBuffer buffer) { + if (buffer.order() != ByteOrder.LITTLE_ENDIAN) { + throw new IllegalArgumentException("ByteBuffer byte order must be little endian"); + } + } + + /** + * Returns the subset of signatures which are expected to be verified by at least one Android + * platform version in the {@code [minSdkVersion, maxSdkVersion]} range. The returned result is + * guaranteed to contain at least one signature. + * + * <p>Each Android platform version typically verifies exactly one signature from the provided + * {@code signatures} set. This method returns the set of these signatures collected over all + * requested platform versions. As a result, the result may contain more than one signature. + * + * @throws NoApkSupportedSignaturesException if no supported signatures were + * found for an Android platform version in the range. + */ + public static <T extends ApkSupportedSignature> List<T> getSignaturesToVerify( + List<T> signatures, int minSdkVersion, int maxSdkVersion) + throws NoApkSupportedSignaturesException { + return getSignaturesToVerify(signatures, minSdkVersion, maxSdkVersion, false); + } + + /** + * Returns the subset of signatures which are expected to be verified by at least one Android + * platform version in the {@code [minSdkVersion, maxSdkVersion]} range. The returned result is + * guaranteed to contain at least one signature. + * + * <p>{@code onlyRequireJcaSupport} can be set to true for cases that only require verifying a + * signature within the signing block using the standard JCA. + * + * <p>Each Android platform version typically verifies exactly one signature from the provided + * {@code signatures} set. This method returns the set of these signatures collected over all + * requested platform versions. As a result, the result may contain more than one signature. + * + * @throws NoApkSupportedSignaturesException if no supported signatures were + * found for an Android platform version in the range. + */ + public static <T extends ApkSupportedSignature> List<T> getSignaturesToVerify( + List<T> signatures, int minSdkVersion, int maxSdkVersion, + boolean onlyRequireJcaSupport) throws + NoApkSupportedSignaturesException { + // Pick the signature with the strongest algorithm at all required SDK versions, to mimic + // Android's behavior on those versions. + // + // Here we assume that, once introduced, a signature algorithm continues to be supported in + // all future Android versions. We also assume that the better-than relationship between + // algorithms is exactly the same on all Android platform versions (except that older + // platforms might support fewer algorithms). If these assumption are no longer true, the + // logic here will need to change accordingly. + Map<Integer, T> + bestSigAlgorithmOnSdkVersion = new HashMap<>(); + int minProvidedSignaturesVersion = Integer.MAX_VALUE; + for (T sig : signatures) { + SignatureAlgorithm sigAlgorithm = sig.algorithm; + int sigMinSdkVersion = onlyRequireJcaSupport ? sigAlgorithm.getJcaSigAlgMinSdkVersion() + : sigAlgorithm.getMinSdkVersion(); + if (sigMinSdkVersion > maxSdkVersion) { + continue; + } + if (sigMinSdkVersion < minProvidedSignaturesVersion) { + minProvidedSignaturesVersion = sigMinSdkVersion; + } + + T candidate = bestSigAlgorithmOnSdkVersion.get(sigMinSdkVersion); + if ((candidate == null) + || (compareSignatureAlgorithm( + sigAlgorithm, candidate.algorithm) > 0)) { + bestSigAlgorithmOnSdkVersion.put(sigMinSdkVersion, sig); + } + } + + // Must have some supported signature algorithms for minSdkVersion. + if (minSdkVersion < minProvidedSignaturesVersion) { + throw new NoApkSupportedSignaturesException( + "Minimum provided signature version " + minProvidedSignaturesVersion + + " > minSdkVersion " + minSdkVersion); + } + if (bestSigAlgorithmOnSdkVersion.isEmpty()) { + throw new NoApkSupportedSignaturesException("No supported signature"); + } + List<T> signaturesToVerify = + new ArrayList<>(bestSigAlgorithmOnSdkVersion.values()); + Collections.sort( + signaturesToVerify, + (sig1, sig2) -> Integer.compare(sig1.algorithm.getId(), sig2.algorithm.getId())); + return signaturesToVerify; + } + + /** + * Returns positive number if {@code alg1} is preferred over {@code alg2}, {@code -1} if + * {@code alg2} is preferred over {@code alg1}, and {@code 0} if there is no preference. + */ + public static int compareSignatureAlgorithm(SignatureAlgorithm alg1, SignatureAlgorithm alg2) { + ContentDigestAlgorithm digestAlg1 = alg1.getContentDigestAlgorithm(); + ContentDigestAlgorithm digestAlg2 = alg2.getContentDigestAlgorithm(); + return compareContentDigestAlgorithm(digestAlg1, digestAlg2); + } + + /** + * Returns a positive number if {@code alg1} is preferred over {@code alg2}, a negative number + * if {@code alg2} is preferred over {@code alg1}, or {@code 0} if there is no preference. + */ + private static int compareContentDigestAlgorithm( + ContentDigestAlgorithm alg1, + ContentDigestAlgorithm alg2) { + switch (alg1) { + case CHUNKED_SHA256: + switch (alg2) { + case CHUNKED_SHA256: + return 0; + case CHUNKED_SHA512: + case VERITY_CHUNKED_SHA256: + return -1; + default: + throw new IllegalArgumentException("Unknown alg2: " + alg2); + } + case CHUNKED_SHA512: + switch (alg2) { + case CHUNKED_SHA256: + case VERITY_CHUNKED_SHA256: + return 1; + case CHUNKED_SHA512: + return 0; + default: + throw new IllegalArgumentException("Unknown alg2: " + alg2); + } + case VERITY_CHUNKED_SHA256: + switch (alg2) { + case CHUNKED_SHA256: + return 1; + case VERITY_CHUNKED_SHA256: + return 0; + case CHUNKED_SHA512: + return -1; + default: + throw new IllegalArgumentException("Unknown alg2: " + alg2); + } + default: + throw new IllegalArgumentException("Unknown alg1: " + alg1); + } + } + + /** + * Returns new byte buffer whose content is a shared subsequence of this buffer's content + * between the specified start (inclusive) and end (exclusive) positions. As opposed to + * {@link ByteBuffer#slice()}, the returned buffer's byte order is the same as the source + * buffer's byte order. + */ + private static ByteBuffer sliceFromTo(ByteBuffer source, int start, int end) { + if (start < 0) { + throw new IllegalArgumentException("start: " + start); + } + if (end < start) { + throw new IllegalArgumentException("end < start: " + end + " < " + start); + } + int capacity = source.capacity(); + if (end > source.capacity()) { + throw new IllegalArgumentException("end > capacity: " + end + " > " + capacity); + } + int originalLimit = source.limit(); + int originalPosition = source.position(); + try { + source.position(0); + source.limit(end); + source.position(start); + ByteBuffer result = source.slice(); + result.order(source.order()); + return result; + } finally { + source.position(0); + source.limit(originalLimit); + source.position(originalPosition); + } + } + + /** + * Relative <em>get</em> method for reading {@code size} number of bytes from the current + * position of this buffer. + * + * <p>This method reads the next {@code size} bytes at this buffer's current position, + * returning them as a {@code ByteBuffer} with start set to 0, limit and capacity set to + * {@code size}, byte order set to this buffer's byte order; and then increments the position by + * {@code size}. + */ + private static ByteBuffer getByteBuffer(ByteBuffer source, int size) { + if (size < 0) { + throw new IllegalArgumentException("size: " + size); + } + int originalLimit = source.limit(); + int position = source.position(); + int limit = position + size; + if ((limit < position) || (limit > originalLimit)) { + throw new BufferUnderflowException(); + } + source.limit(limit); + try { + ByteBuffer result = source.slice(); + result.order(source.order()); + source.position(limit); + return result; + } finally { + source.limit(originalLimit); + } + } + + public static String toHex(byte[] value) { + StringBuilder sb = new StringBuilder(value.length * 2); + int len = value.length; + for (int i = 0; i < len; i++) { + int hi = (value[i] & 0xff) >>> 4; + int lo = value[i] & 0x0f; + sb.append(HEX_DIGITS[hi]).append(HEX_DIGITS[lo]); + } + return sb.toString(); + } + + public static ByteBuffer getLengthPrefixedSlice(ByteBuffer source) throws ApkFormatException { + if (source.remaining() < 4) { + throw new ApkFormatException( + "Remaining buffer too short to contain length of length-prefixed field" + + ". Remaining: " + source.remaining()); + } + int len = source.getInt(); + if (len < 0) { + throw new IllegalArgumentException("Negative length"); + } else if (len > source.remaining()) { + throw new ApkFormatException( + "Length-prefixed field longer than remaining buffer" + + ". Field length: " + len + ", remaining: " + source.remaining()); + } + return getByteBuffer(source, len); + } + + public static byte[] readLengthPrefixedByteArray(ByteBuffer buf) throws ApkFormatException { + int len = buf.getInt(); + if (len < 0) { + throw new ApkFormatException("Negative length"); + } else if (len > buf.remaining()) { + throw new ApkFormatException( + "Underflow while reading length-prefixed value. Length: " + len + + ", available: " + buf.remaining()); + } + byte[] result = new byte[len]; + buf.get(result); + return result; + } + + public static byte[] encodeAsSequenceOfLengthPrefixedPairsOfIntAndLengthPrefixedBytes( + List<Pair<Integer, byte[]>> sequence) { + int resultSize = 0; + for (Pair<Integer, byte[]> element : sequence) { + resultSize += 12 + element.getSecond().length; + } + ByteBuffer result = ByteBuffer.allocate(resultSize); + result.order(ByteOrder.LITTLE_ENDIAN); + for (Pair<Integer, byte[]> element : sequence) { + byte[] second = element.getSecond(); + result.putInt(8 + second.length); + result.putInt(element.getFirst()); + result.putInt(second.length); + result.put(second); + } + return result.array(); + } +} diff --git a/platform/android/java/editor/src/main/java/com/android/apksig/internal/apk/ApkSupportedSignature.java b/platform/android/java/editor/src/main/java/com/android/apksig/internal/apk/ApkSupportedSignature.java new file mode 100644 index 0000000000..61652a435e --- /dev/null +++ b/platform/android/java/editor/src/main/java/com/android/apksig/internal/apk/ApkSupportedSignature.java @@ -0,0 +1,35 @@ +/* + * Copyright (C) 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.apksig.internal.apk; + +/** + * Base implementation of a supported signature for an APK. + */ +public class ApkSupportedSignature { + public final SignatureAlgorithm algorithm; + public final byte[] signature; + + /** + * Constructs a new supported signature using the provided {@code algorithm} and {@code + * signature} bytes. + */ + public ApkSupportedSignature(SignatureAlgorithm algorithm, byte[] signature) { + this.algorithm = algorithm; + this.signature = signature; + } + +} diff --git a/platform/android/java/editor/src/main/java/com/android/apksig/internal/apk/ContentDigestAlgorithm.java b/platform/android/java/editor/src/main/java/com/android/apksig/internal/apk/ContentDigestAlgorithm.java new file mode 100644 index 0000000000..b806d1e420 --- /dev/null +++ b/platform/android/java/editor/src/main/java/com/android/apksig/internal/apk/ContentDigestAlgorithm.java @@ -0,0 +1,61 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.apksig.internal.apk; + +/** APK Signature Scheme v2 content digest algorithm. */ +public enum ContentDigestAlgorithm { + /** SHA2-256 over 1 MB chunks. */ + CHUNKED_SHA256(1, "SHA-256", 256 / 8), + + /** SHA2-512 over 1 MB chunks. */ + CHUNKED_SHA512(2, "SHA-512", 512 / 8), + + /** SHA2-256 over 4 KB chunks for APK verity. */ + VERITY_CHUNKED_SHA256(3, "SHA-256", 256 / 8), + + /** Non-chunk SHA2-256. */ + SHA256(4, "SHA-256", 256 / 8); + + private final int mId; + private final String mJcaMessageDigestAlgorithm; + private final int mChunkDigestOutputSizeBytes; + + private ContentDigestAlgorithm( + int id, String jcaMessageDigestAlgorithm, int chunkDigestOutputSizeBytes) { + mId = id; + mJcaMessageDigestAlgorithm = jcaMessageDigestAlgorithm; + mChunkDigestOutputSizeBytes = chunkDigestOutputSizeBytes; + } + + /** Returns the ID of the digest algorithm used on the APK. */ + public int getId() { + return mId; + } + + /** + * Returns the {@link java.security.MessageDigest} algorithm used for computing digests of + * chunks by this content digest algorithm. + */ + String getJcaMessageDigestAlgorithm() { + return mJcaMessageDigestAlgorithm; + } + + /** Returns the size (in bytes) of the digest of a chunk of content. */ + int getChunkDigestOutputSizeBytes() { + return mChunkDigestOutputSizeBytes; + } +} diff --git a/platform/android/java/editor/src/main/java/com/android/apksig/internal/apk/NoApkSupportedSignaturesException.java b/platform/android/java/editor/src/main/java/com/android/apksig/internal/apk/NoApkSupportedSignaturesException.java new file mode 100644 index 0000000000..52c6085c5f --- /dev/null +++ b/platform/android/java/editor/src/main/java/com/android/apksig/internal/apk/NoApkSupportedSignaturesException.java @@ -0,0 +1,27 @@ +/* + * Copyright (C) 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.apksig.internal.apk; + +/** + * Base exception that is thrown when there are no signatures that support the full range of + * requested platform versions. + */ +public class NoApkSupportedSignaturesException extends Exception { + public NoApkSupportedSignaturesException(String message) { + super(message); + } +} diff --git a/platform/android/java/editor/src/main/java/com/android/apksig/internal/apk/SignatureAlgorithm.java b/platform/android/java/editor/src/main/java/com/android/apksig/internal/apk/SignatureAlgorithm.java new file mode 100644 index 0000000000..804eb37bdd --- /dev/null +++ b/platform/android/java/editor/src/main/java/com/android/apksig/internal/apk/SignatureAlgorithm.java @@ -0,0 +1,225 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.apksig.internal.apk; + +import com.android.apksig.internal.util.AndroidSdkVersion; +import com.android.apksig.internal.util.Pair; +import java.security.spec.AlgorithmParameterSpec; +import java.security.spec.MGF1ParameterSpec; +import java.security.spec.PSSParameterSpec; + +/** + * APK Signing Block signature algorithm. + */ +public enum SignatureAlgorithm { + // TODO reserve the 0x0000 ID to mean null + /** + * RSASSA-PSS with SHA2-256 digest, SHA2-256 MGF1, 32 bytes of salt, trailer: 0xbc, content + * digested using SHA2-256 in 1 MB chunks. + */ + RSA_PSS_WITH_SHA256( + 0x0101, + ContentDigestAlgorithm.CHUNKED_SHA256, + "RSA", + Pair.of("SHA256withRSA/PSS", + new PSSParameterSpec( + "SHA-256", "MGF1", MGF1ParameterSpec.SHA256, 256 / 8, 1)), + AndroidSdkVersion.N, + AndroidSdkVersion.M), + + /** + * RSASSA-PSS with SHA2-512 digest, SHA2-512 MGF1, 64 bytes of salt, trailer: 0xbc, content + * digested using SHA2-512 in 1 MB chunks. + */ + RSA_PSS_WITH_SHA512( + 0x0102, + ContentDigestAlgorithm.CHUNKED_SHA512, + "RSA", + Pair.of( + "SHA512withRSA/PSS", + new PSSParameterSpec( + "SHA-512", "MGF1", MGF1ParameterSpec.SHA512, 512 / 8, 1)), + AndroidSdkVersion.N, + AndroidSdkVersion.M), + + /** RSASSA-PKCS1-v1_5 with SHA2-256 digest, content digested using SHA2-256 in 1 MB chunks. */ + RSA_PKCS1_V1_5_WITH_SHA256( + 0x0103, + ContentDigestAlgorithm.CHUNKED_SHA256, + "RSA", + Pair.of("SHA256withRSA", null), + AndroidSdkVersion.N, + AndroidSdkVersion.INITIAL_RELEASE), + + /** RSASSA-PKCS1-v1_5 with SHA2-512 digest, content digested using SHA2-512 in 1 MB chunks. */ + RSA_PKCS1_V1_5_WITH_SHA512( + 0x0104, + ContentDigestAlgorithm.CHUNKED_SHA512, + "RSA", + Pair.of("SHA512withRSA", null), + AndroidSdkVersion.N, + AndroidSdkVersion.INITIAL_RELEASE), + + /** ECDSA with SHA2-256 digest, content digested using SHA2-256 in 1 MB chunks. */ + ECDSA_WITH_SHA256( + 0x0201, + ContentDigestAlgorithm.CHUNKED_SHA256, + "EC", + Pair.of("SHA256withECDSA", null), + AndroidSdkVersion.N, + AndroidSdkVersion.HONEYCOMB), + + /** ECDSA with SHA2-512 digest, content digested using SHA2-512 in 1 MB chunks. */ + ECDSA_WITH_SHA512( + 0x0202, + ContentDigestAlgorithm.CHUNKED_SHA512, + "EC", + Pair.of("SHA512withECDSA", null), + AndroidSdkVersion.N, + AndroidSdkVersion.HONEYCOMB), + + /** DSA with SHA2-256 digest, content digested using SHA2-256 in 1 MB chunks. */ + DSA_WITH_SHA256( + 0x0301, + ContentDigestAlgorithm.CHUNKED_SHA256, + "DSA", + Pair.of("SHA256withDSA", null), + AndroidSdkVersion.N, + AndroidSdkVersion.INITIAL_RELEASE), + + /** + * DSA with SHA2-256 digest, content digested using SHA2-256 in 1 MB chunks. Signing is done + * deterministically according to RFC 6979. + */ + DETDSA_WITH_SHA256( + 0x0301, + ContentDigestAlgorithm.CHUNKED_SHA256, + "DSA", + Pair.of("SHA256withDetDSA", null), + AndroidSdkVersion.N, + AndroidSdkVersion.INITIAL_RELEASE), + + /** + * RSASSA-PKCS1-v1_5 with SHA2-256 digest, content digested using SHA2-256 in 4 KB chunks, in + * the same way fsverity operates. This digest and the content length (before digestion, 8 bytes + * in little endian) construct the final digest. + */ + VERITY_RSA_PKCS1_V1_5_WITH_SHA256( + 0x0421, + ContentDigestAlgorithm.VERITY_CHUNKED_SHA256, + "RSA", + Pair.of("SHA256withRSA", null), + AndroidSdkVersion.P, + AndroidSdkVersion.INITIAL_RELEASE), + + /** + * ECDSA with SHA2-256 digest, content digested using SHA2-256 in 4 KB chunks, in the same way + * fsverity operates. This digest and the content length (before digestion, 8 bytes in little + * endian) construct the final digest. + */ + VERITY_ECDSA_WITH_SHA256( + 0x0423, + ContentDigestAlgorithm.VERITY_CHUNKED_SHA256, + "EC", + Pair.of("SHA256withECDSA", null), + AndroidSdkVersion.P, + AndroidSdkVersion.HONEYCOMB), + + /** + * DSA with SHA2-256 digest, content digested using SHA2-256 in 4 KB chunks, in the same way + * fsverity operates. This digest and the content length (before digestion, 8 bytes in little + * endian) construct the final digest. + */ + VERITY_DSA_WITH_SHA256( + 0x0425, + ContentDigestAlgorithm.VERITY_CHUNKED_SHA256, + "DSA", + Pair.of("SHA256withDSA", null), + AndroidSdkVersion.P, + AndroidSdkVersion.INITIAL_RELEASE); + + private final int mId; + private final String mJcaKeyAlgorithm; + private final ContentDigestAlgorithm mContentDigestAlgorithm; + private final Pair<String, ? extends AlgorithmParameterSpec> mJcaSignatureAlgAndParams; + private final int mMinSdkVersion; + private final int mJcaSigAlgMinSdkVersion; + + SignatureAlgorithm(int id, + ContentDigestAlgorithm contentDigestAlgorithm, + String jcaKeyAlgorithm, + Pair<String, ? extends AlgorithmParameterSpec> jcaSignatureAlgAndParams, + int minSdkVersion, + int jcaSigAlgMinSdkVersion) { + mId = id; + mContentDigestAlgorithm = contentDigestAlgorithm; + mJcaKeyAlgorithm = jcaKeyAlgorithm; + mJcaSignatureAlgAndParams = jcaSignatureAlgAndParams; + mMinSdkVersion = minSdkVersion; + mJcaSigAlgMinSdkVersion = jcaSigAlgMinSdkVersion; + } + + /** + * Returns the ID of this signature algorithm as used in APK Signature Scheme v2 wire format. + */ + public int getId() { + return mId; + } + + /** + * Returns the content digest algorithm associated with this signature algorithm. + */ + public ContentDigestAlgorithm getContentDigestAlgorithm() { + return mContentDigestAlgorithm; + } + + /** + * Returns the JCA {@link java.security.Key} algorithm used by this signature scheme. + */ + public String getJcaKeyAlgorithm() { + return mJcaKeyAlgorithm; + } + + /** + * Returns the {@link java.security.Signature} algorithm and the {@link AlgorithmParameterSpec} + * (or null if not needed) to parameterize the {@code Signature}. + */ + public Pair<String, ? extends AlgorithmParameterSpec> getJcaSignatureAlgorithmAndParams() { + return mJcaSignatureAlgAndParams; + } + + public int getMinSdkVersion() { + return mMinSdkVersion; + } + + /** + * Returns the minimum SDK version that supports the JCA signature algorithm. + */ + public int getJcaSigAlgMinSdkVersion() { + return mJcaSigAlgMinSdkVersion; + } + + public static SignatureAlgorithm findById(int id) { + for (SignatureAlgorithm alg : SignatureAlgorithm.values()) { + if (alg.getId() == id) { + return alg; + } + } + + return null; + } +} diff --git a/platform/android/java/editor/src/main/java/com/android/apksig/internal/apk/SignatureInfo.java b/platform/android/java/editor/src/main/java/com/android/apksig/internal/apk/SignatureInfo.java new file mode 100644 index 0000000000..5e26327b8d --- /dev/null +++ b/platform/android/java/editor/src/main/java/com/android/apksig/internal/apk/SignatureInfo.java @@ -0,0 +1,53 @@ +/* + * Copyright (C) 2018 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.apksig.internal.apk; + +import java.nio.ByteBuffer; + +/** + * APK Signature Scheme block and additional information relevant to verifying the signatures + * contained in the block against the file. + */ +public class SignatureInfo { + /** Contents of APK Signature Scheme block. */ + public final ByteBuffer signatureBlock; + + /** Position of the APK Signing Block in the file. */ + public final long apkSigningBlockOffset; + + /** Position of the ZIP Central Directory in the file. */ + public final long centralDirOffset; + + /** Position of the ZIP End of Central Directory (EoCD) in the file. */ + public final long eocdOffset; + + /** Contents of ZIP End of Central Directory (EoCD) of the file. */ + public final ByteBuffer eocd; + + public SignatureInfo( + ByteBuffer signatureBlock, + long apkSigningBlockOffset, + long centralDirOffset, + long eocdOffset, + ByteBuffer eocd) { + this.signatureBlock = signatureBlock; + this.apkSigningBlockOffset = apkSigningBlockOffset; + this.centralDirOffset = centralDirOffset; + this.eocdOffset = eocdOffset; + this.eocd = eocd; + } +} diff --git a/platform/android/java/editor/src/main/java/com/android/apksig/internal/apk/SignatureNotFoundException.java b/platform/android/java/editor/src/main/java/com/android/apksig/internal/apk/SignatureNotFoundException.java new file mode 100644 index 0000000000..95f06eff8a --- /dev/null +++ b/platform/android/java/editor/src/main/java/com/android/apksig/internal/apk/SignatureNotFoundException.java @@ -0,0 +1,30 @@ +/* + * Copyright (C) 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.apksig.internal.apk; + +/** + * Base exception that is thrown when the APK is not signed with the requested signature scheme. + */ +public class SignatureNotFoundException extends Exception { + public SignatureNotFoundException(String message) { + super(message); + } + + public SignatureNotFoundException(String message, Throwable cause) { + super(message, cause); + } +}
\ No newline at end of file diff --git a/platform/android/java/editor/src/main/java/com/android/apksig/internal/apk/stamp/SourceStampCertificateLineage.java b/platform/android/java/editor/src/main/java/com/android/apksig/internal/apk/stamp/SourceStampCertificateLineage.java new file mode 100644 index 0000000000..93627ff0e3 --- /dev/null +++ b/platform/android/java/editor/src/main/java/com/android/apksig/internal/apk/stamp/SourceStampCertificateLineage.java @@ -0,0 +1,235 @@ +/* + * Copyright (C) 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.apksig.internal.apk.stamp; + +import static com.android.apksig.internal.apk.ApkSigningBlockUtilsLite.getLengthPrefixedSlice; +import static com.android.apksig.internal.apk.ApkSigningBlockUtilsLite.readLengthPrefixedByteArray; + +import com.android.apksig.apk.ApkFormatException; +import com.android.apksig.internal.apk.ApkSigningBlockUtilsLite; +import com.android.apksig.internal.apk.SignatureAlgorithm; +import com.android.apksig.internal.util.GuaranteedEncodedFormX509Certificate; +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.nio.BufferUnderflowException; +import java.nio.ByteBuffer; +import java.security.InvalidAlgorithmParameterException; +import java.security.InvalidKeyException; +import java.security.NoSuchAlgorithmException; +import java.security.PublicKey; +import java.security.Signature; +import java.security.SignatureException; +import java.security.cert.CertificateException; +import java.security.cert.CertificateFactory; +import java.security.cert.X509Certificate; +import java.security.spec.AlgorithmParameterSpec; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashSet; +import java.util.List; + +/** Lightweight version of the V3SigningCertificateLineage to be used for source stamps. */ +public class SourceStampCertificateLineage { + + private final static int FIRST_VERSION = 1; + private final static int CURRENT_VERSION = FIRST_VERSION; + + /** + * Deserializes the binary representation of a SourceStampCertificateLineage. Also + * verifies that the structure is well-formed, e.g. that the signature for each node is from its + * parent. + */ + public static List<SigningCertificateNode> readSigningCertificateLineage(ByteBuffer inputBytes) + throws IOException { + List<SigningCertificateNode> result = new ArrayList<>(); + int nodeCount = 0; + if (inputBytes == null || !inputBytes.hasRemaining()) { + return null; + } + + ApkSigningBlockUtilsLite.checkByteOrderLittleEndian(inputBytes); + + CertificateFactory certFactory; + try { + certFactory = CertificateFactory.getInstance("X.509"); + } catch (CertificateException e) { + throw new IllegalStateException("Failed to obtain X.509 CertificateFactory", e); + } + + // FORMAT (little endian): + // * uint32: version code + // * sequence of length-prefixed (uint32): nodes + // * length-prefixed bytes: signed data + // * length-prefixed bytes: certificate + // * uint32: signature algorithm id + // * uint32: flags + // * uint32: signature algorithm id (used by to sign next cert in lineage) + // * length-prefixed bytes: signature over above signed data + + X509Certificate lastCert = null; + int lastSigAlgorithmId = 0; + + try { + int version = inputBytes.getInt(); + if (version != CURRENT_VERSION) { + // we only have one version to worry about right now, so just check it + throw new IllegalArgumentException("Encoded SigningCertificateLineage has a version" + + " different than any of which we are aware"); + } + HashSet<X509Certificate> certHistorySet = new HashSet<>(); + while (inputBytes.hasRemaining()) { + nodeCount++; + ByteBuffer nodeBytes = getLengthPrefixedSlice(inputBytes); + ByteBuffer signedData = getLengthPrefixedSlice(nodeBytes); + int flags = nodeBytes.getInt(); + int sigAlgorithmId = nodeBytes.getInt(); + SignatureAlgorithm sigAlgorithm = SignatureAlgorithm.findById(lastSigAlgorithmId); + byte[] signature = readLengthPrefixedByteArray(nodeBytes); + + if (lastCert != null) { + // Use previous level cert to verify current level + String jcaSignatureAlgorithm = + sigAlgorithm.getJcaSignatureAlgorithmAndParams().getFirst(); + AlgorithmParameterSpec jcaSignatureAlgorithmParams = + sigAlgorithm.getJcaSignatureAlgorithmAndParams().getSecond(); + PublicKey publicKey = lastCert.getPublicKey(); + Signature sig = Signature.getInstance(jcaSignatureAlgorithm); + sig.initVerify(publicKey); + if (jcaSignatureAlgorithmParams != null) { + sig.setParameter(jcaSignatureAlgorithmParams); + } + sig.update(signedData); + if (!sig.verify(signature)) { + throw new SecurityException("Unable to verify signature of certificate #" + + nodeCount + " using " + jcaSignatureAlgorithm + " when verifying" + + " SourceStampCertificateLineage object"); + } + } + + signedData.rewind(); + byte[] encodedCert = readLengthPrefixedByteArray(signedData); + int signedSigAlgorithm = signedData.getInt(); + if (lastCert != null && lastSigAlgorithmId != signedSigAlgorithm) { + throw new SecurityException("Signing algorithm ID mismatch for certificate #" + + nodeBytes + " when verifying SourceStampCertificateLineage object"); + } + lastCert = (X509Certificate) certFactory.generateCertificate( + new ByteArrayInputStream(encodedCert)); + lastCert = new GuaranteedEncodedFormX509Certificate(lastCert, encodedCert); + if (certHistorySet.contains(lastCert)) { + throw new SecurityException("Encountered duplicate entries in " + + "SigningCertificateLineage at certificate #" + nodeCount + ". All " + + "signing certificates should be unique"); + } + certHistorySet.add(lastCert); + lastSigAlgorithmId = sigAlgorithmId; + result.add(new SigningCertificateNode( + lastCert, SignatureAlgorithm.findById(signedSigAlgorithm), + SignatureAlgorithm.findById(sigAlgorithmId), signature, flags)); + } + } catch(ApkFormatException | BufferUnderflowException e){ + throw new IOException("Failed to parse SourceStampCertificateLineage object", e); + } catch(NoSuchAlgorithmException | InvalidKeyException + | InvalidAlgorithmParameterException | SignatureException e){ + throw new SecurityException( + "Failed to verify signature over signed data for certificate #" + nodeCount + + " when parsing SourceStampCertificateLineage object", e); + } catch(CertificateException e){ + throw new SecurityException("Failed to decode certificate #" + nodeCount + + " when parsing SourceStampCertificateLineage object", e); + } + return result; + } + + /** + * Represents one signing certificate in the SourceStampCertificateLineage, which + * generally means it is/was used at some point to sign source stamps. + */ + public static class SigningCertificateNode { + + public SigningCertificateNode( + X509Certificate signingCert, + SignatureAlgorithm parentSigAlgorithm, + SignatureAlgorithm sigAlgorithm, + byte[] signature, + int flags) { + this.signingCert = signingCert; + this.parentSigAlgorithm = parentSigAlgorithm; + this.sigAlgorithm = sigAlgorithm; + this.signature = signature; + this.flags = flags; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof SigningCertificateNode)) return false; + + SigningCertificateNode that = (SigningCertificateNode) o; + if (!signingCert.equals(that.signingCert)) return false; + if (parentSigAlgorithm != that.parentSigAlgorithm) return false; + if (sigAlgorithm != that.sigAlgorithm) return false; + if (!Arrays.equals(signature, that.signature)) return false; + if (flags != that.flags) return false; + + // we made it + return true; + } + + @Override + public int hashCode() { + final int prime = 31; + int result = 1; + result = prime * result + ((signingCert == null) ? 0 : signingCert.hashCode()); + result = prime * result + + ((parentSigAlgorithm == null) ? 0 : parentSigAlgorithm.hashCode()); + result = prime * result + ((sigAlgorithm == null) ? 0 : sigAlgorithm.hashCode()); + result = prime * result + Arrays.hashCode(signature); + result = prime * result + flags; + return result; + } + + /** + * the signing cert for this node. This is part of the data signed by the parent node. + */ + public final X509Certificate signingCert; + + /** + * the algorithm used by this node's parent to bless this data. Its ID value is part of + * the data signed by the parent node. {@code null} for first node. + */ + public final SignatureAlgorithm parentSigAlgorithm; + + /** + * the algorithm used by this node to bless the next node's data. Its ID value is part + * of the signed data of the next node. {@code null} for the last node. + */ + public SignatureAlgorithm sigAlgorithm; + + /** + * signature over the signed data (above). The signature is from this node's parent + * signing certificate, which should correspond to the signing certificate used to sign an + * APK before rotating to this one, and is formed using {@code signatureAlgorithm}. + */ + public final byte[] signature; + + /** + * the flags detailing how the platform should treat this signing cert + */ + public int flags; + } +} diff --git a/platform/android/java/editor/src/main/java/com/android/apksig/internal/apk/stamp/SourceStampConstants.java b/platform/android/java/editor/src/main/java/com/android/apksig/internal/apk/stamp/SourceStampConstants.java new file mode 100644 index 0000000000..2a949adb71 --- /dev/null +++ b/platform/android/java/editor/src/main/java/com/android/apksig/internal/apk/stamp/SourceStampConstants.java @@ -0,0 +1,34 @@ +/* + * Copyright (C) 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.apksig.internal.apk.stamp; + +/** Constants used for source stamp signing and verification. */ +public class SourceStampConstants { + private SourceStampConstants() {} + + public static final int V1_SOURCE_STAMP_BLOCK_ID = 0x2b09189e; + public static final int V2_SOURCE_STAMP_BLOCK_ID = 0x6dff800d; + public static final String SOURCE_STAMP_CERTIFICATE_HASH_ZIP_ENTRY_NAME = "stamp-cert-sha256"; + public static final int PROOF_OF_ROTATION_ATTR_ID = 0x9d6303f7; + /** + * The source stamp timestamp attribute value is an 8-byte little-endian encoded long + * representing the epoch time in seconds when the stamp block was signed. The first 8 bytes + * of the attribute value buffer will be used to read the timestamp, and any additional buffer + * space will be ignored. + */ + public static final int STAMP_TIME_ATTR_ID = 0xe43c5946; +} diff --git a/platform/android/java/editor/src/main/java/com/android/apksig/internal/apk/stamp/SourceStampVerifier.java b/platform/android/java/editor/src/main/java/com/android/apksig/internal/apk/stamp/SourceStampVerifier.java new file mode 100644 index 0000000000..ef6da2f68f --- /dev/null +++ b/platform/android/java/editor/src/main/java/com/android/apksig/internal/apk/stamp/SourceStampVerifier.java @@ -0,0 +1,364 @@ +/* + * Copyright (C) 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.apksig.internal.apk.stamp; + +import static com.android.apksig.internal.apk.ApkSigningBlockUtilsLite.getLengthPrefixedSlice; +import static com.android.apksig.internal.apk.ApkSigningBlockUtilsLite.getSignaturesToVerify; +import static com.android.apksig.internal.apk.ApkSigningBlockUtilsLite.readLengthPrefixedByteArray; +import static com.android.apksig.internal.apk.ApkSigningBlockUtilsLite.toHex; + +import com.android.apksig.ApkVerificationIssue; +import com.android.apksig.Constants; +import com.android.apksig.apk.ApkFormatException; +import com.android.apksig.internal.apk.ApkSignerInfo; +import com.android.apksig.internal.apk.ApkSupportedSignature; +import com.android.apksig.internal.apk.NoApkSupportedSignaturesException; +import com.android.apksig.internal.apk.SignatureAlgorithm; +import com.android.apksig.internal.util.ByteBufferUtils; +import com.android.apksig.internal.util.GuaranteedEncodedFormX509Certificate; + +import java.io.ByteArrayInputStream; +import java.nio.BufferUnderflowException; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.security.InvalidAlgorithmParameterException; +import java.security.InvalidKeyException; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.security.PublicKey; +import java.security.Signature; +import java.security.SignatureException; +import java.security.cert.CertificateException; +import java.security.cert.CertificateFactory; +import java.security.cert.X509Certificate; +import java.security.spec.AlgorithmParameterSpec; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * Source Stamp verifier. + * + * <p>SourceStamp improves traceability of apps with respect to unauthorized distribution. + * + * <p>The stamp is part of the APK that is protected by the signing block. + * + * <p>The APK contents hash is signed using the stamp key, and is saved as part of the signing + * block. + */ +class SourceStampVerifier { + /** Hidden constructor to prevent instantiation. */ + private SourceStampVerifier() { + } + + /** + * Parses the SourceStamp block and populates the {@code result}. + * + * <p>This verifies signatures over digest provided. + * + * <p>This method adds one or more errors to the {@code result} if a verification error is + * expected to be encountered on an Android platform version in the {@code [minSdkVersion, + * maxSdkVersion]} range. + */ + public static void verifyV1SourceStamp( + ByteBuffer sourceStampBlockData, + CertificateFactory certFactory, + ApkSignerInfo result, + byte[] apkDigest, + byte[] sourceStampCertificateDigest, + int minSdkVersion, + int maxSdkVersion) + throws ApkFormatException, NoSuchAlgorithmException { + X509Certificate sourceStampCertificate = + verifySourceStampCertificate( + sourceStampBlockData, certFactory, sourceStampCertificateDigest, result); + if (result.containsWarnings() || result.containsErrors()) { + return; + } + + ByteBuffer apkDigestSignatures = getLengthPrefixedSlice(sourceStampBlockData); + verifySourceStampSignature( + apkDigest, + minSdkVersion, + maxSdkVersion, + sourceStampCertificate, + apkDigestSignatures, + result); + } + + /** + * Parses the SourceStamp block and populates the {@code result}. + * + * <p>This verifies signatures over digest of multiple signature schemes provided. + * + * <p>This method adds one or more errors to the {@code result} if a verification error is + * expected to be encountered on an Android platform version in the {@code [minSdkVersion, + * maxSdkVersion]} range. + */ + public static void verifyV2SourceStamp( + ByteBuffer sourceStampBlockData, + CertificateFactory certFactory, + ApkSignerInfo result, + Map<Integer, byte[]> signatureSchemeApkDigests, + byte[] sourceStampCertificateDigest, + int minSdkVersion, + int maxSdkVersion) + throws ApkFormatException, NoSuchAlgorithmException { + X509Certificate sourceStampCertificate = + verifySourceStampCertificate( + sourceStampBlockData, certFactory, sourceStampCertificateDigest, result); + if (result.containsWarnings() || result.containsErrors()) { + return; + } + + // Parse signed signature schemes block. + ByteBuffer signedSignatureSchemes = getLengthPrefixedSlice(sourceStampBlockData); + Map<Integer, ByteBuffer> signedSignatureSchemeData = new HashMap<>(); + while (signedSignatureSchemes.hasRemaining()) { + ByteBuffer signedSignatureScheme = getLengthPrefixedSlice(signedSignatureSchemes); + int signatureSchemeId = signedSignatureScheme.getInt(); + ByteBuffer apkDigestSignatures = getLengthPrefixedSlice(signedSignatureScheme); + signedSignatureSchemeData.put(signatureSchemeId, apkDigestSignatures); + } + + for (Map.Entry<Integer, byte[]> signatureSchemeApkDigest : + signatureSchemeApkDigests.entrySet()) { + // TODO(b/192301300): Should the new v3.1 be included in the source stamp, or since a + // v3.0 block must always be present with a v3.1 block is it sufficient to just use the + // v3.0 block? + if (signatureSchemeApkDigest.getKey() + == Constants.VERSION_APK_SIGNATURE_SCHEME_V31) { + continue; + } + if (!signedSignatureSchemeData.containsKey(signatureSchemeApkDigest.getKey())) { + result.addWarning(ApkVerificationIssue.SOURCE_STAMP_NO_SIGNATURE); + return; + } + verifySourceStampSignature( + signatureSchemeApkDigest.getValue(), + minSdkVersion, + maxSdkVersion, + sourceStampCertificate, + signedSignatureSchemeData.get(signatureSchemeApkDigest.getKey()), + result); + if (result.containsWarnings() || result.containsErrors()) { + return; + } + } + + if (sourceStampBlockData.hasRemaining()) { + // The stamp block contains some additional attributes. + ByteBuffer stampAttributeData = getLengthPrefixedSlice(sourceStampBlockData); + ByteBuffer stampAttributeDataSignatures = getLengthPrefixedSlice(sourceStampBlockData); + + byte[] stampAttributeBytes = new byte[stampAttributeData.remaining()]; + stampAttributeData.get(stampAttributeBytes); + stampAttributeData.flip(); + + verifySourceStampSignature(stampAttributeBytes, minSdkVersion, maxSdkVersion, + sourceStampCertificate, stampAttributeDataSignatures, result); + if (result.containsErrors() || result.containsWarnings()) { + return; + } + parseStampAttributes(stampAttributeData, sourceStampCertificate, result); + } + } + + private static X509Certificate verifySourceStampCertificate( + ByteBuffer sourceStampBlockData, + CertificateFactory certFactory, + byte[] sourceStampCertificateDigest, + ApkSignerInfo result) + throws NoSuchAlgorithmException, ApkFormatException { + // Parse the SourceStamp certificate. + byte[] sourceStampEncodedCertificate = readLengthPrefixedByteArray(sourceStampBlockData); + X509Certificate sourceStampCertificate; + try { + sourceStampCertificate = (X509Certificate) certFactory.generateCertificate( + new ByteArrayInputStream(sourceStampEncodedCertificate)); + } catch (CertificateException e) { + result.addWarning(ApkVerificationIssue.SOURCE_STAMP_MALFORMED_CERTIFICATE, e); + return null; + } + // Wrap the cert so that the result's getEncoded returns exactly the original encoded + // form. Without this, getEncoded may return a different form from what was stored in + // the signature. This is because some X509Certificate(Factory) implementations + // re-encode certificates. + sourceStampCertificate = + new GuaranteedEncodedFormX509Certificate( + sourceStampCertificate, sourceStampEncodedCertificate); + result.certs.add(sourceStampCertificate); + // Verify the SourceStamp certificate found in the signing block is the same as the + // SourceStamp certificate found in the APK. + MessageDigest messageDigest = MessageDigest.getInstance("SHA-256"); + messageDigest.update(sourceStampEncodedCertificate); + byte[] sourceStampBlockCertificateDigest = messageDigest.digest(); + if (!Arrays.equals(sourceStampCertificateDigest, sourceStampBlockCertificateDigest)) { + result.addWarning( + ApkVerificationIssue + .SOURCE_STAMP_CERTIFICATE_MISMATCH_BETWEEN_SIGNATURE_BLOCK_AND_APK, + toHex(sourceStampBlockCertificateDigest), + toHex(sourceStampCertificateDigest)); + return null; + } + return sourceStampCertificate; + } + + private static void verifySourceStampSignature( + byte[] data, + int minSdkVersion, + int maxSdkVersion, + X509Certificate sourceStampCertificate, + ByteBuffer signatures, + ApkSignerInfo result) { + // Parse the signatures block and identify supported signatures + int signatureCount = 0; + List<ApkSupportedSignature> supportedSignatures = new ArrayList<>(1); + while (signatures.hasRemaining()) { + signatureCount++; + try { + ByteBuffer signature = getLengthPrefixedSlice(signatures); + int sigAlgorithmId = signature.getInt(); + byte[] sigBytes = readLengthPrefixedByteArray(signature); + SignatureAlgorithm signatureAlgorithm = SignatureAlgorithm.findById(sigAlgorithmId); + if (signatureAlgorithm == null) { + result.addInfoMessage( + ApkVerificationIssue.SOURCE_STAMP_UNKNOWN_SIG_ALGORITHM, + sigAlgorithmId); + continue; + } + supportedSignatures.add( + new ApkSupportedSignature(signatureAlgorithm, sigBytes)); + } catch (ApkFormatException | BufferUnderflowException e) { + result.addWarning( + ApkVerificationIssue.SOURCE_STAMP_MALFORMED_SIGNATURE, signatureCount); + return; + } + } + if (supportedSignatures.isEmpty()) { + result.addWarning(ApkVerificationIssue.SOURCE_STAMP_NO_SIGNATURE); + return; + } + // Verify signatures over digests using the SourceStamp's certificate. + List<ApkSupportedSignature> signaturesToVerify; + try { + signaturesToVerify = + getSignaturesToVerify( + supportedSignatures, minSdkVersion, maxSdkVersion, true); + } catch (NoApkSupportedSignaturesException e) { + // To facilitate debugging capture the signature algorithms and resulting exception in + // the warning. + StringBuilder signatureAlgorithms = new StringBuilder(); + for (ApkSupportedSignature supportedSignature : supportedSignatures) { + if (signatureAlgorithms.length() > 0) { + signatureAlgorithms.append(", "); + } + signatureAlgorithms.append(supportedSignature.algorithm); + } + result.addWarning(ApkVerificationIssue.SOURCE_STAMP_NO_SUPPORTED_SIGNATURE, + signatureAlgorithms.toString(), e); + return; + } + for (ApkSupportedSignature signature : signaturesToVerify) { + SignatureAlgorithm signatureAlgorithm = signature.algorithm; + String jcaSignatureAlgorithm = + signatureAlgorithm.getJcaSignatureAlgorithmAndParams().getFirst(); + AlgorithmParameterSpec jcaSignatureAlgorithmParams = + signatureAlgorithm.getJcaSignatureAlgorithmAndParams().getSecond(); + PublicKey publicKey = sourceStampCertificate.getPublicKey(); + try { + Signature sig = Signature.getInstance(jcaSignatureAlgorithm); + sig.initVerify(publicKey); + if (jcaSignatureAlgorithmParams != null) { + sig.setParameter(jcaSignatureAlgorithmParams); + } + sig.update(data); + byte[] sigBytes = signature.signature; + if (!sig.verify(sigBytes)) { + result.addWarning( + ApkVerificationIssue.SOURCE_STAMP_DID_NOT_VERIFY, signatureAlgorithm); + return; + } + } catch (InvalidKeyException + | InvalidAlgorithmParameterException + | SignatureException + | NoSuchAlgorithmException e) { + result.addWarning( + ApkVerificationIssue.SOURCE_STAMP_VERIFY_EXCEPTION, signatureAlgorithm, e); + return; + } + } + } + + private static void parseStampAttributes(ByteBuffer stampAttributeData, + X509Certificate sourceStampCertificate, ApkSignerInfo result) + throws ApkFormatException { + ByteBuffer stampAttributes = getLengthPrefixedSlice(stampAttributeData); + int stampAttributeCount = 0; + while (stampAttributes.hasRemaining()) { + stampAttributeCount++; + try { + ByteBuffer attribute = getLengthPrefixedSlice(stampAttributes); + int id = attribute.getInt(); + byte[] value = ByteBufferUtils.toByteArray(attribute); + if (id == SourceStampConstants.PROOF_OF_ROTATION_ATTR_ID) { + readStampCertificateLineage(value, sourceStampCertificate, result); + } else if (id == SourceStampConstants.STAMP_TIME_ATTR_ID) { + long timestamp = ByteBuffer.wrap(value).order( + ByteOrder.LITTLE_ENDIAN).getLong(); + if (timestamp > 0) { + result.timestamp = timestamp; + } else { + result.addWarning(ApkVerificationIssue.SOURCE_STAMP_INVALID_TIMESTAMP, + timestamp); + } + } else { + result.addInfoMessage(ApkVerificationIssue.SOURCE_STAMP_UNKNOWN_ATTRIBUTE, id); + } + } catch (ApkFormatException | BufferUnderflowException e) { + result.addWarning(ApkVerificationIssue.SOURCE_STAMP_MALFORMED_ATTRIBUTE, + stampAttributeCount); + return; + } + } + } + + private static void readStampCertificateLineage(byte[] lineageBytes, + X509Certificate sourceStampCertificate, ApkSignerInfo result) { + try { + // SourceStampCertificateLineage is verified when built + List<SourceStampCertificateLineage.SigningCertificateNode> nodes = + SourceStampCertificateLineage.readSigningCertificateLineage( + ByteBuffer.wrap(lineageBytes).order(ByteOrder.LITTLE_ENDIAN)); + for (int i = 0; i < nodes.size(); i++) { + result.certificateLineage.add(nodes.get(i).signingCert); + } + // Make sure that the last cert in the chain matches this signer cert + if (!sourceStampCertificate.equals( + result.certificateLineage.get(result.certificateLineage.size() - 1))) { + result.addWarning(ApkVerificationIssue.SOURCE_STAMP_POR_CERT_MISMATCH); + } + } catch (SecurityException e) { + result.addWarning(ApkVerificationIssue.SOURCE_STAMP_POR_DID_NOT_VERIFY); + } catch (IllegalArgumentException e) { + result.addWarning(ApkVerificationIssue.SOURCE_STAMP_POR_CERT_MISMATCH); + } catch (Exception e) { + result.addWarning(ApkVerificationIssue.SOURCE_STAMP_MALFORMED_LINEAGE); + } + } +} diff --git a/platform/android/java/editor/src/main/java/com/android/apksig/internal/apk/stamp/V1SourceStampSigner.java b/platform/android/java/editor/src/main/java/com/android/apksig/internal/apk/stamp/V1SourceStampSigner.java new file mode 100644 index 0000000000..dee24bd1f6 --- /dev/null +++ b/platform/android/java/editor/src/main/java/com/android/apksig/internal/apk/stamp/V1SourceStampSigner.java @@ -0,0 +1,109 @@ +/* + * Copyright (C) 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.apksig.internal.apk.stamp; + +import static com.android.apksig.internal.apk.ApkSigningBlockUtils.encodeAsLengthPrefixedElement; +import static com.android.apksig.internal.apk.ApkSigningBlockUtils.encodeAsSequenceOfLengthPrefixedElements; +import static com.android.apksig.internal.apk.ApkSigningBlockUtils.encodeAsSequenceOfLengthPrefixedPairsOfIntAndLengthPrefixedBytes; + +import com.android.apksig.internal.apk.ApkSigningBlockUtils; +import com.android.apksig.internal.apk.ApkSigningBlockUtils.SignerConfig; +import com.android.apksig.internal.apk.ContentDigestAlgorithm; +import com.android.apksig.internal.util.Pair; + +import java.security.InvalidKeyException; +import java.security.NoSuchAlgorithmException; +import java.security.SignatureException; +import java.security.cert.CertificateEncodingException; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.List; +import java.util.Map; + +/** + * SourceStamp signer. + * + * <p>SourceStamp improves traceability of apps with respect to unauthorized distribution. + * + * <p>The stamp is part of the APK that is protected by the signing block. + * + * <p>The APK contents hash is signed using the stamp key, and is saved as part of the signing + * block. + * + * <p>V1 of the source stamp allows signing the digest of at most one signature scheme only. + */ +public abstract class V1SourceStampSigner { + public static final int V1_SOURCE_STAMP_BLOCK_ID = + SourceStampConstants.V1_SOURCE_STAMP_BLOCK_ID; + + /** Hidden constructor to prevent instantiation. */ + private V1SourceStampSigner() {} + + public static Pair<byte[], Integer> generateSourceStampBlock( + SignerConfig sourceStampSignerConfig, Map<ContentDigestAlgorithm, byte[]> digestInfo) + throws SignatureException, NoSuchAlgorithmException, InvalidKeyException { + if (sourceStampSignerConfig.certificates.isEmpty()) { + throw new SignatureException("No certificates configured for signer"); + } + + List<Pair<Integer, byte[]>> digests = new ArrayList<>(); + for (Map.Entry<ContentDigestAlgorithm, byte[]> digest : digestInfo.entrySet()) { + digests.add(Pair.of(digest.getKey().getId(), digest.getValue())); + } + Collections.sort(digests, Comparator.comparing(Pair::getFirst)); + + SourceStampBlock sourceStampBlock = new SourceStampBlock(); + + try { + sourceStampBlock.stampCertificate = + sourceStampSignerConfig.certificates.get(0).getEncoded(); + } catch (CertificateEncodingException e) { + throw new SignatureException( + "Retrieving the encoded form of the stamp certificate failed", e); + } + + byte[] digestBytes = + encodeAsSequenceOfLengthPrefixedPairsOfIntAndLengthPrefixedBytes(digests); + sourceStampBlock.signedDigests = + ApkSigningBlockUtils.generateSignaturesOverData( + sourceStampSignerConfig, digestBytes); + + // FORMAT: + // * length-prefixed bytes: X.509 certificate (ASN.1 DER encoded) + // * length-prefixed sequence of length-prefixed signatures: + // * uint32: signature algorithm ID + // * length-prefixed bytes: signature of signed data + byte[] sourceStampSignerBlock = + encodeAsSequenceOfLengthPrefixedElements( + new byte[][] { + sourceStampBlock.stampCertificate, + encodeAsSequenceOfLengthPrefixedPairsOfIntAndLengthPrefixedBytes( + sourceStampBlock.signedDigests), + }); + + // FORMAT: + // * length-prefixed stamp block. + return Pair.of(encodeAsLengthPrefixedElement(sourceStampSignerBlock), + SourceStampConstants.V1_SOURCE_STAMP_BLOCK_ID); + } + + private static final class SourceStampBlock { + public byte[] stampCertificate; + public List<Pair<Integer, byte[]>> signedDigests; + } +} diff --git a/platform/android/java/editor/src/main/java/com/android/apksig/internal/apk/stamp/V1SourceStampVerifier.java b/platform/android/java/editor/src/main/java/com/android/apksig/internal/apk/stamp/V1SourceStampVerifier.java new file mode 100644 index 0000000000..c3fdeecc7b --- /dev/null +++ b/platform/android/java/editor/src/main/java/com/android/apksig/internal/apk/stamp/V1SourceStampVerifier.java @@ -0,0 +1,139 @@ +/* + * Copyright (C) 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.apksig.internal.apk.stamp; + +import static com.android.apksig.internal.apk.ApkSigningBlockUtils.encodeAsSequenceOfLengthPrefixedPairsOfIntAndLengthPrefixedBytes; +import static com.android.apksig.internal.apk.stamp.SourceStampConstants.V1_SOURCE_STAMP_BLOCK_ID; + +import com.android.apksig.ApkVerifier; +import com.android.apksig.apk.ApkFormatException; +import com.android.apksig.apk.ApkUtils; +import com.android.apksig.internal.apk.ApkSigningBlockUtils; +import com.android.apksig.internal.apk.ContentDigestAlgorithm; +import com.android.apksig.internal.apk.SignatureInfo; +import com.android.apksig.internal.util.Pair; +import com.android.apksig.util.DataSource; + +import java.io.IOException; +import java.nio.BufferUnderflowException; +import java.nio.ByteBuffer; +import java.security.NoSuchAlgorithmException; +import java.security.cert.CertificateException; +import java.security.cert.CertificateFactory; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.List; +import java.util.Map; + +/** + * Source Stamp verifier. + * + * <p>V1 of the source stamp verifies the stamp signature of at most one signature scheme. + */ +public abstract class V1SourceStampVerifier { + + /** Hidden constructor to prevent instantiation. */ + private V1SourceStampVerifier() {} + + /** + * Verifies the provided APK's SourceStamp signatures and returns the result of verification. + * The APK must be considered verified only if {@link ApkSigningBlockUtils.Result#verified} is + * {@code true}. If verification fails, the result will contain errors -- see {@link + * ApkSigningBlockUtils.Result#getErrors()}. + * + * @throws NoSuchAlgorithmException if the APK's signatures cannot be verified because a + * required cryptographic algorithm implementation is missing + * @throws ApkSigningBlockUtils.SignatureNotFoundException if no SourceStamp signatures are + * found + * @throws IOException if an I/O error occurs when reading the APK + */ + public static ApkSigningBlockUtils.Result verify( + DataSource apk, + ApkUtils.ZipSections zipSections, + byte[] sourceStampCertificateDigest, + Map<ContentDigestAlgorithm, byte[]> apkContentDigests, + int minSdkVersion, + int maxSdkVersion) + throws IOException, NoSuchAlgorithmException, + ApkSigningBlockUtils.SignatureNotFoundException { + ApkSigningBlockUtils.Result result = + new ApkSigningBlockUtils.Result(ApkSigningBlockUtils.VERSION_SOURCE_STAMP); + SignatureInfo signatureInfo = + ApkSigningBlockUtils.findSignature( + apk, zipSections, V1_SOURCE_STAMP_BLOCK_ID, result); + + verify( + signatureInfo.signatureBlock, + sourceStampCertificateDigest, + apkContentDigests, + minSdkVersion, + maxSdkVersion, + result); + return result; + } + + /** + * Verifies the provided APK's SourceStamp signatures and outputs the results into the provided + * {@code result}. APK is considered verified only if there are no errors reported in the {@code + * result}. See {@link #verify(DataSource, ApkUtils.ZipSections, byte[], Map, int, int)} for + * more information about the contract of this method. + */ + private static void verify( + ByteBuffer sourceStampBlock, + byte[] sourceStampCertificateDigest, + Map<ContentDigestAlgorithm, byte[]> apkContentDigests, + int minSdkVersion, + int maxSdkVersion, + ApkSigningBlockUtils.Result result) + throws NoSuchAlgorithmException { + ApkSigningBlockUtils.Result.SignerInfo signerInfo = + new ApkSigningBlockUtils.Result.SignerInfo(); + result.signers.add(signerInfo); + try { + CertificateFactory certFactory = CertificateFactory.getInstance("X.509"); + ByteBuffer sourceStampBlockData = + ApkSigningBlockUtils.getLengthPrefixedSlice(sourceStampBlock); + byte[] digestBytes = + encodeAsSequenceOfLengthPrefixedPairsOfIntAndLengthPrefixedBytes( + getApkDigests(apkContentDigests)); + SourceStampVerifier.verifyV1SourceStamp( + sourceStampBlockData, + certFactory, + signerInfo, + digestBytes, + sourceStampCertificateDigest, + minSdkVersion, + maxSdkVersion); + result.verified = !result.containsErrors() && !result.containsWarnings(); + } catch (CertificateException e) { + throw new IllegalStateException("Failed to obtain X.509 CertificateFactory", e); + } catch (ApkFormatException | BufferUnderflowException e) { + signerInfo.addWarning(ApkVerifier.Issue.SOURCE_STAMP_MALFORMED_SIGNATURE); + } + } + + private static List<Pair<Integer, byte[]>> getApkDigests( + Map<ContentDigestAlgorithm, byte[]> apkContentDigests) { + List<Pair<Integer, byte[]>> digests = new ArrayList<>(); + for (Map.Entry<ContentDigestAlgorithm, byte[]> apkContentDigest : + apkContentDigests.entrySet()) { + digests.add(Pair.of(apkContentDigest.getKey().getId(), apkContentDigest.getValue())); + } + Collections.sort(digests, Comparator.comparing(Pair::getFirst)); + return digests; + } +} diff --git a/platform/android/java/editor/src/main/java/com/android/apksig/internal/apk/stamp/V2SourceStampSigner.java b/platform/android/java/editor/src/main/java/com/android/apksig/internal/apk/stamp/V2SourceStampSigner.java new file mode 100644 index 0000000000..9283f02673 --- /dev/null +++ b/platform/android/java/editor/src/main/java/com/android/apksig/internal/apk/stamp/V2SourceStampSigner.java @@ -0,0 +1,286 @@ +/* + * Copyright (C) 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.apksig.internal.apk.stamp; + +import static com.android.apksig.internal.apk.ApkSigningBlockUtils.VERSION_APK_SIGNATURE_SCHEME_V2; +import static com.android.apksig.internal.apk.ApkSigningBlockUtils.VERSION_APK_SIGNATURE_SCHEME_V3; +import static com.android.apksig.internal.apk.ApkSigningBlockUtils.VERSION_JAR_SIGNATURE_SCHEME; +import static com.android.apksig.internal.apk.ApkSigningBlockUtils.encodeAsLengthPrefixedElement; +import static com.android.apksig.internal.apk.ApkSigningBlockUtils.encodeAsSequenceOfLengthPrefixedElements; +import static com.android.apksig.internal.apk.ApkSigningBlockUtils.encodeAsSequenceOfLengthPrefixedPairsOfIntAndLengthPrefixedBytes; + +import com.android.apksig.SigningCertificateLineage; +import com.android.apksig.internal.apk.ApkSigningBlockUtils; +import com.android.apksig.internal.apk.ApkSigningBlockUtils.SignerConfig; +import com.android.apksig.internal.apk.ContentDigestAlgorithm; +import com.android.apksig.internal.util.Pair; + +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.security.InvalidKeyException; +import java.security.NoSuchAlgorithmException; +import java.security.SignatureException; +import java.security.cert.CertificateEncodingException; +import java.time.Instant; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * SourceStamp signer. + * + * <p>SourceStamp improves traceability of apps with respect to unauthorized distribution. + * + * <p>The stamp is part of the APK that is protected by the signing block. + * + * <p>The APK contents hash is signed using the stamp key, and is saved as part of the signing + * block. + * + * <p>V2 of the source stamp allows signing the digests of more than one signature schemes. + */ +public class V2SourceStampSigner { + public static final int V2_SOURCE_STAMP_BLOCK_ID = + SourceStampConstants.V2_SOURCE_STAMP_BLOCK_ID; + + private final SignerConfig mSourceStampSignerConfig; + private final Map<Integer, Map<ContentDigestAlgorithm, byte[]>> mSignatureSchemeDigestInfos; + private final boolean mSourceStampTimestampEnabled; + + /** Hidden constructor to prevent instantiation. */ + private V2SourceStampSigner(Builder builder) { + mSourceStampSignerConfig = builder.mSourceStampSignerConfig; + mSignatureSchemeDigestInfos = builder.mSignatureSchemeDigestInfos; + mSourceStampTimestampEnabled = builder.mSourceStampTimestampEnabled; + } + + public static Pair<byte[], Integer> generateSourceStampBlock( + SignerConfig sourceStampSignerConfig, + Map<Integer, Map<ContentDigestAlgorithm, byte[]>> signatureSchemeDigestInfos) + throws SignatureException, NoSuchAlgorithmException, InvalidKeyException { + return new Builder(sourceStampSignerConfig, + signatureSchemeDigestInfos).build().generateSourceStampBlock(); + } + + public Pair<byte[], Integer> generateSourceStampBlock() + throws SignatureException, NoSuchAlgorithmException, InvalidKeyException { + if (mSourceStampSignerConfig.certificates.isEmpty()) { + throw new SignatureException("No certificates configured for signer"); + } + + // Extract the digests for signature schemes. + List<Pair<Integer, byte[]>> signatureSchemeDigests = new ArrayList<>(); + getSignedDigestsFor( + VERSION_APK_SIGNATURE_SCHEME_V3, + mSignatureSchemeDigestInfos, + mSourceStampSignerConfig, + signatureSchemeDigests); + getSignedDigestsFor( + VERSION_APK_SIGNATURE_SCHEME_V2, + mSignatureSchemeDigestInfos, + mSourceStampSignerConfig, + signatureSchemeDigests); + getSignedDigestsFor( + VERSION_JAR_SIGNATURE_SCHEME, + mSignatureSchemeDigestInfos, + mSourceStampSignerConfig, + signatureSchemeDigests); + Collections.sort(signatureSchemeDigests, Comparator.comparing(Pair::getFirst)); + + SourceStampBlock sourceStampBlock = new SourceStampBlock(); + + try { + sourceStampBlock.stampCertificate = + mSourceStampSignerConfig.certificates.get(0).getEncoded(); + } catch (CertificateEncodingException e) { + throw new SignatureException( + "Retrieving the encoded form of the stamp certificate failed", e); + } + + sourceStampBlock.signedDigests = signatureSchemeDigests; + + sourceStampBlock.stampAttributes = encodeStampAttributes( + generateStampAttributes(mSourceStampSignerConfig.signingCertificateLineage)); + sourceStampBlock.signedStampAttributes = + ApkSigningBlockUtils.generateSignaturesOverData(mSourceStampSignerConfig, + sourceStampBlock.stampAttributes); + + // FORMAT: + // * length-prefixed bytes: X.509 certificate (ASN.1 DER encoded) + // * length-prefixed sequence of length-prefixed signed signature scheme digests: + // * uint32: signature scheme id + // * length-prefixed bytes: signed digests for the respective signature scheme + // * length-prefixed bytes: encoded stamp attributes + // * length-prefixed sequence of length-prefixed signed stamp attributes: + // * uint32: signature algorithm id + // * length-prefixed bytes: signed stamp attributes for the respective signature algorithm + byte[] sourceStampSignerBlock = + encodeAsSequenceOfLengthPrefixedElements( + new byte[][]{ + sourceStampBlock.stampCertificate, + encodeAsSequenceOfLengthPrefixedPairsOfIntAndLengthPrefixedBytes( + sourceStampBlock.signedDigests), + sourceStampBlock.stampAttributes, + encodeAsSequenceOfLengthPrefixedPairsOfIntAndLengthPrefixedBytes( + sourceStampBlock.signedStampAttributes), + }); + + // FORMAT: + // * length-prefixed stamp block. + return Pair.of(encodeAsLengthPrefixedElement(sourceStampSignerBlock), + SourceStampConstants.V2_SOURCE_STAMP_BLOCK_ID); + } + + private static void getSignedDigestsFor( + int signatureSchemeVersion, + Map<Integer, Map<ContentDigestAlgorithm, byte[]>> mSignatureSchemeDigestInfos, + SignerConfig mSourceStampSignerConfig, + List<Pair<Integer, byte[]>> signatureSchemeDigests) + throws NoSuchAlgorithmException, InvalidKeyException, SignatureException { + if (!mSignatureSchemeDigestInfos.containsKey(signatureSchemeVersion)) { + return; + } + + Map<ContentDigestAlgorithm, byte[]> digestInfo = + mSignatureSchemeDigestInfos.get(signatureSchemeVersion); + List<Pair<Integer, byte[]>> digests = new ArrayList<>(); + for (Map.Entry<ContentDigestAlgorithm, byte[]> digest : digestInfo.entrySet()) { + digests.add(Pair.of(digest.getKey().getId(), digest.getValue())); + } + Collections.sort(digests, Comparator.comparing(Pair::getFirst)); + + // FORMAT: + // * length-prefixed sequence of length-prefixed digests: + // * uint32: digest algorithm id + // * length-prefixed bytes: digest of the respective digest algorithm + byte[] digestBytes = + encodeAsSequenceOfLengthPrefixedPairsOfIntAndLengthPrefixedBytes(digests); + + // FORMAT: + // * length-prefixed sequence of length-prefixed signed digests: + // * uint32: signature algorithm id + // * length-prefixed bytes: signed digest for the respective signature algorithm + List<Pair<Integer, byte[]>> signedDigest = + ApkSigningBlockUtils.generateSignaturesOverData( + mSourceStampSignerConfig, digestBytes); + + // FORMAT: + // * length-prefixed sequence of length-prefixed signed signature scheme digests: + // * uint32: signature scheme id + // * length-prefixed bytes: signed digests for the respective signature scheme + signatureSchemeDigests.add( + Pair.of( + signatureSchemeVersion, + encodeAsSequenceOfLengthPrefixedPairsOfIntAndLengthPrefixedBytes( + signedDigest))); + } + + private static byte[] encodeStampAttributes(Map<Integer, byte[]> stampAttributes) { + int payloadSize = 0; + for (byte[] attributeValue : stampAttributes.values()) { + // Pair size + Attribute ID + Attribute value + payloadSize += 4 + 4 + attributeValue.length; + } + + // FORMAT (little endian): + // * length-prefixed bytes: pair + // * uint32: ID + // * bytes: value + ByteBuffer result = ByteBuffer.allocate(4 + payloadSize); + result.order(ByteOrder.LITTLE_ENDIAN); + result.putInt(payloadSize); + for (Map.Entry<Integer, byte[]> stampAttribute : stampAttributes.entrySet()) { + // Pair size + result.putInt(4 + stampAttribute.getValue().length); + result.putInt(stampAttribute.getKey()); + result.put(stampAttribute.getValue()); + } + return result.array(); + } + + private Map<Integer, byte[]> generateStampAttributes(SigningCertificateLineage lineage) { + HashMap<Integer, byte[]> stampAttributes = new HashMap<>(); + + if (mSourceStampTimestampEnabled) { + // Write the current epoch time as the timestamp for the source stamp. + long timestamp = Instant.now().getEpochSecond(); + if (timestamp > 0) { + ByteBuffer attributeBuffer = ByteBuffer.allocate(8); + attributeBuffer.order(ByteOrder.LITTLE_ENDIAN); + attributeBuffer.putLong(timestamp); + stampAttributes.put(SourceStampConstants.STAMP_TIME_ATTR_ID, + attributeBuffer.array()); + } else { + // The epoch time should never be <= 0, and since security decisions can potentially + // be made based on the value in the timestamp, throw an Exception to ensure the + // issues with the environment are resolved before allowing the signing. + throw new IllegalStateException( + "Received an invalid value from Instant#getTimestamp: " + timestamp); + } + } + + if (lineage != null) { + stampAttributes.put(SourceStampConstants.PROOF_OF_ROTATION_ATTR_ID, + lineage.encodeSigningCertificateLineage()); + } + return stampAttributes; + } + + private static final class SourceStampBlock { + public byte[] stampCertificate; + public List<Pair<Integer, byte[]>> signedDigests; + // Optional stamp attributes that are not required for verification. + public byte[] stampAttributes; + public List<Pair<Integer, byte[]>> signedStampAttributes; + } + + /** Builder of {@link V2SourceStampSigner} instances. */ + public static class Builder { + private final SignerConfig mSourceStampSignerConfig; + private final Map<Integer, Map<ContentDigestAlgorithm, byte[]>> mSignatureSchemeDigestInfos; + private boolean mSourceStampTimestampEnabled = true; + + /** + * Instantiates a new {@code Builder} with the provided {@code sourceStampSignerConfig} + * and the {@code signatureSchemeDigestInfos}. + */ + public Builder(SignerConfig sourceStampSignerConfig, + Map<Integer, Map<ContentDigestAlgorithm, byte[]>> signatureSchemeDigestInfos) { + mSourceStampSignerConfig = sourceStampSignerConfig; + mSignatureSchemeDigestInfos = signatureSchemeDigestInfos; + } + + /** + * Sets whether the source stamp should contain the timestamp attribute with the time + * at which the source stamp was signed. + */ + public Builder setSourceStampTimestampEnabled(boolean value) { + mSourceStampTimestampEnabled = value; + return this; + } + + /** + * Builds a new V2SourceStampSigner that can be used to generate a new source stamp + * block signed with the specified signing config. + */ + public V2SourceStampSigner build() { + return new V2SourceStampSigner(this); + } + } +} diff --git a/platform/android/java/editor/src/main/java/com/android/apksig/internal/apk/stamp/V2SourceStampVerifier.java b/platform/android/java/editor/src/main/java/com/android/apksig/internal/apk/stamp/V2SourceStampVerifier.java new file mode 100644 index 0000000000..a215b986a8 --- /dev/null +++ b/platform/android/java/editor/src/main/java/com/android/apksig/internal/apk/stamp/V2SourceStampVerifier.java @@ -0,0 +1,159 @@ +/* + * Copyright (C) 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.apksig.internal.apk.stamp; + +import static com.android.apksig.internal.apk.ApkSigningBlockUtilsLite.encodeAsSequenceOfLengthPrefixedPairsOfIntAndLengthPrefixedBytes; +import static com.android.apksig.internal.apk.stamp.SourceStampConstants.V2_SOURCE_STAMP_BLOCK_ID; + +import com.android.apksig.ApkVerificationIssue; +import com.android.apksig.Constants; +import com.android.apksig.apk.ApkFormatException; +import com.android.apksig.internal.apk.ApkSigResult; +import com.android.apksig.internal.apk.ApkSignerInfo; +import com.android.apksig.internal.apk.ApkSigningBlockUtilsLite; +import com.android.apksig.internal.apk.ContentDigestAlgorithm; +import com.android.apksig.internal.apk.SignatureInfo; +import com.android.apksig.internal.apk.SignatureNotFoundException; +import com.android.apksig.internal.util.Pair; +import com.android.apksig.util.DataSource; +import com.android.apksig.zip.ZipSections; + +import java.io.IOException; +import java.nio.BufferUnderflowException; +import java.nio.ByteBuffer; +import java.security.NoSuchAlgorithmException; +import java.security.cert.CertificateException; +import java.security.cert.CertificateFactory; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * Source Stamp verifier. + * + * <p>V2 of the source stamp verifies the stamp signature of more than one signature schemes. + */ +public abstract class V2SourceStampVerifier { + + /** Hidden constructor to prevent instantiation. */ + private V2SourceStampVerifier() {} + + /** + * Verifies the provided APK's SourceStamp signatures and returns the result of verification. + * The APK must be considered verified only if {@link ApkSigResult#verified} is + * {@code true}. If verification fails, the result will contain errors -- see {@link + * ApkSigResult#getErrors()}. + * + * @throws NoSuchAlgorithmException if the APK's signatures cannot be verified because a + * required cryptographic algorithm implementation is missing + * @throws SignatureNotFoundException if no SourceStamp signatures are + * found + * @throws IOException if an I/O error occurs when reading the APK + */ + public static ApkSigResult verify( + DataSource apk, + ZipSections zipSections, + byte[] sourceStampCertificateDigest, + Map<Integer, Map<ContentDigestAlgorithm, byte[]>> signatureSchemeApkContentDigests, + int minSdkVersion, + int maxSdkVersion) + throws IOException, NoSuchAlgorithmException, SignatureNotFoundException { + ApkSigResult result = + new ApkSigResult(Constants.VERSION_SOURCE_STAMP); + SignatureInfo signatureInfo = + ApkSigningBlockUtilsLite.findSignature( + apk, zipSections, V2_SOURCE_STAMP_BLOCK_ID); + + verify( + signatureInfo.signatureBlock, + sourceStampCertificateDigest, + signatureSchemeApkContentDigests, + minSdkVersion, + maxSdkVersion, + result); + return result; + } + + /** + * Verifies the provided APK's SourceStamp signatures and outputs the results into the provided + * {@code result}. APK is considered verified only if there are no errors reported in the {@code + * result}. See {@link #verify(DataSource, ZipSections, byte[], Map, int, int)} for + * more information about the contract of this method. + */ + private static void verify( + ByteBuffer sourceStampBlock, + byte[] sourceStampCertificateDigest, + Map<Integer, Map<ContentDigestAlgorithm, byte[]>> signatureSchemeApkContentDigests, + int minSdkVersion, + int maxSdkVersion, + ApkSigResult result) + throws NoSuchAlgorithmException { + ApkSignerInfo signerInfo = new ApkSignerInfo(); + result.mSigners.add(signerInfo); + try { + CertificateFactory certFactory = CertificateFactory.getInstance("X.509"); + ByteBuffer sourceStampBlockData = + ApkSigningBlockUtilsLite.getLengthPrefixedSlice(sourceStampBlock); + SourceStampVerifier.verifyV2SourceStamp( + sourceStampBlockData, + certFactory, + signerInfo, + getSignatureSchemeDigests(signatureSchemeApkContentDigests), + sourceStampCertificateDigest, + minSdkVersion, + maxSdkVersion); + result.verified = !result.containsErrors() && !result.containsWarnings(); + } catch (CertificateException e) { + throw new IllegalStateException("Failed to obtain X.509 CertificateFactory", e); + } catch (ApkFormatException | BufferUnderflowException e) { + signerInfo.addWarning(ApkVerificationIssue.SOURCE_STAMP_MALFORMED_SIGNATURE); + } + } + + private static Map<Integer, byte[]> getSignatureSchemeDigests( + Map<Integer, Map<ContentDigestAlgorithm, byte[]>> signatureSchemeApkContentDigests) { + Map<Integer, byte[]> digests = new HashMap<>(); + for (Map.Entry<Integer, Map<ContentDigestAlgorithm, byte[]>> + signatureSchemeApkContentDigest : signatureSchemeApkContentDigests.entrySet()) { + List<Pair<Integer, byte[]>> apkDigests = + getApkDigests(signatureSchemeApkContentDigest.getValue()); + digests.put( + signatureSchemeApkContentDigest.getKey(), + encodeAsSequenceOfLengthPrefixedPairsOfIntAndLengthPrefixedBytes(apkDigests)); + } + return digests; + } + + private static List<Pair<Integer, byte[]>> getApkDigests( + Map<ContentDigestAlgorithm, byte[]> apkContentDigests) { + List<Pair<Integer, byte[]>> digests = new ArrayList<>(); + for (Map.Entry<ContentDigestAlgorithm, byte[]> apkContentDigest : + apkContentDigests.entrySet()) { + digests.add(Pair.of(apkContentDigest.getKey().getId(), apkContentDigest.getValue())); + } + Collections.sort(digests, new Comparator<Pair<Integer, byte[]>>() { + @Override + public int compare(Pair<Integer, byte[]> pair1, Pair<Integer, byte[]> pair2) { + return pair1.getFirst() - pair2.getFirst(); + } + }); + return digests; + } +} diff --git a/platform/android/java/editor/src/main/java/com/android/apksig/internal/apk/v1/DigestAlgorithm.java b/platform/android/java/editor/src/main/java/com/android/apksig/internal/apk/v1/DigestAlgorithm.java new file mode 100644 index 0000000000..51b9810fa9 --- /dev/null +++ b/platform/android/java/editor/src/main/java/com/android/apksig/internal/apk/v1/DigestAlgorithm.java @@ -0,0 +1,74 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.apksig.internal.apk.v1; + +import java.util.Comparator; + +/** + * Digest algorithm used with JAR signing (aka v1 signing scheme). + */ +public enum DigestAlgorithm { + /** SHA-1 */ + SHA1("SHA-1"), + + /** SHA2-256 */ + SHA256("SHA-256"); + + private final String mJcaMessageDigestAlgorithm; + + private DigestAlgorithm(String jcaMessageDigestAlgoritm) { + mJcaMessageDigestAlgorithm = jcaMessageDigestAlgoritm; + } + + /** + * Returns the {@link java.security.MessageDigest} algorithm represented by this digest + * algorithm. + */ + String getJcaMessageDigestAlgorithm() { + return mJcaMessageDigestAlgorithm; + } + + public static Comparator<DigestAlgorithm> BY_STRENGTH_COMPARATOR = new StrengthComparator(); + + private static class StrengthComparator implements Comparator<DigestAlgorithm> { + @Override + public int compare(DigestAlgorithm a1, DigestAlgorithm a2) { + switch (a1) { + case SHA1: + switch (a2) { + case SHA1: + return 0; + case SHA256: + return -1; + } + throw new RuntimeException("Unsupported algorithm: " + a2); + + case SHA256: + switch (a2) { + case SHA1: + return 1; + case SHA256: + return 0; + } + throw new RuntimeException("Unsupported algorithm: " + a2); + + default: + throw new RuntimeException("Unsupported algorithm: " + a1); + } + } + } +} diff --git a/platform/android/java/editor/src/main/java/com/android/apksig/internal/apk/v1/V1SchemeConstants.java b/platform/android/java/editor/src/main/java/com/android/apksig/internal/apk/v1/V1SchemeConstants.java new file mode 100644 index 0000000000..db1d15f618 --- /dev/null +++ b/platform/android/java/editor/src/main/java/com/android/apksig/internal/apk/v1/V1SchemeConstants.java @@ -0,0 +1,26 @@ +/* + * Copyright (C) 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.apksig.internal.apk.v1; + +/** Constants used by the Jar Signing / V1 Signature Scheme signing and verification. */ +public class V1SchemeConstants { + private V1SchemeConstants() {} + + public static final String MANIFEST_ENTRY_NAME = "META-INF/MANIFEST.MF"; + public static final String SF_ATTRIBUTE_NAME_ANDROID_APK_SIGNED_NAME_STR = + "X-Android-APK-Signed"; +} diff --git a/platform/android/java/editor/src/main/java/com/android/apksig/internal/apk/v1/V1SchemeSigner.java b/platform/android/java/editor/src/main/java/com/android/apksig/internal/apk/v1/V1SchemeSigner.java new file mode 100644 index 0000000000..3cb109eff2 --- /dev/null +++ b/platform/android/java/editor/src/main/java/com/android/apksig/internal/apk/v1/V1SchemeSigner.java @@ -0,0 +1,586 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.apksig.internal.apk.v1; + +import static com.android.apksig.Constants.MAX_APK_SIGNERS; +import static com.android.apksig.Constants.OID_RSA_ENCRYPTION; +import static com.android.apksig.internal.pkcs7.AlgorithmIdentifier.getSignerInfoDigestAlgorithmOid; +import static com.android.apksig.internal.pkcs7.AlgorithmIdentifier.getSignerInfoSignatureAlgorithm; + +import com.android.apksig.apk.ApkFormatException; +import com.android.apksig.internal.apk.ApkSigningBlockUtils; +import com.android.apksig.internal.asn1.Asn1EncodingException; +import com.android.apksig.internal.jar.ManifestWriter; +import com.android.apksig.internal.jar.SignatureFileWriter; +import com.android.apksig.internal.pkcs7.AlgorithmIdentifier; +import com.android.apksig.internal.util.Pair; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.security.InvalidKeyException; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.security.PrivateKey; +import java.security.PublicKey; +import java.security.Signature; +import java.security.SignatureException; +import java.security.cert.CertificateEncodingException; +import java.security.cert.CertificateException; +import java.security.cert.X509Certificate; +import java.util.ArrayList; +import java.util.Base64; +import java.util.Collections; +import java.util.HashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.SortedMap; +import java.util.TreeMap; +import java.util.jar.Attributes; +import java.util.jar.Manifest; + +/** + * APK signer which uses JAR signing (aka v1 signing scheme). + * + * @see <a href="https://docs.oracle.com/javase/8/docs/technotes/guides/jar/jar.html#Signed_JAR_File">Signed JAR File</a> + */ +public abstract class V1SchemeSigner { + public static final String MANIFEST_ENTRY_NAME = V1SchemeConstants.MANIFEST_ENTRY_NAME; + + private static final Attributes.Name ATTRIBUTE_NAME_CREATED_BY = + new Attributes.Name("Created-By"); + private static final String ATTRIBUTE_VALUE_MANIFEST_VERSION = "1.0"; + private static final String ATTRIBUTE_VALUE_SIGNATURE_VERSION = "1.0"; + + private static final Attributes.Name SF_ATTRIBUTE_NAME_ANDROID_APK_SIGNED_NAME = + new Attributes.Name(V1SchemeConstants.SF_ATTRIBUTE_NAME_ANDROID_APK_SIGNED_NAME_STR); + + /** + * Signer configuration. + */ + public static class SignerConfig { + /** Name. */ + public String name; + + /** Private key. */ + public PrivateKey privateKey; + + /** + * Certificates, with the first certificate containing the public key corresponding to + * {@link #privateKey}. + */ + public List<X509Certificate> certificates; + + /** + * Digest algorithm used for the signature. + */ + public DigestAlgorithm signatureDigestAlgorithm; + + /** + * If DSA is the signing algorithm, whether or not deterministic DSA signing should be used. + */ + public boolean deterministicDsaSigning; + } + + /** Hidden constructor to prevent instantiation. */ + private V1SchemeSigner() {} + + /** + * Gets the JAR signing digest algorithm to be used for signing an APK using the provided key. + * + * @param minSdkVersion minimum API Level of the platform on which the APK may be installed (see + * AndroidManifest.xml minSdkVersion attribute) + * + * @throws InvalidKeyException if the provided key is not suitable for signing APKs using + * JAR signing (aka v1 signature scheme) + */ + public static DigestAlgorithm getSuggestedSignatureDigestAlgorithm( + PublicKey signingKey, int minSdkVersion) throws InvalidKeyException { + String keyAlgorithm = signingKey.getAlgorithm(); + if ("RSA".equalsIgnoreCase(keyAlgorithm) || OID_RSA_ENCRYPTION.equals((keyAlgorithm))) { + // Prior to API Level 18, only SHA-1 can be used with RSA. + if (minSdkVersion < 18) { + return DigestAlgorithm.SHA1; + } + return DigestAlgorithm.SHA256; + } else if ("DSA".equalsIgnoreCase(keyAlgorithm)) { + // Prior to API Level 21, only SHA-1 can be used with DSA + if (minSdkVersion < 21) { + return DigestAlgorithm.SHA1; + } else { + return DigestAlgorithm.SHA256; + } + } else if ("EC".equalsIgnoreCase(keyAlgorithm)) { + if (minSdkVersion < 18) { + throw new InvalidKeyException( + "ECDSA signatures only supported for minSdkVersion 18 and higher"); + } + return DigestAlgorithm.SHA256; + } else { + throw new InvalidKeyException("Unsupported key algorithm: " + keyAlgorithm); + } + } + + /** + * Returns a safe version of the provided signer name. + */ + public static String getSafeSignerName(String name) { + if (name.isEmpty()) { + throw new IllegalArgumentException("Empty name"); + } + + // According to https://docs.oracle.com/javase/tutorial/deployment/jar/signing.html, the + // name must not be longer than 8 characters and may contain only A-Z, 0-9, _, and -. + StringBuilder result = new StringBuilder(); + char[] nameCharsUpperCase = name.toUpperCase(Locale.US).toCharArray(); + for (int i = 0; i < Math.min(nameCharsUpperCase.length, 8); i++) { + char c = nameCharsUpperCase[i]; + if (((c >= 'A') && (c <= 'Z')) + || ((c >= '0') && (c <= '9')) + || (c == '-') + || (c == '_')) { + result.append(c); + } else { + result.append('_'); + } + } + return result.toString(); + } + + /** + * Returns a new {@link MessageDigest} instance corresponding to the provided digest algorithm. + */ + private static MessageDigest getMessageDigestInstance(DigestAlgorithm digestAlgorithm) + throws NoSuchAlgorithmException { + String jcaAlgorithm = digestAlgorithm.getJcaMessageDigestAlgorithm(); + return MessageDigest.getInstance(jcaAlgorithm); + } + + /** + * Returns the JCA {@link MessageDigest} algorithm corresponding to the provided digest + * algorithm. + */ + public static String getJcaMessageDigestAlgorithm(DigestAlgorithm digestAlgorithm) { + return digestAlgorithm.getJcaMessageDigestAlgorithm(); + } + + /** + * Returns {@code true} if the provided JAR entry must be mentioned in signed JAR archive's + * manifest. + */ + public static boolean isJarEntryDigestNeededInManifest(String entryName) { + // See https://docs.oracle.com/javase/8/docs/technotes/guides/jar/jar.html#Signed_JAR_File + + // Entries which represent directories sould not be listed in the manifest. + if (entryName.endsWith("/")) { + return false; + } + + // Entries outside of META-INF must be listed in the manifest. + if (!entryName.startsWith("META-INF/")) { + return true; + } + // Entries in subdirectories of META-INF must be listed in the manifest. + if (entryName.indexOf('/', "META-INF/".length()) != -1) { + return true; + } + + // Ignored file names (case-insensitive) in META-INF directory: + // MANIFEST.MF + // *.SF + // *.RSA + // *.DSA + // *.EC + // SIG-* + String fileNameLowerCase = + entryName.substring("META-INF/".length()).toLowerCase(Locale.US); + if (("manifest.mf".equals(fileNameLowerCase)) + || (fileNameLowerCase.endsWith(".sf")) + || (fileNameLowerCase.endsWith(".rsa")) + || (fileNameLowerCase.endsWith(".dsa")) + || (fileNameLowerCase.endsWith(".ec")) + || (fileNameLowerCase.startsWith("sig-"))) { + return false; + } + return true; + } + + /** + * Signs the provided APK using JAR signing (aka v1 signature scheme) and returns the list of + * JAR entries which need to be added to the APK as part of the signature. + * + * @param signerConfigs signer configurations, one for each signer. At least one signer config + * must be provided. + * + * @throws ApkFormatException if the source manifest is malformed + * @throws NoSuchAlgorithmException if a required cryptographic algorithm implementation is + * missing + * @throws InvalidKeyException if a signing key is not suitable for this signature scheme or + * cannot be used in general + * @throws SignatureException if an error occurs when computing digests of generating + * signatures + */ + public static List<Pair<String, byte[]>> sign( + List<SignerConfig> signerConfigs, + DigestAlgorithm jarEntryDigestAlgorithm, + Map<String, byte[]> jarEntryDigests, + List<Integer> apkSigningSchemeIds, + byte[] sourceManifestBytes, + String createdBy) + throws NoSuchAlgorithmException, ApkFormatException, InvalidKeyException, + CertificateException, SignatureException { + if (signerConfigs.isEmpty()) { + throw new IllegalArgumentException("At least one signer config must be provided"); + } + if (signerConfigs.size() > MAX_APK_SIGNERS) { + throw new IllegalArgumentException( + "APK Signature Scheme v1 only supports a maximum of " + MAX_APK_SIGNERS + ", " + + signerConfigs.size() + " provided"); + } + OutputManifestFile manifest = + generateManifestFile( + jarEntryDigestAlgorithm, jarEntryDigests, sourceManifestBytes); + + return signManifest( + signerConfigs, jarEntryDigestAlgorithm, apkSigningSchemeIds, createdBy, manifest); + } + + /** + * Signs the provided APK using JAR signing (aka v1 signature scheme) and returns the list of + * JAR entries which need to be added to the APK as part of the signature. + * + * @param signerConfigs signer configurations, one for each signer. At least one signer config + * must be provided. + * + * @throws InvalidKeyException if a signing key is not suitable for this signature scheme or + * cannot be used in general + * @throws SignatureException if an error occurs when computing digests of generating + * signatures + */ + public static List<Pair<String, byte[]>> signManifest( + List<SignerConfig> signerConfigs, + DigestAlgorithm digestAlgorithm, + List<Integer> apkSigningSchemeIds, + String createdBy, + OutputManifestFile manifest) + throws NoSuchAlgorithmException, InvalidKeyException, CertificateException, + SignatureException { + if (signerConfigs.isEmpty()) { + throw new IllegalArgumentException("At least one signer config must be provided"); + } + + // For each signer output .SF and .(RSA|DSA|EC) file, then output MANIFEST.MF. + List<Pair<String, byte[]>> signatureJarEntries = + new ArrayList<>(2 * signerConfigs.size() + 1); + byte[] sfBytes = + generateSignatureFile(apkSigningSchemeIds, digestAlgorithm, createdBy, manifest); + for (SignerConfig signerConfig : signerConfigs) { + String signerName = signerConfig.name; + byte[] signatureBlock; + try { + signatureBlock = generateSignatureBlock(signerConfig, sfBytes); + } catch (InvalidKeyException e) { + throw new InvalidKeyException( + "Failed to sign using signer \"" + signerName + "\"", e); + } catch (CertificateException e) { + throw new CertificateException( + "Failed to sign using signer \"" + signerName + "\"", e); + } catch (SignatureException e) { + throw new SignatureException( + "Failed to sign using signer \"" + signerName + "\"", e); + } + signatureJarEntries.add(Pair.of("META-INF/" + signerName + ".SF", sfBytes)); + PublicKey publicKey = signerConfig.certificates.get(0).getPublicKey(); + String signatureBlockFileName = + "META-INF/" + signerName + "." + + publicKey.getAlgorithm().toUpperCase(Locale.US); + signatureJarEntries.add( + Pair.of(signatureBlockFileName, signatureBlock)); + } + signatureJarEntries.add(Pair.of(V1SchemeConstants.MANIFEST_ENTRY_NAME, manifest.contents)); + return signatureJarEntries; + } + + /** + * Returns the names of JAR entries which this signer will produce as part of v1 signature. + */ + public static Set<String> getOutputEntryNames(List<SignerConfig> signerConfigs) { + Set<String> result = new HashSet<>(2 * signerConfigs.size() + 1); + for (SignerConfig signerConfig : signerConfigs) { + String signerName = signerConfig.name; + result.add("META-INF/" + signerName + ".SF"); + PublicKey publicKey = signerConfig.certificates.get(0).getPublicKey(); + String signatureBlockFileName = + "META-INF/" + signerName + "." + + publicKey.getAlgorithm().toUpperCase(Locale.US); + result.add(signatureBlockFileName); + } + result.add(V1SchemeConstants.MANIFEST_ENTRY_NAME); + return result; + } + + /** + * Generated and returns the {@code META-INF/MANIFEST.MF} file based on the provided (optional) + * input {@code MANIFEST.MF} and digests of JAR entries covered by the manifest. + */ + public static OutputManifestFile generateManifestFile( + DigestAlgorithm jarEntryDigestAlgorithm, + Map<String, byte[]> jarEntryDigests, + byte[] sourceManifestBytes) throws ApkFormatException { + Manifest sourceManifest = null; + if (sourceManifestBytes != null) { + try { + sourceManifest = new Manifest(new ByteArrayInputStream(sourceManifestBytes)); + } catch (IOException e) { + throw new ApkFormatException("Malformed source META-INF/MANIFEST.MF", e); + } + } + ByteArrayOutputStream manifestOut = new ByteArrayOutputStream(); + Attributes mainAttrs = new Attributes(); + // Copy the main section from the source manifest (if provided). Otherwise use defaults. + // NOTE: We don't output our own Created-By header because this signer did not create the + // JAR/APK being signed -- the signer only adds signatures to the already existing + // JAR/APK. + if (sourceManifest != null) { + mainAttrs.putAll(sourceManifest.getMainAttributes()); + } else { + mainAttrs.put(Attributes.Name.MANIFEST_VERSION, ATTRIBUTE_VALUE_MANIFEST_VERSION); + } + + try { + ManifestWriter.writeMainSection(manifestOut, mainAttrs); + } catch (IOException e) { + throw new RuntimeException("Failed to write in-memory MANIFEST.MF", e); + } + + List<String> sortedEntryNames = new ArrayList<>(jarEntryDigests.keySet()); + Collections.sort(sortedEntryNames); + SortedMap<String, byte[]> invidualSectionsContents = new TreeMap<>(); + String entryDigestAttributeName = getEntryDigestAttributeName(jarEntryDigestAlgorithm); + for (String entryName : sortedEntryNames) { + checkEntryNameValid(entryName); + byte[] entryDigest = jarEntryDigests.get(entryName); + Attributes entryAttrs = new Attributes(); + entryAttrs.putValue( + entryDigestAttributeName, + Base64.getEncoder().encodeToString(entryDigest)); + ByteArrayOutputStream sectionOut = new ByteArrayOutputStream(); + byte[] sectionBytes; + try { + ManifestWriter.writeIndividualSection(sectionOut, entryName, entryAttrs); + sectionBytes = sectionOut.toByteArray(); + manifestOut.write(sectionBytes); + } catch (IOException e) { + throw new RuntimeException("Failed to write in-memory MANIFEST.MF", e); + } + invidualSectionsContents.put(entryName, sectionBytes); + } + + OutputManifestFile result = new OutputManifestFile(); + result.contents = manifestOut.toByteArray(); + result.mainSectionAttributes = mainAttrs; + result.individualSectionsContents = invidualSectionsContents; + return result; + } + + private static void checkEntryNameValid(String name) throws ApkFormatException { + // JAR signing spec says CR, LF, and NUL are not permitted in entry names + // CR or LF in entry names will result in malformed MANIFEST.MF and .SF files because there + // is no way to escape characters in MANIFEST.MF and .SF files. NUL can, presumably, cause + // issues when parsing using C and C++ like languages. + for (char c : name.toCharArray()) { + if ((c == '\r') || (c == '\n') || (c == 0)) { + throw new ApkFormatException( + String.format( + "Unsupported character 0x%1$02x in ZIP entry name \"%2$s\"", + (int) c, + name)); + } + } + } + + public static class OutputManifestFile { + public byte[] contents; + public SortedMap<String, byte[]> individualSectionsContents; + public Attributes mainSectionAttributes; + } + + private static byte[] generateSignatureFile( + List<Integer> apkSignatureSchemeIds, + DigestAlgorithm manifestDigestAlgorithm, + String createdBy, + OutputManifestFile manifest) throws NoSuchAlgorithmException { + Manifest sf = new Manifest(); + Attributes mainAttrs = sf.getMainAttributes(); + mainAttrs.put(Attributes.Name.SIGNATURE_VERSION, ATTRIBUTE_VALUE_SIGNATURE_VERSION); + mainAttrs.put(ATTRIBUTE_NAME_CREATED_BY, createdBy); + if (!apkSignatureSchemeIds.isEmpty()) { + // Add APK Signature Scheme v2 (and newer) signature stripping protection. + // This attribute indicates that this APK is supposed to have been signed using one or + // more APK-specific signature schemes in addition to the standard JAR signature scheme + // used by this code. APK signature verifier should reject the APK if it does not + // contain a signature for the signature scheme the verifier prefers out of this set. + StringBuilder attrValue = new StringBuilder(); + for (int id : apkSignatureSchemeIds) { + if (attrValue.length() > 0) { + attrValue.append(", "); + } + attrValue.append(String.valueOf(id)); + } + mainAttrs.put( + SF_ATTRIBUTE_NAME_ANDROID_APK_SIGNED_NAME, + attrValue.toString()); + } + + // Add main attribute containing the digest of MANIFEST.MF. + MessageDigest md = getMessageDigestInstance(manifestDigestAlgorithm); + mainAttrs.putValue( + getManifestDigestAttributeName(manifestDigestAlgorithm), + Base64.getEncoder().encodeToString(md.digest(manifest.contents))); + ByteArrayOutputStream out = new ByteArrayOutputStream(); + try { + SignatureFileWriter.writeMainSection(out, mainAttrs); + } catch (IOException e) { + throw new RuntimeException("Failed to write in-memory .SF file", e); + } + String entryDigestAttributeName = getEntryDigestAttributeName(manifestDigestAlgorithm); + for (Map.Entry<String, byte[]> manifestSection + : manifest.individualSectionsContents.entrySet()) { + String sectionName = manifestSection.getKey(); + byte[] sectionContents = manifestSection.getValue(); + byte[] sectionDigest = md.digest(sectionContents); + Attributes attrs = new Attributes(); + attrs.putValue( + entryDigestAttributeName, + Base64.getEncoder().encodeToString(sectionDigest)); + + try { + SignatureFileWriter.writeIndividualSection(out, sectionName, attrs); + } catch (IOException e) { + throw new RuntimeException("Failed to write in-memory .SF file", e); + } + } + + // A bug in the java.util.jar implementation of Android platforms up to version 1.6 will + // cause a spurious IOException to be thrown if the length of the signature file is a + // multiple of 1024 bytes. As a workaround, add an extra CRLF in this case. + if ((out.size() > 0) && ((out.size() % 1024) == 0)) { + try { + SignatureFileWriter.writeSectionDelimiter(out); + } catch (IOException e) { + throw new RuntimeException("Failed to write to ByteArrayOutputStream", e); + } + } + + return out.toByteArray(); + } + + + + /** + * Generates the CMS PKCS #7 signature block corresponding to the provided signature file and + * signing configuration. + */ + private static byte[] generateSignatureBlock( + SignerConfig signerConfig, byte[] signatureFileBytes) + throws NoSuchAlgorithmException, InvalidKeyException, CertificateException, + SignatureException { + // Obtain relevant bits of signing configuration + List<X509Certificate> signerCerts = signerConfig.certificates; + X509Certificate signingCert = signerCerts.get(0); + PublicKey publicKey = signingCert.getPublicKey(); + DigestAlgorithm digestAlgorithm = signerConfig.signatureDigestAlgorithm; + Pair<String, AlgorithmIdentifier> signatureAlgs = + getSignerInfoSignatureAlgorithm(publicKey, digestAlgorithm, + signerConfig.deterministicDsaSigning); + String jcaSignatureAlgorithm = signatureAlgs.getFirst(); + + // Generate the cryptographic signature of the signature file + byte[] signatureBytes; + try { + Signature signature = Signature.getInstance(jcaSignatureAlgorithm); + signature.initSign(signerConfig.privateKey); + signature.update(signatureFileBytes); + signatureBytes = signature.sign(); + } catch (InvalidKeyException e) { + throw new InvalidKeyException("Failed to sign using " + jcaSignatureAlgorithm, e); + } catch (SignatureException e) { + throw new SignatureException("Failed to sign using " + jcaSignatureAlgorithm, e); + } + + // Verify the signature against the public key in the signing certificate + try { + Signature signature = Signature.getInstance(jcaSignatureAlgorithm); + signature.initVerify(publicKey); + signature.update(signatureFileBytes); + if (!signature.verify(signatureBytes)) { + throw new SignatureException("Signature did not verify"); + } + } catch (InvalidKeyException e) { + throw new InvalidKeyException( + "Failed to verify generated " + jcaSignatureAlgorithm + " signature using" + + " public key from certificate", + e); + } catch (SignatureException e) { + throw new SignatureException( + "Failed to verify generated " + jcaSignatureAlgorithm + " signature using" + + " public key from certificate", + e); + } + + AlgorithmIdentifier digestAlgorithmId = + getSignerInfoDigestAlgorithmOid(digestAlgorithm); + AlgorithmIdentifier signatureAlgorithmId = signatureAlgs.getSecond(); + try { + return ApkSigningBlockUtils.generatePkcs7DerEncodedMessage( + signatureBytes, + null, + signerCerts, digestAlgorithmId, + signatureAlgorithmId); + } catch (Asn1EncodingException | CertificateEncodingException ex) { + throw new SignatureException("Failed to encode signature block"); + } + } + + + + private static String getEntryDigestAttributeName(DigestAlgorithm digestAlgorithm) { + switch (digestAlgorithm) { + case SHA1: + return "SHA1-Digest"; + case SHA256: + return "SHA-256-Digest"; + default: + throw new IllegalArgumentException( + "Unexpected content digest algorithm: " + digestAlgorithm); + } + } + + private static String getManifestDigestAttributeName(DigestAlgorithm digestAlgorithm) { + switch (digestAlgorithm) { + case SHA1: + return "SHA1-Digest-Manifest"; + case SHA256: + return "SHA-256-Digest-Manifest"; + default: + throw new IllegalArgumentException( + "Unexpected content digest algorithm: " + digestAlgorithm); + } + } +} diff --git a/platform/android/java/editor/src/main/java/com/android/apksig/internal/apk/v1/V1SchemeVerifier.java b/platform/android/java/editor/src/main/java/com/android/apksig/internal/apk/v1/V1SchemeVerifier.java new file mode 100644 index 0000000000..f3fd64156f --- /dev/null +++ b/platform/android/java/editor/src/main/java/com/android/apksig/internal/apk/v1/V1SchemeVerifier.java @@ -0,0 +1,1570 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.apksig.internal.apk.v1; + +import static com.android.apksig.Constants.MAX_APK_SIGNERS; +import static com.android.apksig.internal.oid.OidConstants.getSigAlgSupportedApiLevels; +import static com.android.apksig.internal.pkcs7.AlgorithmIdentifier.getJcaDigestAlgorithm; +import static com.android.apksig.internal.pkcs7.AlgorithmIdentifier.getJcaSignatureAlgorithm; +import static com.android.apksig.internal.x509.Certificate.findCertificate; +import static com.android.apksig.internal.x509.Certificate.parseCertificates; + +import com.android.apksig.ApkVerifier.Issue; +import com.android.apksig.ApkVerifier.IssueWithParams; +import com.android.apksig.apk.ApkFormatException; +import com.android.apksig.apk.ApkUtils; +import com.android.apksig.internal.apk.ApkSigningBlockUtils; +import com.android.apksig.internal.asn1.Asn1BerParser; +import com.android.apksig.internal.asn1.Asn1Class; +import com.android.apksig.internal.asn1.Asn1DecodingException; +import com.android.apksig.internal.asn1.Asn1Field; +import com.android.apksig.internal.asn1.Asn1OpaqueObject; +import com.android.apksig.internal.asn1.Asn1Type; +import com.android.apksig.internal.jar.ManifestParser; +import com.android.apksig.internal.oid.OidConstants; +import com.android.apksig.internal.pkcs7.Attribute; +import com.android.apksig.internal.pkcs7.ContentInfo; +import com.android.apksig.internal.pkcs7.Pkcs7Constants; +import com.android.apksig.internal.pkcs7.Pkcs7DecodingException; +import com.android.apksig.internal.pkcs7.SignedData; +import com.android.apksig.internal.pkcs7.SignerInfo; +import com.android.apksig.internal.util.AndroidSdkVersion; +import com.android.apksig.internal.util.ByteBufferUtils; +import com.android.apksig.internal.util.InclusiveIntRange; +import com.android.apksig.internal.util.Pair; +import com.android.apksig.internal.zip.CentralDirectoryRecord; +import com.android.apksig.internal.zip.LocalFileRecord; +import com.android.apksig.internal.zip.ZipUtils; +import com.android.apksig.util.DataSinks; +import com.android.apksig.util.DataSource; +import com.android.apksig.zip.ZipFormatException; + +import java.io.IOException; +import java.nio.ByteBuffer; +import java.security.InvalidKeyException; +import java.security.KeyFactory; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.security.Principal; +import java.security.PublicKey; +import java.security.Signature; +import java.security.SignatureException; +import java.security.cert.CertificateException; +import java.security.cert.X509Certificate; +import java.security.spec.InvalidKeySpecException; +import java.security.spec.X509EncodedKeySpec; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Base64; +import java.util.Base64.Decoder; +import java.util.Collection; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.StringTokenizer; +import java.util.jar.Attributes; + +/** + * APK verifier which uses JAR signing (aka v1 signing scheme). + * + * @see <a href="https://docs.oracle.com/javase/8/docs/technotes/guides/jar/jar.html#Signed_JAR_File">Signed JAR File</a> + */ +public abstract class V1SchemeVerifier { + private V1SchemeVerifier() {} + + /** + * Verifies the provided APK's JAR signatures and returns the result of verification. APK is + * considered verified only if {@link Result#verified} is {@code true}. If verification fails, + * the result will contain errors -- see {@link Result#getErrors()}. + * + * <p>Verification succeeds iff the APK's JAR signatures are expected to verify on all Android + * platform versions in the {@code [minSdkVersion, maxSdkVersion]} range. If the APK's signature + * is expected to not verify on any of the specified platform versions, this method returns a + * result with one or more errors and whose {@code Result.verified == false}, or this method + * throws an exception. + * + * @throws ApkFormatException if the APK is malformed + * @throws IOException if an I/O error occurs when reading the APK + * @throws NoSuchAlgorithmException if the APK's JAR signatures cannot be verified because a + * required cryptographic algorithm implementation is missing + */ + public static Result verify( + DataSource apk, + ApkUtils.ZipSections apkSections, + Map<Integer, String> supportedApkSigSchemeNames, + Set<Integer> foundApkSigSchemeIds, + int minSdkVersion, + int maxSdkVersion) throws IOException, ApkFormatException, NoSuchAlgorithmException { + if (minSdkVersion > maxSdkVersion) { + throw new IllegalArgumentException( + "minSdkVersion (" + minSdkVersion + ") > maxSdkVersion (" + maxSdkVersion + + ")"); + } + + Result result = new Result(); + + // Parse the ZIP Central Directory and check that there are no entries with duplicate names. + List<CentralDirectoryRecord> cdRecords = parseZipCentralDirectory(apk, apkSections); + Set<String> cdEntryNames = checkForDuplicateEntries(cdRecords, result); + if (result.containsErrors()) { + return result; + } + + // Verify JAR signature(s). + Signers.verify( + apk, + apkSections.getZipCentralDirectoryOffset(), + cdRecords, + cdEntryNames, + supportedApkSigSchemeNames, + foundApkSigSchemeIds, + minSdkVersion, + maxSdkVersion, + result); + + return result; + } + + /** + * Returns the set of entry names and reports any duplicate entry names in the {@code result} + * as errors. + */ + private static Set<String> checkForDuplicateEntries( + List<CentralDirectoryRecord> cdRecords, Result result) { + Set<String> cdEntryNames = new HashSet<>(cdRecords.size()); + Set<String> duplicateCdEntryNames = null; + for (CentralDirectoryRecord cdRecord : cdRecords) { + String entryName = cdRecord.getName(); + if (!cdEntryNames.add(entryName)) { + // This is an error. Report this once per duplicate name. + if (duplicateCdEntryNames == null) { + duplicateCdEntryNames = new HashSet<>(); + } + if (duplicateCdEntryNames.add(entryName)) { + result.addError(Issue.JAR_SIG_DUPLICATE_ZIP_ENTRY, entryName); + } + } + } + return cdEntryNames; + } + + /** + * Parses raw representation of MANIFEST.MF file into a pair of main entry manifest section + * representation and a mapping between entry name and its manifest section representation. + * + * @param manifestBytes raw representation of Manifest.MF + * @param cdEntryNames expected set of entry names + * @param result object to keep track of errors that happened during the parsing + * @return a pair of main entry manifest section representation and a mapping between entry name + * and its manifest section representation + */ + public static Pair<ManifestParser.Section, Map<String, ManifestParser.Section>> parseManifest( + byte[] manifestBytes, Set<String> cdEntryNames, Result result) { + ManifestParser manifest = new ManifestParser(manifestBytes); + ManifestParser.Section manifestMainSection = manifest.readSection(); + List<ManifestParser.Section> manifestIndividualSections = manifest.readAllSections(); + Map<String, ManifestParser.Section> entryNameToManifestSection = + new HashMap<>(manifestIndividualSections.size()); + int manifestSectionNumber = 0; + for (ManifestParser.Section manifestSection : manifestIndividualSections) { + manifestSectionNumber++; + String entryName = manifestSection.getName(); + if (entryName == null) { + result.addError(Issue.JAR_SIG_UNNNAMED_MANIFEST_SECTION, manifestSectionNumber); + continue; + } + if (entryNameToManifestSection.put(entryName, manifestSection) != null) { + result.addError(Issue.JAR_SIG_DUPLICATE_MANIFEST_SECTION, entryName); + continue; + } + if (!cdEntryNames.contains(entryName)) { + result.addError( + Issue.JAR_SIG_MISSING_ZIP_ENTRY_REFERENCED_IN_MANIFEST, entryName); + continue; + } + } + return Pair.of(manifestMainSection, entryNameToManifestSection); + } + + /** + * All JAR signers of an APK. + */ + private static class Signers { + + /** + * Verifies JAR signatures of the provided APK and populates the provided result container + * with errors, warnings, and information about signers. The APK is considered verified if + * the {@link Result#verified} is {@code true}. + */ + private static void verify( + DataSource apk, + long cdStartOffset, + List<CentralDirectoryRecord> cdRecords, + Set<String> cdEntryNames, + Map<Integer, String> supportedApkSigSchemeNames, + Set<Integer> foundApkSigSchemeIds, + int minSdkVersion, + int maxSdkVersion, + Result result) throws ApkFormatException, IOException, NoSuchAlgorithmException { + + // Find JAR manifest and signature block files. + CentralDirectoryRecord manifestEntry = null; + Map<String, CentralDirectoryRecord> sigFileEntries = new HashMap<>(1); + List<CentralDirectoryRecord> sigBlockEntries = new ArrayList<>(1); + for (CentralDirectoryRecord cdRecord : cdRecords) { + String entryName = cdRecord.getName(); + if (!entryName.startsWith("META-INF/")) { + continue; + } + if ((manifestEntry == null) && (V1SchemeConstants.MANIFEST_ENTRY_NAME.equals( + entryName))) { + manifestEntry = cdRecord; + continue; + } + if (entryName.endsWith(".SF")) { + sigFileEntries.put(entryName, cdRecord); + continue; + } + if ((entryName.endsWith(".RSA")) + || (entryName.endsWith(".DSA")) + || (entryName.endsWith(".EC"))) { + sigBlockEntries.add(cdRecord); + continue; + } + } + if (manifestEntry == null) { + result.addError(Issue.JAR_SIG_NO_MANIFEST); + return; + } + + // Parse the JAR manifest and check that all JAR entries it references exist in the APK. + byte[] manifestBytes; + try { + manifestBytes = + LocalFileRecord.getUncompressedData(apk, manifestEntry, cdStartOffset); + } catch (ZipFormatException e) { + throw new ApkFormatException("Malformed ZIP entry: " + manifestEntry.getName(), e); + } + + Pair<ManifestParser.Section, Map<String, ManifestParser.Section>> manifestSections = + parseManifest(manifestBytes, cdEntryNames, result); + + if (result.containsErrors()) { + return; + } + + ManifestParser.Section manifestMainSection = manifestSections.getFirst(); + Map<String, ManifestParser.Section> entryNameToManifestSection = + manifestSections.getSecond(); + + // STATE OF AFFAIRS: + // * All JAR entries listed in JAR manifest are present in the APK. + + // Identify signers + List<Signer> signers = new ArrayList<>(sigBlockEntries.size()); + for (CentralDirectoryRecord sigBlockEntry : sigBlockEntries) { + String sigBlockEntryName = sigBlockEntry.getName(); + int extensionDelimiterIndex = sigBlockEntryName.lastIndexOf('.'); + if (extensionDelimiterIndex == -1) { + throw new RuntimeException( + "Signature block file name does not contain extension: " + + sigBlockEntryName); + } + String sigFileEntryName = + sigBlockEntryName.substring(0, extensionDelimiterIndex) + ".SF"; + CentralDirectoryRecord sigFileEntry = sigFileEntries.get(sigFileEntryName); + if (sigFileEntry == null) { + result.addWarning( + Issue.JAR_SIG_MISSING_FILE, sigBlockEntryName, sigFileEntryName); + continue; + } + String signerName = sigBlockEntryName.substring("META-INF/".length()); + Result.SignerInfo signerInfo = + new Result.SignerInfo( + signerName, sigBlockEntryName, sigFileEntry.getName()); + Signer signer = new Signer(signerName, sigBlockEntry, sigFileEntry, signerInfo); + signers.add(signer); + } + if (signers.isEmpty()) { + result.addError(Issue.JAR_SIG_NO_SIGNATURES); + return; + } + if (signers.size() > MAX_APK_SIGNERS) { + result.addError(Issue.JAR_SIG_MAX_SIGNATURES_EXCEEDED, MAX_APK_SIGNERS, + signers.size()); + return; + } + + // Verify each signer's signature block file .(RSA|DSA|EC) against the corresponding + // signature file .SF. Any error encountered for any signer terminates verification, to + // mimic Android's behavior. + for (Signer signer : signers) { + signer.verifySigBlockAgainstSigFile( + apk, cdStartOffset, minSdkVersion, maxSdkVersion); + if (signer.getResult().containsErrors()) { + result.signers.add(signer.getResult()); + } + } + if (result.containsErrors()) { + return; + } + // STATE OF AFFAIRS: + // * All JAR entries listed in JAR manifest are present in the APK. + // * All signature files (.SF) verify against corresponding block files (.RSA|.DSA|.EC). + + // Verify each signer's signature file (.SF) against the JAR manifest. + List<Signer> remainingSigners = new ArrayList<>(signers.size()); + for (Signer signer : signers) { + signer.verifySigFileAgainstManifest( + manifestBytes, + manifestMainSection, + entryNameToManifestSection, + supportedApkSigSchemeNames, + foundApkSigSchemeIds, + minSdkVersion, + maxSdkVersion); + if (signer.isIgnored()) { + result.ignoredSigners.add(signer.getResult()); + } else { + if (signer.getResult().containsErrors()) { + result.signers.add(signer.getResult()); + } else { + remainingSigners.add(signer); + } + } + } + if (result.containsErrors()) { + return; + } + signers = remainingSigners; + if (signers.isEmpty()) { + result.addError(Issue.JAR_SIG_NO_SIGNATURES); + return; + } + // STATE OF AFFAIRS: + // * All signature files (.SF) verify against corresponding block files (.RSA|.DSA|.EC). + // * Contents of all JAR manifest sections listed in .SF files verify against .SF files. + // * All JAR entries listed in JAR manifest are present in the APK. + + // Verify data of JAR entries against JAR manifest and .SF files. On Android, an APK's + // JAR entry is considered signed by signers associated with an .SF file iff the entry + // is mentioned in the .SF file and the entry's digest(s) mentioned in the JAR manifest + // match theentry's uncompressed data. Android requires that all such JAR entries are + // signed by the same set of signers. This set may be smaller than the set of signers + // we've identified so far. + Set<Signer> apkSigners = + verifyJarEntriesAgainstManifestAndSigners( + apk, + cdStartOffset, + cdRecords, + entryNameToManifestSection, + signers, + minSdkVersion, + maxSdkVersion, + result); + if (result.containsErrors()) { + return; + } + // STATE OF AFFAIRS: + // * All signature files (.SF) verify against corresponding block files (.RSA|.DSA|.EC). + // * Contents of all JAR manifest sections listed in .SF files verify against .SF files. + // * All JAR entries listed in JAR manifest are present in the APK. + // * All JAR entries present in the APK and supposed to be covered by JAR signature + // (i.e., reside outside of META-INF/) are covered by signatures from the same set + // of signers. + + // Report any JAR entries which aren't covered by signature. + Set<String> signatureEntryNames = new HashSet<>(1 + result.signers.size() * 2); + signatureEntryNames.add(manifestEntry.getName()); + for (Signer signer : apkSigners) { + signatureEntryNames.add(signer.getSignatureBlockEntryName()); + signatureEntryNames.add(signer.getSignatureFileEntryName()); + } + for (CentralDirectoryRecord cdRecord : cdRecords) { + String entryName = cdRecord.getName(); + if ((entryName.startsWith("META-INF/")) + && (!entryName.endsWith("/")) + && (!signatureEntryNames.contains(entryName))) { + result.addWarning(Issue.JAR_SIG_UNPROTECTED_ZIP_ENTRY, entryName); + } + } + + // Reflect the sets of used signers and ignored signers in the result. + for (Signer signer : signers) { + if (apkSigners.contains(signer)) { + result.signers.add(signer.getResult()); + } else { + result.ignoredSigners.add(signer.getResult()); + } + } + + result.verified = true; + } + } + + static class Signer { + private final String mName; + private final Result.SignerInfo mResult; + private final CentralDirectoryRecord mSignatureFileEntry; + private final CentralDirectoryRecord mSignatureBlockEntry; + private boolean mIgnored; + + private byte[] mSigFileBytes; + private Set<String> mSigFileEntryNames; + + private Signer( + String name, + CentralDirectoryRecord sigBlockEntry, + CentralDirectoryRecord sigFileEntry, + Result.SignerInfo result) { + mName = name; + mResult = result; + mSignatureBlockEntry = sigBlockEntry; + mSignatureFileEntry = sigFileEntry; + } + + public String getName() { + return mName; + } + + public String getSignatureFileEntryName() { + return mSignatureFileEntry.getName(); + } + + public String getSignatureBlockEntryName() { + return mSignatureBlockEntry.getName(); + } + + void setIgnored() { + mIgnored = true; + } + + public boolean isIgnored() { + return mIgnored; + } + + public Set<String> getSigFileEntryNames() { + return mSigFileEntryNames; + } + + public Result.SignerInfo getResult() { + return mResult; + } + + public void verifySigBlockAgainstSigFile( + DataSource apk, long cdStartOffset, int minSdkVersion, int maxSdkVersion) + throws IOException, ApkFormatException, NoSuchAlgorithmException { + // Obtain the signature block from the APK + byte[] sigBlockBytes; + try { + sigBlockBytes = + LocalFileRecord.getUncompressedData( + apk, mSignatureBlockEntry, cdStartOffset); + } catch (ZipFormatException e) { + throw new ApkFormatException( + "Malformed ZIP entry: " + mSignatureBlockEntry.getName(), e); + } + // Obtain the signature file from the APK + try { + mSigFileBytes = + LocalFileRecord.getUncompressedData( + apk, mSignatureFileEntry, cdStartOffset); + } catch (ZipFormatException e) { + throw new ApkFormatException( + "Malformed ZIP entry: " + mSignatureFileEntry.getName(), e); + } + + // Extract PKCS #7 SignedData from the signature block + SignedData signedData; + try { + ContentInfo contentInfo = + Asn1BerParser.parse(ByteBuffer.wrap(sigBlockBytes), ContentInfo.class); + if (!Pkcs7Constants.OID_SIGNED_DATA.equals(contentInfo.contentType)) { + throw new Asn1DecodingException( + "Unsupported ContentInfo.contentType: " + contentInfo.contentType); + } + signedData = + Asn1BerParser.parse(contentInfo.content.getEncoded(), SignedData.class); + } catch (Asn1DecodingException e) { + e.printStackTrace(); + mResult.addError( + Issue.JAR_SIG_PARSE_EXCEPTION, mSignatureBlockEntry.getName(), e); + return; + } + + if (signedData.signerInfos.isEmpty()) { + mResult.addError(Issue.JAR_SIG_NO_SIGNERS, mSignatureBlockEntry.getName()); + return; + } + + // Find the first SignedData.SignerInfos element which verifies against the signature + // file + SignerInfo firstVerifiedSignerInfo = null; + X509Certificate firstVerifiedSignerInfoSigningCertificate = null; + // Prior to Android N, Android attempts to verify only the first SignerInfo. From N + // onwards, Android attempts to verify all SignerInfos and then picks the first verified + // SignerInfo. + List<SignerInfo> unverifiedSignerInfosToTry; + if (minSdkVersion < AndroidSdkVersion.N) { + unverifiedSignerInfosToTry = + Collections.singletonList(signedData.signerInfos.get(0)); + } else { + unverifiedSignerInfosToTry = signedData.signerInfos; + } + List<X509Certificate> signedDataCertificates = null; + for (SignerInfo unverifiedSignerInfo : unverifiedSignerInfosToTry) { + // Parse SignedData.certificates -- they are needed to verify SignerInfo + if (signedDataCertificates == null) { + try { + signedDataCertificates = parseCertificates(signedData.certificates); + } catch (CertificateException e) { + mResult.addError( + Issue.JAR_SIG_PARSE_EXCEPTION, mSignatureBlockEntry.getName(), e); + return; + } + } + + // Verify SignerInfo + X509Certificate signingCertificate; + try { + signingCertificate = + verifySignerInfoAgainstSigFile( + signedData, + signedDataCertificates, + unverifiedSignerInfo, + mSigFileBytes, + minSdkVersion, + maxSdkVersion); + if (mResult.containsErrors()) { + return; + } + if (signingCertificate != null) { + // SignerInfo verified + if (firstVerifiedSignerInfo == null) { + firstVerifiedSignerInfo = unverifiedSignerInfo; + firstVerifiedSignerInfoSigningCertificate = signingCertificate; + } + } + } catch (Pkcs7DecodingException e) { + mResult.addError( + Issue.JAR_SIG_PARSE_EXCEPTION, mSignatureBlockEntry.getName(), e); + return; + } catch (InvalidKeyException | SignatureException e) { + mResult.addError( + Issue.JAR_SIG_VERIFY_EXCEPTION, + mSignatureBlockEntry.getName(), + mSignatureFileEntry.getName(), + e); + return; + } + } + if (firstVerifiedSignerInfo == null) { + // No SignerInfo verified + mResult.addError( + Issue.JAR_SIG_DID_NOT_VERIFY, + mSignatureBlockEntry.getName(), + mSignatureFileEntry.getName()); + return; + } + // Verified + List<X509Certificate> signingCertChain = + getCertificateChain( + signedDataCertificates, firstVerifiedSignerInfoSigningCertificate); + mResult.certChain.clear(); + mResult.certChain.addAll(signingCertChain); + } + + /** + * Returns the signing certificate if the provided {@link SignerInfo} verifies against the + * contents of the provided signature file, or {@code null} if it does not verify. + */ + private X509Certificate verifySignerInfoAgainstSigFile( + SignedData signedData, + Collection<X509Certificate> signedDataCertificates, + SignerInfo signerInfo, + byte[] signatureFile, + int minSdkVersion, + int maxSdkVersion) + throws Pkcs7DecodingException, NoSuchAlgorithmException, + InvalidKeyException, SignatureException { + String digestAlgorithmOid = signerInfo.digestAlgorithm.algorithm; + String signatureAlgorithmOid = signerInfo.signatureAlgorithm.algorithm; + InclusiveIntRange desiredApiLevels = + InclusiveIntRange.fromTo(minSdkVersion, maxSdkVersion); + List<InclusiveIntRange> apiLevelsWhereDigestAndSigAlgorithmSupported = + getSigAlgSupportedApiLevels(digestAlgorithmOid, signatureAlgorithmOid); + List<InclusiveIntRange> apiLevelsWhereDigestAlgorithmNotSupported = + desiredApiLevels.getValuesNotIn(apiLevelsWhereDigestAndSigAlgorithmSupported); + if (!apiLevelsWhereDigestAlgorithmNotSupported.isEmpty()) { + String digestAlgorithmUserFriendly = + OidConstants.OidToUserFriendlyNameMapper.getUserFriendlyNameForOid( + digestAlgorithmOid); + if (digestAlgorithmUserFriendly == null) { + digestAlgorithmUserFriendly = digestAlgorithmOid; + } + String signatureAlgorithmUserFriendly = + OidConstants.OidToUserFriendlyNameMapper.getUserFriendlyNameForOid( + signatureAlgorithmOid); + if (signatureAlgorithmUserFriendly == null) { + signatureAlgorithmUserFriendly = signatureAlgorithmOid; + } + StringBuilder apiLevelsUserFriendly = new StringBuilder(); + for (InclusiveIntRange range : apiLevelsWhereDigestAlgorithmNotSupported) { + if (apiLevelsUserFriendly.length() > 0) { + apiLevelsUserFriendly.append(", "); + } + if (range.getMin() == range.getMax()) { + apiLevelsUserFriendly.append(String.valueOf(range.getMin())); + } else if (range.getMax() == Integer.MAX_VALUE) { + apiLevelsUserFriendly.append(range.getMin() + "+"); + } else { + apiLevelsUserFriendly.append(range.getMin() + "-" + range.getMax()); + } + } + mResult.addError( + Issue.JAR_SIG_UNSUPPORTED_SIG_ALG, + mSignatureBlockEntry.getName(), + digestAlgorithmOid, + signatureAlgorithmOid, + apiLevelsUserFriendly.toString(), + digestAlgorithmUserFriendly, + signatureAlgorithmUserFriendly); + return null; + } + + // From the bag of certs, obtain the certificate referenced by the SignerInfo, + // and verify the cryptographic signature in the SignerInfo against the certificate. + + // Locate the signing certificate referenced by the SignerInfo + X509Certificate signingCertificate = + findCertificate(signedDataCertificates, signerInfo.sid); + if (signingCertificate == null) { + throw new SignatureException( + "Signing certificate referenced in SignerInfo not found in" + + " SignedData"); + } + + // Check whether the signing certificate is acceptable. Android performs these + // checks explicitly, instead of delegating this to + // Signature.initVerify(Certificate). + if (signingCertificate.hasUnsupportedCriticalExtension()) { + throw new SignatureException( + "Signing certificate has unsupported critical extensions"); + } + boolean[] keyUsageExtension = signingCertificate.getKeyUsage(); + if (keyUsageExtension != null) { + boolean digitalSignature = + (keyUsageExtension.length >= 1) && (keyUsageExtension[0]); + boolean nonRepudiation = + (keyUsageExtension.length >= 2) && (keyUsageExtension[1]); + if ((!digitalSignature) && (!nonRepudiation)) { + throw new SignatureException( + "Signing certificate not authorized for use in digital signatures" + + ": keyUsage extension missing digitalSignature and" + + " nonRepudiation"); + } + } + + // Verify the cryptographic signature in SignerInfo against the certificate's + // public key + String jcaSignatureAlgorithm = + getJcaSignatureAlgorithm(digestAlgorithmOid, signatureAlgorithmOid); + Signature s = Signature.getInstance(jcaSignatureAlgorithm); + PublicKey publicKey = signingCertificate.getPublicKey(); + try { + s.initVerify(publicKey); + } catch (InvalidKeyException e) { + // An InvalidKeyException could be caught if the PublicKey in the certificate is not + // properly encoded; attempt to resolve any encoding errors, generate a new public + // key, and reattempt the initVerify with the newly encoded key. + try { + byte[] encodedPublicKey = ApkSigningBlockUtils.encodePublicKey(publicKey); + publicKey = KeyFactory.getInstance(publicKey.getAlgorithm()).generatePublic( + new X509EncodedKeySpec(encodedPublicKey)); + } catch (InvalidKeySpecException ikse) { + // If an InvalidKeySpecException is caught then throw the original Exception + // since the key couldn't be properly re-encoded, and the original Exception + // will have more useful debugging info. + throw e; + } + s = Signature.getInstance(jcaSignatureAlgorithm); + s.initVerify(publicKey); + } + + if (signerInfo.signedAttrs != null) { + // Signed attributes present -- verify signature against the ASN.1 DER encoded form + // of signed attributes. This verifies integrity of the signature file because + // signed attributes must contain the digest of the signature file. + if (minSdkVersion < AndroidSdkVersion.KITKAT) { + // Prior to Android KitKat, APKs with signed attributes are unsafe: + // * The APK's contents are not protected by the JAR signature because the + // digest in signed attributes is not verified. This means an attacker can + // arbitrarily modify the APK without invalidating its signature. + // * Luckily, the signature over signed attributes was verified incorrectly + // (over the verbatim IMPLICIT [0] form rather than over re-encoded + // UNIVERSAL SET form) which means that JAR signatures which would verify on + // pre-KitKat Android and yet do not protect the APK from modification could + // be generated only by broken tools or on purpose by the entity signing the + // APK. + // + // We thus reject such unsafe APKs, even if they verify on platforms before + // KitKat. + throw new SignatureException( + "APKs with Signed Attributes broken on platforms with API Level < " + + AndroidSdkVersion.KITKAT); + } + try { + List<Attribute> signedAttributes = + Asn1BerParser.parseImplicitSetOf( + signerInfo.signedAttrs.getEncoded(), Attribute.class); + SignedAttributes signedAttrs = new SignedAttributes(signedAttributes); + if (maxSdkVersion >= AndroidSdkVersion.N) { + // Content Type attribute is checked only on Android N and newer + String contentType = + signedAttrs.getSingleObjectIdentifierValue( + Pkcs7Constants.OID_CONTENT_TYPE); + if (contentType == null) { + throw new SignatureException("No Content Type in signed attributes"); + } + if (!contentType.equals(signedData.encapContentInfo.contentType)) { + // Did not verify: Content type signed attribute does not match + // SignedData.encapContentInfo.eContentType. This fails verification of + // this SignerInfo but should not prevent verification of other + // SignerInfos. Hence, no exception is thrown. + return null; + } + } + byte[] expectedSignatureFileDigest = + signedAttrs.getSingleOctetStringValue( + Pkcs7Constants.OID_MESSAGE_DIGEST); + if (expectedSignatureFileDigest == null) { + throw new SignatureException("No content digest in signed attributes"); + } + byte[] actualSignatureFileDigest = + MessageDigest.getInstance( + getJcaDigestAlgorithm(digestAlgorithmOid)) + .digest(signatureFile); + if (!Arrays.equals( + expectedSignatureFileDigest, actualSignatureFileDigest)) { + // Skip verification: signature file digest in signed attributes does not + // match the signature file. This fails verification of + // this SignerInfo but should not prevent verification of other + // SignerInfos. Hence, no exception is thrown. + return null; + } + } catch (Asn1DecodingException e) { + throw new SignatureException("Failed to parse signed attributes", e); + } + // PKCS #7 requires that signature is over signed attributes re-encoded as + // ASN.1 DER. However, Android does not re-encode except for changing the + // first byte of encoded form from IMPLICIT [0] to UNIVERSAL SET. We do the + // same for maximum compatibility. + ByteBuffer signedAttrsOriginalEncoding = signerInfo.signedAttrs.getEncoded(); + s.update((byte) 0x31); // UNIVERSAL SET + signedAttrsOriginalEncoding.position(1); + s.update(signedAttrsOriginalEncoding); + } else { + // No signed attributes present -- verify signature against the contents of the + // signature file + s.update(signatureFile); + } + byte[] sigBytes = ByteBufferUtils.toByteArray(signerInfo.signature.slice()); + if (!s.verify(sigBytes)) { + // Cryptographic signature did not verify. This fails verification of this + // SignerInfo but should not prevent verification of other SignerInfos. Hence, no + // exception is thrown. + return null; + } + // Cryptographic signature verified + return signingCertificate; + } + + + + public static List<X509Certificate> getCertificateChain( + List<X509Certificate> certs, X509Certificate leaf) { + List<X509Certificate> unusedCerts = new ArrayList<>(certs); + List<X509Certificate> result = new ArrayList<>(1); + result.add(leaf); + unusedCerts.remove(leaf); + X509Certificate root = leaf; + while (!root.getSubjectDN().equals(root.getIssuerDN())) { + Principal targetDn = root.getIssuerDN(); + boolean issuerFound = false; + for (int i = 0; i < unusedCerts.size(); i++) { + X509Certificate unusedCert = unusedCerts.get(i); + if (targetDn.equals(unusedCert.getSubjectDN())) { + issuerFound = true; + unusedCerts.remove(i); + result.add(unusedCert); + root = unusedCert; + break; + } + } + if (!issuerFound) { + break; + } + } + return result; + } + + + + + public void verifySigFileAgainstManifest( + byte[] manifestBytes, + ManifestParser.Section manifestMainSection, + Map<String, ManifestParser.Section> entryNameToManifestSection, + Map<Integer, String> supportedApkSigSchemeNames, + Set<Integer> foundApkSigSchemeIds, + int minSdkVersion, + int maxSdkVersion) throws NoSuchAlgorithmException { + // Inspect the main section of the .SF file. + ManifestParser sf = new ManifestParser(mSigFileBytes); + ManifestParser.Section sfMainSection = sf.readSection(); + if (sfMainSection.getAttributeValue(Attributes.Name.SIGNATURE_VERSION) == null) { + mResult.addError( + Issue.JAR_SIG_MISSING_VERSION_ATTR_IN_SIG_FILE, + mSignatureFileEntry.getName()); + setIgnored(); + return; + } + + if (maxSdkVersion >= AndroidSdkVersion.N) { + // Android N and newer rejects APKs whose .SF file says they were supposed to be + // signed with APK Signature Scheme v2 (or newer) and yet no such signature was + // found. + checkForStrippedApkSignatures( + sfMainSection, supportedApkSigSchemeNames, foundApkSigSchemeIds); + if (mResult.containsErrors()) { + return; + } + } + + boolean createdBySigntool = false; + String createdBy = sfMainSection.getAttributeValue("Created-By"); + if (createdBy != null) { + createdBySigntool = createdBy.indexOf("signtool") != -1; + } + boolean manifestDigestVerified = + verifyManifestDigest( + sfMainSection, + createdBySigntool, + manifestBytes, + minSdkVersion, + maxSdkVersion); + if (!createdBySigntool) { + verifyManifestMainSectionDigest( + sfMainSection, + manifestMainSection, + manifestBytes, + minSdkVersion, + maxSdkVersion); + } + if (mResult.containsErrors()) { + return; + } + + // Inspect per-entry sections of .SF file. Technically, if the digest of JAR manifest + // verifies, per-entry sections should be ignored. However, most Android platform + // implementations require that such sections exist. + List<ManifestParser.Section> sfSections = sf.readAllSections(); + Set<String> sfEntryNames = new HashSet<>(sfSections.size()); + int sfSectionNumber = 0; + for (ManifestParser.Section sfSection : sfSections) { + sfSectionNumber++; + String entryName = sfSection.getName(); + if (entryName == null) { + mResult.addError( + Issue.JAR_SIG_UNNNAMED_SIG_FILE_SECTION, + mSignatureFileEntry.getName(), + sfSectionNumber); + setIgnored(); + return; + } + if (!sfEntryNames.add(entryName)) { + mResult.addError( + Issue.JAR_SIG_DUPLICATE_SIG_FILE_SECTION, + mSignatureFileEntry.getName(), + entryName); + setIgnored(); + return; + } + if (manifestDigestVerified) { + // No need to verify this entry's corresponding JAR manifest entry because the + // JAR manifest verifies in full. + continue; + } + // Whole-file digest of JAR manifest hasn't been verified. Thus, we need to verify + // the digest of the JAR manifest section corresponding to this .SF section. + ManifestParser.Section manifestSection = entryNameToManifestSection.get(entryName); + if (manifestSection == null) { + mResult.addError( + Issue.JAR_SIG_NO_ZIP_ENTRY_DIGEST_IN_SIG_FILE, + entryName, + mSignatureFileEntry.getName()); + setIgnored(); + continue; + } + verifyManifestIndividualSectionDigest( + sfSection, + createdBySigntool, + manifestSection, + manifestBytes, + minSdkVersion, + maxSdkVersion); + } + mSigFileEntryNames = sfEntryNames; + } + + + /** + * Returns {@code true} if the whole-file digest of the manifest against the main section of + * the .SF file. + */ + private boolean verifyManifestDigest( + ManifestParser.Section sfMainSection, + boolean createdBySigntool, + byte[] manifestBytes, + int minSdkVersion, + int maxSdkVersion) throws NoSuchAlgorithmException { + Collection<NamedDigest> expectedDigests = + getDigestsToVerify( + sfMainSection, + ((createdBySigntool) ? "-Digest" : "-Digest-Manifest"), + minSdkVersion, + maxSdkVersion); + boolean digestFound = !expectedDigests.isEmpty(); + if (!digestFound) { + mResult.addWarning( + Issue.JAR_SIG_NO_MANIFEST_DIGEST_IN_SIG_FILE, + mSignatureFileEntry.getName()); + return false; + } + + boolean verified = true; + for (NamedDigest expectedDigest : expectedDigests) { + String jcaDigestAlgorithm = expectedDigest.jcaDigestAlgorithm; + byte[] actual = digest(jcaDigestAlgorithm, manifestBytes); + byte[] expected = expectedDigest.digest; + if (!Arrays.equals(expected, actual)) { + mResult.addWarning( + Issue.JAR_SIG_ZIP_ENTRY_DIGEST_DID_NOT_VERIFY, + V1SchemeConstants.MANIFEST_ENTRY_NAME, + jcaDigestAlgorithm, + mSignatureFileEntry.getName(), + Base64.getEncoder().encodeToString(actual), + Base64.getEncoder().encodeToString(expected)); + verified = false; + } + } + return verified; + } + + /** + * Verifies the digest of the manifest's main section against the main section of the .SF + * file. + */ + private void verifyManifestMainSectionDigest( + ManifestParser.Section sfMainSection, + ManifestParser.Section manifestMainSection, + byte[] manifestBytes, + int minSdkVersion, + int maxSdkVersion) throws NoSuchAlgorithmException { + Collection<NamedDigest> expectedDigests = + getDigestsToVerify( + sfMainSection, + "-Digest-Manifest-Main-Attributes", + minSdkVersion, + maxSdkVersion); + if (expectedDigests.isEmpty()) { + return; + } + + for (NamedDigest expectedDigest : expectedDigests) { + String jcaDigestAlgorithm = expectedDigest.jcaDigestAlgorithm; + byte[] actual = + digest( + jcaDigestAlgorithm, + manifestBytes, + manifestMainSection.getStartOffset(), + manifestMainSection.getSizeBytes()); + byte[] expected = expectedDigest.digest; + if (!Arrays.equals(expected, actual)) { + mResult.addError( + Issue.JAR_SIG_MANIFEST_MAIN_SECTION_DIGEST_DID_NOT_VERIFY, + jcaDigestAlgorithm, + mSignatureFileEntry.getName(), + Base64.getEncoder().encodeToString(actual), + Base64.getEncoder().encodeToString(expected)); + } + } + } + + /** + * Verifies the digest of the manifest's individual section against the corresponding + * individual section of the .SF file. + */ + private void verifyManifestIndividualSectionDigest( + ManifestParser.Section sfIndividualSection, + boolean createdBySigntool, + ManifestParser.Section manifestIndividualSection, + byte[] manifestBytes, + int minSdkVersion, + int maxSdkVersion) throws NoSuchAlgorithmException { + String entryName = sfIndividualSection.getName(); + Collection<NamedDigest> expectedDigests = + getDigestsToVerify( + sfIndividualSection, "-Digest", minSdkVersion, maxSdkVersion); + if (expectedDigests.isEmpty()) { + mResult.addError( + Issue.JAR_SIG_NO_ZIP_ENTRY_DIGEST_IN_SIG_FILE, + entryName, + mSignatureFileEntry.getName()); + return; + } + + int sectionStartIndex = manifestIndividualSection.getStartOffset(); + int sectionSizeBytes = manifestIndividualSection.getSizeBytes(); + if (createdBySigntool) { + int sectionEndIndex = sectionStartIndex + sectionSizeBytes; + if ((manifestBytes[sectionEndIndex - 1] == '\n') + && (manifestBytes[sectionEndIndex - 2] == '\n')) { + sectionSizeBytes--; + } + } + for (NamedDigest expectedDigest : expectedDigests) { + String jcaDigestAlgorithm = expectedDigest.jcaDigestAlgorithm; + byte[] actual = + digest( + jcaDigestAlgorithm, + manifestBytes, + sectionStartIndex, + sectionSizeBytes); + byte[] expected = expectedDigest.digest; + if (!Arrays.equals(expected, actual)) { + mResult.addError( + Issue.JAR_SIG_MANIFEST_SECTION_DIGEST_DID_NOT_VERIFY, + entryName, + jcaDigestAlgorithm, + mSignatureFileEntry.getName(), + Base64.getEncoder().encodeToString(actual), + Base64.getEncoder().encodeToString(expected)); + } + } + } + + private void checkForStrippedApkSignatures( + ManifestParser.Section sfMainSection, + Map<Integer, String> supportedApkSigSchemeNames, + Set<Integer> foundApkSigSchemeIds) { + String signedWithApkSchemes = + sfMainSection.getAttributeValue( + V1SchemeConstants.SF_ATTRIBUTE_NAME_ANDROID_APK_SIGNED_NAME_STR); + // This field contains a comma-separated list of APK signature scheme IDs which were + // used to sign this APK. Android rejects APKs where an ID is known to the platform but + // the APK didn't verify using that scheme. + + if (signedWithApkSchemes == null) { + // APK signature (e.g., v2 scheme) stripping protections not enabled. + if (!foundApkSigSchemeIds.isEmpty()) { + // APK is signed with an APK signature scheme such as v2 scheme. + mResult.addWarning( + Issue.JAR_SIG_NO_APK_SIG_STRIP_PROTECTION, + mSignatureFileEntry.getName()); + } + return; + } + + if (supportedApkSigSchemeNames.isEmpty()) { + return; + } + + Set<Integer> supportedApkSigSchemeIds = supportedApkSigSchemeNames.keySet(); + Set<Integer> supportedExpectedApkSigSchemeIds = new HashSet<>(1); + StringTokenizer tokenizer = new StringTokenizer(signedWithApkSchemes, ","); + while (tokenizer.hasMoreTokens()) { + String idText = tokenizer.nextToken().trim(); + if (idText.isEmpty()) { + continue; + } + int id; + try { + id = Integer.parseInt(idText); + } catch (Exception ignored) { + continue; + } + // This APK was supposed to be signed with the APK signature scheme having + // this ID. + if (supportedApkSigSchemeIds.contains(id)) { + supportedExpectedApkSigSchemeIds.add(id); + } else { + mResult.addWarning( + Issue.JAR_SIG_UNKNOWN_APK_SIG_SCHEME_ID, + mSignatureFileEntry.getName(), + id); + } + } + + for (int id : supportedExpectedApkSigSchemeIds) { + if (!foundApkSigSchemeIds.contains(id)) { + String apkSigSchemeName = supportedApkSigSchemeNames.get(id); + mResult.addError( + Issue.JAR_SIG_MISSING_APK_SIG_REFERENCED, + mSignatureFileEntry.getName(), + id, + apkSigSchemeName); + } + } + } + } + + public static Collection<NamedDigest> getDigestsToVerify( + ManifestParser.Section section, + String digestAttrSuffix, + int minSdkVersion, + int maxSdkVersion) { + Decoder base64Decoder = Base64.getDecoder(); + List<NamedDigest> result = new ArrayList<>(1); + if (minSdkVersion < AndroidSdkVersion.JELLY_BEAN_MR2) { + // Prior to JB MR2, Android platform's logic for picking a digest algorithm to verify is + // to rely on the ancient Digest-Algorithms attribute which contains + // whitespace-separated list of digest algorithms (defaulting to SHA-1) to try. The + // first digest attribute (with supported digest algorithm) found using the list is + // used. + String algs = section.getAttributeValue("Digest-Algorithms"); + if (algs == null) { + algs = "SHA SHA1"; + } + StringTokenizer tokens = new StringTokenizer(algs); + while (tokens.hasMoreTokens()) { + String alg = tokens.nextToken(); + String attrName = alg + digestAttrSuffix; + String digestBase64 = section.getAttributeValue(attrName); + if (digestBase64 == null) { + // Attribute not found + continue; + } + alg = getCanonicalJcaMessageDigestAlgorithm(alg); + if ((alg == null) + || (getMinSdkVersionFromWhichSupportedInManifestOrSignatureFile(alg) + > minSdkVersion)) { + // Unsupported digest algorithm + continue; + } + // Supported digest algorithm + result.add(new NamedDigest(alg, base64Decoder.decode(digestBase64))); + break; + } + // No supported digests found -- this will fail to verify on pre-JB MR2 Androids. + if (result.isEmpty()) { + return result; + } + } + + if (maxSdkVersion >= AndroidSdkVersion.JELLY_BEAN_MR2) { + // On JB MR2 and newer, Android platform picks the strongest algorithm out of: + // SHA-512, SHA-384, SHA-256, SHA-1. + for (String alg : JB_MR2_AND_NEWER_DIGEST_ALGS) { + String attrName = getJarDigestAttributeName(alg, digestAttrSuffix); + String digestBase64 = section.getAttributeValue(attrName); + if (digestBase64 == null) { + // Attribute not found + continue; + } + byte[] digest = base64Decoder.decode(digestBase64); + byte[] digestInResult = getDigest(result, alg); + if ((digestInResult == null) || (!Arrays.equals(digestInResult, digest))) { + result.add(new NamedDigest(alg, digest)); + } + break; + } + } + + return result; + } + + private static final String[] JB_MR2_AND_NEWER_DIGEST_ALGS = { + "SHA-512", + "SHA-384", + "SHA-256", + "SHA-1", + }; + + private static String getCanonicalJcaMessageDigestAlgorithm(String algorithm) { + return UPPER_CASE_JCA_DIGEST_ALG_TO_CANONICAL.get(algorithm.toUpperCase(Locale.US)); + } + + public static int getMinSdkVersionFromWhichSupportedInManifestOrSignatureFile( + String jcaAlgorithmName) { + Integer result = + MIN_SDK_VESION_FROM_WHICH_DIGEST_SUPPORTED_IN_MANIFEST.get( + jcaAlgorithmName.toUpperCase(Locale.US)); + return (result != null) ? result : Integer.MAX_VALUE; + } + + private static String getJarDigestAttributeName( + String jcaDigestAlgorithm, String attrNameSuffix) { + if ("SHA-1".equalsIgnoreCase(jcaDigestAlgorithm)) { + return "SHA1" + attrNameSuffix; + } else { + return jcaDigestAlgorithm + attrNameSuffix; + } + } + + private static final Map<String, String> UPPER_CASE_JCA_DIGEST_ALG_TO_CANONICAL; + static { + UPPER_CASE_JCA_DIGEST_ALG_TO_CANONICAL = new HashMap<>(8); + UPPER_CASE_JCA_DIGEST_ALG_TO_CANONICAL.put("MD5", "MD5"); + UPPER_CASE_JCA_DIGEST_ALG_TO_CANONICAL.put("SHA", "SHA-1"); + UPPER_CASE_JCA_DIGEST_ALG_TO_CANONICAL.put("SHA1", "SHA-1"); + UPPER_CASE_JCA_DIGEST_ALG_TO_CANONICAL.put("SHA-1", "SHA-1"); + UPPER_CASE_JCA_DIGEST_ALG_TO_CANONICAL.put("SHA-256", "SHA-256"); + UPPER_CASE_JCA_DIGEST_ALG_TO_CANONICAL.put("SHA-384", "SHA-384"); + UPPER_CASE_JCA_DIGEST_ALG_TO_CANONICAL.put("SHA-512", "SHA-512"); + } + + private static final Map<String, Integer> + MIN_SDK_VESION_FROM_WHICH_DIGEST_SUPPORTED_IN_MANIFEST; + static { + MIN_SDK_VESION_FROM_WHICH_DIGEST_SUPPORTED_IN_MANIFEST = new HashMap<>(5); + MIN_SDK_VESION_FROM_WHICH_DIGEST_SUPPORTED_IN_MANIFEST.put("MD5", 0); + MIN_SDK_VESION_FROM_WHICH_DIGEST_SUPPORTED_IN_MANIFEST.put("SHA-1", 0); + MIN_SDK_VESION_FROM_WHICH_DIGEST_SUPPORTED_IN_MANIFEST.put("SHA-256", 0); + MIN_SDK_VESION_FROM_WHICH_DIGEST_SUPPORTED_IN_MANIFEST.put( + "SHA-384", AndroidSdkVersion.GINGERBREAD); + MIN_SDK_VESION_FROM_WHICH_DIGEST_SUPPORTED_IN_MANIFEST.put( + "SHA-512", AndroidSdkVersion.GINGERBREAD); + } + + private static byte[] getDigest(Collection<NamedDigest> digests, String jcaDigestAlgorithm) { + for (NamedDigest digest : digests) { + if (digest.jcaDigestAlgorithm.equalsIgnoreCase(jcaDigestAlgorithm)) { + return digest.digest; + } + } + return null; + } + + public static List<CentralDirectoryRecord> parseZipCentralDirectory( + DataSource apk, + ApkUtils.ZipSections apkSections) + throws IOException, ApkFormatException { + return ZipUtils.parseZipCentralDirectory(apk, apkSections); + } + + /** + * Returns {@code true} if the provided JAR entry must be mentioned in signed JAR archive's + * manifest for the APK to verify on Android. + */ + private static boolean isJarEntryDigestNeededInManifest(String entryName) { + // NOTE: This logic is different from what's required by the JAR signing scheme. This is + // because Android's APK verification logic differs from that spec. In particular, JAR + // signing spec includes into JAR manifest all files in subdirectories of META-INF and + // any files inside META-INF not related to signatures. + if (entryName.startsWith("META-INF/")) { + return false; + } + return !entryName.endsWith("/"); + } + + private static Set<Signer> verifyJarEntriesAgainstManifestAndSigners( + DataSource apk, + long cdOffsetInApk, + Collection<CentralDirectoryRecord> cdRecords, + Map<String, ManifestParser.Section> entryNameToManifestSection, + List<Signer> signers, + int minSdkVersion, + int maxSdkVersion, + Result result) throws ApkFormatException, IOException, NoSuchAlgorithmException { + // Iterate over APK contents as sequentially as possible to improve performance. + List<CentralDirectoryRecord> cdRecordsSortedByLocalFileHeaderOffset = + new ArrayList<>(cdRecords); + Collections.sort( + cdRecordsSortedByLocalFileHeaderOffset, + CentralDirectoryRecord.BY_LOCAL_FILE_HEADER_OFFSET_COMPARATOR); + List<Signer> firstSignedEntrySigners = null; + String firstSignedEntryName = null; + for (CentralDirectoryRecord cdRecord : cdRecordsSortedByLocalFileHeaderOffset) { + String entryName = cdRecord.getName(); + if (!isJarEntryDigestNeededInManifest(entryName)) { + continue; + } + + ManifestParser.Section manifestSection = entryNameToManifestSection.get(entryName); + if (manifestSection == null) { + result.addError(Issue.JAR_SIG_NO_ZIP_ENTRY_DIGEST_IN_MANIFEST, entryName); + continue; + } + + List<Signer> entrySigners = new ArrayList<>(signers.size()); + for (Signer signer : signers) { + if (signer.getSigFileEntryNames().contains(entryName)) { + entrySigners.add(signer); + } + } + if (entrySigners.isEmpty()) { + result.addError(Issue.JAR_SIG_ZIP_ENTRY_NOT_SIGNED, entryName); + continue; + } + if (firstSignedEntrySigners == null) { + firstSignedEntrySigners = entrySigners; + firstSignedEntryName = entryName; + } else if (!entrySigners.equals(firstSignedEntrySigners)) { + result.addError( + Issue.JAR_SIG_ZIP_ENTRY_SIGNERS_MISMATCH, + firstSignedEntryName, + getSignerNames(firstSignedEntrySigners), + entryName, + getSignerNames(entrySigners)); + continue; + } + + List<NamedDigest> expectedDigests = + new ArrayList<>( + getDigestsToVerify( + manifestSection, "-Digest", minSdkVersion, maxSdkVersion)); + if (expectedDigests.isEmpty()) { + result.addError(Issue.JAR_SIG_NO_ZIP_ENTRY_DIGEST_IN_MANIFEST, entryName); + continue; + } + + MessageDigest[] mds = new MessageDigest[expectedDigests.size()]; + for (int i = 0; i < expectedDigests.size(); i++) { + mds[i] = getMessageDigest(expectedDigests.get(i).jcaDigestAlgorithm); + } + + try { + LocalFileRecord.outputUncompressedData( + apk, + cdRecord, + cdOffsetInApk, + DataSinks.asDataSink(mds)); + } catch (ZipFormatException e) { + throw new ApkFormatException("Malformed ZIP entry: " + entryName, e); + } catch (IOException e) { + throw new IOException("Failed to read entry: " + entryName, e); + } + + for (int i = 0; i < expectedDigests.size(); i++) { + NamedDigest expectedDigest = expectedDigests.get(i); + byte[] actualDigest = mds[i].digest(); + if (!Arrays.equals(expectedDigest.digest, actualDigest)) { + result.addError( + Issue.JAR_SIG_ZIP_ENTRY_DIGEST_DID_NOT_VERIFY, + entryName, + expectedDigest.jcaDigestAlgorithm, + V1SchemeConstants.MANIFEST_ENTRY_NAME, + Base64.getEncoder().encodeToString(actualDigest), + Base64.getEncoder().encodeToString(expectedDigest.digest)); + } + } + } + + if (firstSignedEntrySigners == null) { + result.addError(Issue.JAR_SIG_NO_SIGNED_ZIP_ENTRIES); + return Collections.emptySet(); + } else { + return new HashSet<>(firstSignedEntrySigners); + } + } + + private static List<String> getSignerNames(List<Signer> signers) { + if (signers.isEmpty()) { + return Collections.emptyList(); + } + List<String> result = new ArrayList<>(signers.size()); + for (Signer signer : signers) { + result.add(signer.getName()); + } + return result; + } + + private static MessageDigest getMessageDigest(String algorithm) + throws NoSuchAlgorithmException { + return MessageDigest.getInstance(algorithm); + } + + private static byte[] digest(String algorithm, byte[] data, int offset, int length) + throws NoSuchAlgorithmException { + MessageDigest md = getMessageDigest(algorithm); + md.update(data, offset, length); + return md.digest(); + } + + private static byte[] digest(String algorithm, byte[] data) throws NoSuchAlgorithmException { + return getMessageDigest(algorithm).digest(data); + } + + public static class NamedDigest { + public final String jcaDigestAlgorithm; + public final byte[] digest; + + private NamedDigest(String jcaDigestAlgorithm, byte[] digest) { + this.jcaDigestAlgorithm = jcaDigestAlgorithm; + this.digest = digest; + } + } + + public static class Result { + + /** Whether the APK's JAR signature verifies. */ + public boolean verified; + + /** List of APK's signers. These signers are used by Android. */ + public final List<SignerInfo> signers = new ArrayList<>(); + + /** + * Signers encountered in the APK but not included in the set of the APK's signers. These + * signers are ignored by Android. + */ + public final List<SignerInfo> ignoredSigners = new ArrayList<>(); + + private final List<IssueWithParams> mWarnings = new ArrayList<>(); + private final List<IssueWithParams> mErrors = new ArrayList<>(); + + private boolean containsErrors() { + if (!mErrors.isEmpty()) { + return true; + } + for (SignerInfo signer : signers) { + if (signer.containsErrors()) { + return true; + } + } + return false; + } + + private void addError(Issue msg, Object... parameters) { + mErrors.add(new IssueWithParams(msg, parameters)); + } + + private void addWarning(Issue msg, Object... parameters) { + mWarnings.add(new IssueWithParams(msg, parameters)); + } + + public List<IssueWithParams> getErrors() { + return mErrors; + } + + public List<IssueWithParams> getWarnings() { + return mWarnings; + } + + public static class SignerInfo { + public final String name; + public final String signatureFileName; + public final String signatureBlockFileName; + public final List<X509Certificate> certChain = new ArrayList<>(); + + private final List<IssueWithParams> mWarnings = new ArrayList<>(); + private final List<IssueWithParams> mErrors = new ArrayList<>(); + + private SignerInfo( + String name, String signatureBlockFileName, String signatureFileName) { + this.name = name; + this.signatureBlockFileName = signatureBlockFileName; + this.signatureFileName = signatureFileName; + } + + private boolean containsErrors() { + return !mErrors.isEmpty(); + } + + private void addError(Issue msg, Object... parameters) { + mErrors.add(new IssueWithParams(msg, parameters)); + } + + private void addWarning(Issue msg, Object... parameters) { + mWarnings.add(new IssueWithParams(msg, parameters)); + } + + public List<IssueWithParams> getErrors() { + return mErrors; + } + + public List<IssueWithParams> getWarnings() { + return mWarnings; + } + } + } + + private static class SignedAttributes { + private Map<String, List<Asn1OpaqueObject>> mAttrs; + + public SignedAttributes(Collection<Attribute> attrs) throws Pkcs7DecodingException { + Map<String, List<Asn1OpaqueObject>> result = new HashMap<>(attrs.size()); + for (Attribute attr : attrs) { + if (result.put(attr.attrType, attr.attrValues) != null) { + throw new Pkcs7DecodingException("Duplicate signed attribute: " + attr.attrType); + } + } + mAttrs = result; + } + + private Asn1OpaqueObject getSingleValue(String attrOid) throws Pkcs7DecodingException { + List<Asn1OpaqueObject> values = mAttrs.get(attrOid); + if ((values == null) || (values.isEmpty())) { + return null; + } + if (values.size() > 1) { + throw new Pkcs7DecodingException("Attribute " + attrOid + " has multiple values"); + } + return values.get(0); + } + + public String getSingleObjectIdentifierValue(String attrOid) throws Pkcs7DecodingException { + Asn1OpaqueObject value = getSingleValue(attrOid); + if (value == null) { + return null; + } + try { + return Asn1BerParser.parse(value.getEncoded(), ObjectIdentifierChoice.class).value; + } catch (Asn1DecodingException e) { + throw new Pkcs7DecodingException("Failed to decode OBJECT IDENTIFIER", e); + } + } + + public byte[] getSingleOctetStringValue(String attrOid) throws Pkcs7DecodingException { + Asn1OpaqueObject value = getSingleValue(attrOid); + if (value == null) { + return null; + } + try { + return Asn1BerParser.parse(value.getEncoded(), OctetStringChoice.class).value; + } catch (Asn1DecodingException e) { + throw new Pkcs7DecodingException("Failed to decode OBJECT IDENTIFIER", e); + } + } + } + + @Asn1Class(type = Asn1Type.CHOICE) + public static class OctetStringChoice { + @Asn1Field(type = Asn1Type.OCTET_STRING) + public byte[] value; + } + + @Asn1Class(type = Asn1Type.CHOICE) + public static class ObjectIdentifierChoice { + @Asn1Field(type = Asn1Type.OBJECT_IDENTIFIER) + public String value; + } +} diff --git a/platform/android/java/editor/src/main/java/com/android/apksig/internal/apk/v2/V2SchemeConstants.java b/platform/android/java/editor/src/main/java/com/android/apksig/internal/apk/v2/V2SchemeConstants.java new file mode 100644 index 0000000000..0e244c8373 --- /dev/null +++ b/platform/android/java/editor/src/main/java/com/android/apksig/internal/apk/v2/V2SchemeConstants.java @@ -0,0 +1,25 @@ +/* + * Copyright (C) 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.apksig.internal.apk.v2; + +/** Constants used by the V2 Signature Scheme signing and verification. */ +public class V2SchemeConstants { + private V2SchemeConstants() {} + + public static final int APK_SIGNATURE_SCHEME_V2_BLOCK_ID = 0x7109871a; + public static final int STRIPPING_PROTECTION_ATTR_ID = 0xbeeff00d; +} diff --git a/platform/android/java/editor/src/main/java/com/android/apksig/internal/apk/v2/V2SchemeSigner.java b/platform/android/java/editor/src/main/java/com/android/apksig/internal/apk/v2/V2SchemeSigner.java new file mode 100644 index 0000000000..06da96cf20 --- /dev/null +++ b/platform/android/java/editor/src/main/java/com/android/apksig/internal/apk/v2/V2SchemeSigner.java @@ -0,0 +1,329 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.apksig.internal.apk.v2; + +import static com.android.apksig.Constants.MAX_APK_SIGNERS; +import static com.android.apksig.internal.apk.ApkSigningBlockUtils.encodeAsSequenceOfLengthPrefixedElements; +import static com.android.apksig.internal.apk.ApkSigningBlockUtils.encodeAsSequenceOfLengthPrefixedPairsOfIntAndLengthPrefixedBytes; +import static com.android.apksig.internal.apk.ApkSigningBlockUtils.encodeCertificates; +import static com.android.apksig.internal.apk.ApkSigningBlockUtils.encodePublicKey; + +import com.android.apksig.internal.apk.ApkSigningBlockUtils; +import com.android.apksig.internal.apk.ApkSigningBlockUtils.SignerConfig; +import com.android.apksig.internal.apk.ContentDigestAlgorithm; +import com.android.apksig.internal.apk.SignatureAlgorithm; +import com.android.apksig.internal.util.Pair; +import com.android.apksig.util.DataSource; +import com.android.apksig.util.RunnablesExecutor; + +import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.security.InvalidKeyException; +import java.security.NoSuchAlgorithmException; +import java.security.PublicKey; +import java.security.SignatureException; +import java.security.cert.CertificateEncodingException; +import java.security.interfaces.ECKey; +import java.security.interfaces.RSAKey; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Map; + +/** + * APK Signature Scheme v2 signer. + * + * <p>APK Signature Scheme v2 is a whole-file signature scheme which aims to protect every single + * bit of the APK, as opposed to the JAR Signature Scheme which protects only the names and + * uncompressed contents of ZIP entries. + * + * @see <a href="https://source.android.com/security/apksigning/v2.html">APK Signature Scheme v2</a> + */ +public abstract class V2SchemeSigner { + /* + * The two main goals of APK Signature Scheme v2 are: + * 1. Detect any unauthorized modifications to the APK. This is achieved by making the signature + * cover every byte of the APK being signed. + * 2. Enable much faster signature and integrity verification. This is achieved by requiring + * only a minimal amount of APK parsing before the signature is verified, thus completely + * bypassing ZIP entry decompression and by making integrity verification parallelizable by + * employing a hash tree. + * + * The generated signature block is wrapped into an APK Signing Block and inserted into the + * original APK immediately before the start of ZIP Central Directory. This is to ensure that + * JAR and ZIP parsers continue to work on the signed APK. The APK Signing Block is designed for + * extensibility. For example, a future signature scheme could insert its signatures there as + * well. The contract of the APK Signing Block is that all contents outside of the block must be + * protected by signatures inside the block. + */ + + public static final int APK_SIGNATURE_SCHEME_V2_BLOCK_ID = + V2SchemeConstants.APK_SIGNATURE_SCHEME_V2_BLOCK_ID; + + /** Hidden constructor to prevent instantiation. */ + private V2SchemeSigner() {} + + /** + * Gets the APK Signature Scheme v2 signature algorithms to be used for signing an APK using the + * provided key. + * + * @param minSdkVersion minimum API Level of the platform on which the APK may be installed (see + * AndroidManifest.xml minSdkVersion attribute). + * @throws InvalidKeyException if the provided key is not suitable for signing APKs using APK + * Signature Scheme v2 + */ + public static List<SignatureAlgorithm> getSuggestedSignatureAlgorithms(PublicKey signingKey, + int minSdkVersion, boolean verityEnabled, boolean deterministicDsaSigning) + throws InvalidKeyException { + String keyAlgorithm = signingKey.getAlgorithm(); + if ("RSA".equalsIgnoreCase(keyAlgorithm)) { + // Use RSASSA-PKCS1-v1_5 signature scheme instead of RSASSA-PSS to guarantee + // deterministic signatures which make life easier for OTA updates (fewer files + // changed when deterministic signature schemes are used). + + // Pick a digest which is no weaker than the key. + int modulusLengthBits = ((RSAKey) signingKey).getModulus().bitLength(); + if (modulusLengthBits <= 3072) { + // 3072-bit RSA is roughly 128-bit strong, meaning SHA-256 is a good fit. + List<SignatureAlgorithm> algorithms = new ArrayList<>(); + algorithms.add(SignatureAlgorithm.RSA_PKCS1_V1_5_WITH_SHA256); + if (verityEnabled) { + algorithms.add(SignatureAlgorithm.VERITY_RSA_PKCS1_V1_5_WITH_SHA256); + } + return algorithms; + } else { + // Keys longer than 3072 bit need to be paired with a stronger digest to avoid the + // digest being the weak link. SHA-512 is the next strongest supported digest. + return Collections.singletonList(SignatureAlgorithm.RSA_PKCS1_V1_5_WITH_SHA512); + } + } else if ("DSA".equalsIgnoreCase(keyAlgorithm)) { + // DSA is supported only with SHA-256. + List<SignatureAlgorithm> algorithms = new ArrayList<>(); + algorithms.add( + deterministicDsaSigning ? + SignatureAlgorithm.DETDSA_WITH_SHA256 : + SignatureAlgorithm.DSA_WITH_SHA256); + if (verityEnabled) { + algorithms.add(SignatureAlgorithm.VERITY_DSA_WITH_SHA256); + } + return algorithms; + } else if ("EC".equalsIgnoreCase(keyAlgorithm)) { + // Pick a digest which is no weaker than the key. + int keySizeBits = ((ECKey) signingKey).getParams().getOrder().bitLength(); + if (keySizeBits <= 256) { + // 256-bit Elliptic Curve is roughly 128-bit strong, meaning SHA-256 is a good fit. + List<SignatureAlgorithm> algorithms = new ArrayList<>(); + algorithms.add(SignatureAlgorithm.ECDSA_WITH_SHA256); + if (verityEnabled) { + algorithms.add(SignatureAlgorithm.VERITY_ECDSA_WITH_SHA256); + } + return algorithms; + } else { + // Keys longer than 256 bit need to be paired with a stronger digest to avoid the + // digest being the weak link. SHA-512 is the next strongest supported digest. + return Collections.singletonList(SignatureAlgorithm.ECDSA_WITH_SHA512); + } + } else { + throw new InvalidKeyException("Unsupported key algorithm: " + keyAlgorithm); + } + } + + public static ApkSigningBlockUtils.SigningSchemeBlockAndDigests + generateApkSignatureSchemeV2Block(RunnablesExecutor executor, + DataSource beforeCentralDir, + DataSource centralDir, + DataSource eocd, + List<SignerConfig> signerConfigs, + boolean v3SigningEnabled) + throws IOException, InvalidKeyException, NoSuchAlgorithmException, + SignatureException { + return generateApkSignatureSchemeV2Block(executor, beforeCentralDir, centralDir, eocd, + signerConfigs, v3SigningEnabled, null); + } + + public static ApkSigningBlockUtils.SigningSchemeBlockAndDigests + generateApkSignatureSchemeV2Block( + RunnablesExecutor executor, + DataSource beforeCentralDir, + DataSource centralDir, + DataSource eocd, + List<SignerConfig> signerConfigs, + boolean v3SigningEnabled, + List<byte[]> preservedV2SignerBlocks) + throws IOException, InvalidKeyException, NoSuchAlgorithmException, + SignatureException { + Pair<List<SignerConfig>, Map<ContentDigestAlgorithm, byte[]>> digestInfo = + ApkSigningBlockUtils.computeContentDigests( + executor, beforeCentralDir, centralDir, eocd, signerConfigs); + return new ApkSigningBlockUtils.SigningSchemeBlockAndDigests( + generateApkSignatureSchemeV2Block( + digestInfo.getFirst(), digestInfo.getSecond(), v3SigningEnabled, + preservedV2SignerBlocks), + digestInfo.getSecond()); + } + + private static Pair<byte[], Integer> generateApkSignatureSchemeV2Block( + List<SignerConfig> signerConfigs, + Map<ContentDigestAlgorithm, byte[]> contentDigests, + boolean v3SigningEnabled, + List<byte[]> preservedV2SignerBlocks) + throws NoSuchAlgorithmException, InvalidKeyException, SignatureException { + // FORMAT: + // * length-prefixed sequence of length-prefixed signer blocks. + + if (signerConfigs.size() > MAX_APK_SIGNERS) { + throw new IllegalArgumentException( + "APK Signature Scheme v2 only supports a maximum of " + MAX_APK_SIGNERS + ", " + + signerConfigs.size() + " provided"); + } + + List<byte[]> signerBlocks = new ArrayList<>(signerConfigs.size()); + if (preservedV2SignerBlocks != null && preservedV2SignerBlocks.size() > 0) { + signerBlocks.addAll(preservedV2SignerBlocks); + } + int signerNumber = 0; + for (SignerConfig signerConfig : signerConfigs) { + signerNumber++; + byte[] signerBlock; + try { + signerBlock = generateSignerBlock(signerConfig, contentDigests, v3SigningEnabled); + } catch (InvalidKeyException e) { + throw new InvalidKeyException("Signer #" + signerNumber + " failed", e); + } catch (SignatureException e) { + throw new SignatureException("Signer #" + signerNumber + " failed", e); + } + signerBlocks.add(signerBlock); + } + + return Pair.of( + encodeAsSequenceOfLengthPrefixedElements( + new byte[][] { + encodeAsSequenceOfLengthPrefixedElements(signerBlocks), + }), + V2SchemeConstants.APK_SIGNATURE_SCHEME_V2_BLOCK_ID); + } + + private static byte[] generateSignerBlock( + SignerConfig signerConfig, + Map<ContentDigestAlgorithm, byte[]> contentDigests, + boolean v3SigningEnabled) + throws NoSuchAlgorithmException, InvalidKeyException, SignatureException { + if (signerConfig.certificates.isEmpty()) { + throw new SignatureException("No certificates configured for signer"); + } + PublicKey publicKey = signerConfig.certificates.get(0).getPublicKey(); + + byte[] encodedPublicKey = encodePublicKey(publicKey); + + V2SignatureSchemeBlock.SignedData signedData = new V2SignatureSchemeBlock.SignedData(); + try { + signedData.certificates = encodeCertificates(signerConfig.certificates); + } catch (CertificateEncodingException e) { + throw new SignatureException("Failed to encode certificates", e); + } + + List<Pair<Integer, byte[]>> digests = + new ArrayList<>(signerConfig.signatureAlgorithms.size()); + for (SignatureAlgorithm signatureAlgorithm : signerConfig.signatureAlgorithms) { + ContentDigestAlgorithm contentDigestAlgorithm = + signatureAlgorithm.getContentDigestAlgorithm(); + byte[] contentDigest = contentDigests.get(contentDigestAlgorithm); + if (contentDigest == null) { + throw new RuntimeException( + contentDigestAlgorithm + + " content digest for " + + signatureAlgorithm + + " not computed"); + } + digests.add(Pair.of(signatureAlgorithm.getId(), contentDigest)); + } + signedData.digests = digests; + signedData.additionalAttributes = generateAdditionalAttributes(v3SigningEnabled); + + V2SignatureSchemeBlock.Signer signer = new V2SignatureSchemeBlock.Signer(); + // FORMAT: + // * length-prefixed sequence of length-prefixed digests: + // * uint32: signature algorithm ID + // * length-prefixed bytes: digest of contents + // * length-prefixed sequence of certificates: + // * length-prefixed bytes: X.509 certificate (ASN.1 DER encoded). + // * length-prefixed sequence of length-prefixed additional attributes: + // * uint32: ID + // * (length - 4) bytes: value + + signer.signedData = + encodeAsSequenceOfLengthPrefixedElements( + new byte[][] { + encodeAsSequenceOfLengthPrefixedPairsOfIntAndLengthPrefixedBytes( + signedData.digests), + encodeAsSequenceOfLengthPrefixedElements(signedData.certificates), + signedData.additionalAttributes, + new byte[0], + }); + signer.publicKey = encodedPublicKey; + signer.signatures = new ArrayList<>(); + signer.signatures = + ApkSigningBlockUtils.generateSignaturesOverData(signerConfig, signer.signedData); + + // FORMAT: + // * length-prefixed signed data + // * length-prefixed sequence of length-prefixed signatures: + // * uint32: signature algorithm ID + // * length-prefixed bytes: signature of signed data + // * length-prefixed bytes: public key (X.509 SubjectPublicKeyInfo, ASN.1 DER encoded) + return encodeAsSequenceOfLengthPrefixedElements( + new byte[][] { + signer.signedData, + encodeAsSequenceOfLengthPrefixedPairsOfIntAndLengthPrefixedBytes( + signer.signatures), + signer.publicKey, + }); + } + + private static byte[] generateAdditionalAttributes(boolean v3SigningEnabled) { + if (v3SigningEnabled) { + // FORMAT (little endian): + // * length-prefixed bytes: attribute pair + // * uint32: ID - STRIPPING_PROTECTION_ATTR_ID in this case + // * uint32: value - 3 (v3 signature scheme id) in this case + int payloadSize = 4 + 4 + 4; + ByteBuffer result = ByteBuffer.allocate(payloadSize); + result.order(ByteOrder.LITTLE_ENDIAN); + result.putInt(payloadSize - 4); + result.putInt(V2SchemeConstants.STRIPPING_PROTECTION_ATTR_ID); + result.putInt(ApkSigningBlockUtils.VERSION_APK_SIGNATURE_SCHEME_V3); + return result.array(); + } else { + return new byte[0]; + } + } + + private static final class V2SignatureSchemeBlock { + private static final class Signer { + public byte[] signedData; + public List<Pair<Integer, byte[]>> signatures; + public byte[] publicKey; + } + + private static final class SignedData { + public List<Pair<Integer, byte[]>> digests; + public List<byte[]> certificates; + public byte[] additionalAttributes; + } + } +} diff --git a/platform/android/java/editor/src/main/java/com/android/apksig/internal/apk/v2/V2SchemeVerifier.java b/platform/android/java/editor/src/main/java/com/android/apksig/internal/apk/v2/V2SchemeVerifier.java new file mode 100644 index 0000000000..4d6e3e1a8c --- /dev/null +++ b/platform/android/java/editor/src/main/java/com/android/apksig/internal/apk/v2/V2SchemeVerifier.java @@ -0,0 +1,471 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.apksig.internal.apk.v2; + +import static com.android.apksig.Constants.MAX_APK_SIGNERS; + +import com.android.apksig.ApkVerifier.Issue; +import com.android.apksig.apk.ApkFormatException; +import com.android.apksig.apk.ApkUtils; +import com.android.apksig.internal.apk.ApkSigningBlockUtils; +import com.android.apksig.internal.apk.ContentDigestAlgorithm; +import com.android.apksig.internal.apk.SignatureAlgorithm; +import com.android.apksig.internal.apk.SignatureInfo; +import com.android.apksig.internal.util.ByteBufferUtils; +import com.android.apksig.internal.util.X509CertificateUtils; +import com.android.apksig.internal.util.GuaranteedEncodedFormX509Certificate; +import com.android.apksig.util.DataSource; +import com.android.apksig.util.RunnablesExecutor; +import java.io.IOException; +import java.nio.BufferUnderflowException; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.security.InvalidAlgorithmParameterException; +import java.security.InvalidKeyException; +import java.security.KeyFactory; +import java.security.NoSuchAlgorithmException; +import java.security.PublicKey; +import java.security.Signature; +import java.security.SignatureException; +import java.security.cert.CertificateException; +import java.security.cert.CertificateFactory; +import java.security.cert.X509Certificate; +import java.security.spec.AlgorithmParameterSpec; +import java.security.spec.X509EncodedKeySpec; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * APK Signature Scheme v2 verifier. + * + * <p>APK Signature Scheme v2 is a whole-file signature scheme which aims to protect every single + * bit of the APK, as opposed to the JAR Signature Scheme which protects only the names and + * uncompressed contents of ZIP entries. + * + * @see <a href="https://source.android.com/security/apksigning/v2.html">APK Signature Scheme v2</a> + */ +public abstract class V2SchemeVerifier { + /** Hidden constructor to prevent instantiation. */ + private V2SchemeVerifier() {} + + /** + * Verifies the provided APK's APK Signature Scheme v2 signatures and returns the result of + * verification. The APK must be considered verified only if + * {@link ApkSigningBlockUtils.Result#verified} is + * {@code true}. If verification fails, the result will contain errors -- see + * {@link ApkSigningBlockUtils.Result#getErrors()}. + * + * <p>Verification succeeds iff the APK's APK Signature Scheme v2 signatures are expected to + * verify on all Android platform versions in the {@code [minSdkVersion, maxSdkVersion]} range. + * If the APK's signature is expected to not verify on any of the specified platform versions, + * this method returns a result with one or more errors and whose + * {@code Result.verified == false}, or this method throws an exception. + * + * @throws ApkFormatException if the APK is malformed + * @throws NoSuchAlgorithmException if the APK's signatures cannot be verified because a + * required cryptographic algorithm implementation is missing + * @throws ApkSigningBlockUtils.SignatureNotFoundException if no APK Signature Scheme v2 + * signatures are found + * @throws IOException if an I/O error occurs when reading the APK + */ + public static ApkSigningBlockUtils.Result verify( + RunnablesExecutor executor, + DataSource apk, + ApkUtils.ZipSections zipSections, + Map<Integer, String> supportedApkSigSchemeNames, + Set<Integer> foundSigSchemeIds, + int minSdkVersion, + int maxSdkVersion) + throws IOException, ApkFormatException, NoSuchAlgorithmException, + ApkSigningBlockUtils.SignatureNotFoundException { + ApkSigningBlockUtils.Result result = new ApkSigningBlockUtils.Result( + ApkSigningBlockUtils.VERSION_APK_SIGNATURE_SCHEME_V2); + SignatureInfo signatureInfo = + ApkSigningBlockUtils.findSignature(apk, zipSections, + V2SchemeConstants.APK_SIGNATURE_SCHEME_V2_BLOCK_ID , result); + + DataSource beforeApkSigningBlock = apk.slice(0, signatureInfo.apkSigningBlockOffset); + DataSource centralDir = + apk.slice( + signatureInfo.centralDirOffset, + signatureInfo.eocdOffset - signatureInfo.centralDirOffset); + ByteBuffer eocd = signatureInfo.eocd; + + verify(executor, + beforeApkSigningBlock, + signatureInfo.signatureBlock, + centralDir, + eocd, + supportedApkSigSchemeNames, + foundSigSchemeIds, + minSdkVersion, + maxSdkVersion, + result); + return result; + } + + /** + * Verifies the provided APK's v2 signatures and outputs the results into the provided + * {@code result}. APK is considered verified only if there are no errors reported in the + * {@code result}. See {@link #verify(RunnablesExecutor, DataSource, ApkUtils.ZipSections, Map, + * Set, int, int)} for more information about the contract of this method. + * + * @param result result populated by this method with interesting information about the APK, + * such as information about signers, and verification errors and warnings. + */ + private static void verify( + RunnablesExecutor executor, + DataSource beforeApkSigningBlock, + ByteBuffer apkSignatureSchemeV2Block, + DataSource centralDir, + ByteBuffer eocd, + Map<Integer, String> supportedApkSigSchemeNames, + Set<Integer> foundSigSchemeIds, + int minSdkVersion, + int maxSdkVersion, + ApkSigningBlockUtils.Result result) + throws IOException, NoSuchAlgorithmException { + Set<ContentDigestAlgorithm> contentDigestsToVerify = new HashSet<>(1); + parseSigners( + apkSignatureSchemeV2Block, + contentDigestsToVerify, + supportedApkSigSchemeNames, + foundSigSchemeIds, + minSdkVersion, + maxSdkVersion, + result); + if (result.containsErrors()) { + return; + } + ApkSigningBlockUtils.verifyIntegrity( + executor, beforeApkSigningBlock, centralDir, eocd, contentDigestsToVerify, result); + if (!result.containsErrors()) { + result.verified = true; + } + } + + /** + * Parses each signer in the provided APK Signature Scheme v2 block and populates corresponding + * {@code signerInfos} of the provided {@code result}. + * + * <p>This verifies signatures over {@code signed-data} block contained in each signer block. + * However, this does not verify the integrity of the rest of the APK but rather simply reports + * the expected digests of the rest of the APK (see {@code contentDigestsToVerify}). + * + * <p>This method adds one or more errors to the {@code result} if a verification error is + * expected to be encountered on an Android platform version in the + * {@code [minSdkVersion, maxSdkVersion]} range. + */ + public static void parseSigners( + ByteBuffer apkSignatureSchemeV2Block, + Set<ContentDigestAlgorithm> contentDigestsToVerify, + Map<Integer, String> supportedApkSigSchemeNames, + Set<Integer> foundApkSigSchemeIds, + int minSdkVersion, + int maxSdkVersion, + ApkSigningBlockUtils.Result result) throws NoSuchAlgorithmException { + ByteBuffer signers; + try { + signers = ApkSigningBlockUtils.getLengthPrefixedSlice(apkSignatureSchemeV2Block); + } catch (ApkFormatException e) { + result.addError(Issue.V2_SIG_MALFORMED_SIGNERS); + return; + } + if (!signers.hasRemaining()) { + result.addError(Issue.V2_SIG_NO_SIGNERS); + return; + } + + CertificateFactory certFactory; + try { + certFactory = CertificateFactory.getInstance("X.509"); + } catch (CertificateException e) { + throw new RuntimeException("Failed to obtain X.509 CertificateFactory", e); + } + int signerCount = 0; + while (signers.hasRemaining()) { + int signerIndex = signerCount; + signerCount++; + ApkSigningBlockUtils.Result.SignerInfo signerInfo = + new ApkSigningBlockUtils.Result.SignerInfo(); + signerInfo.index = signerIndex; + result.signers.add(signerInfo); + try { + ByteBuffer signer = ApkSigningBlockUtils.getLengthPrefixedSlice(signers); + parseSigner( + signer, + certFactory, + signerInfo, + contentDigestsToVerify, + supportedApkSigSchemeNames, + foundApkSigSchemeIds, + minSdkVersion, + maxSdkVersion); + } catch (ApkFormatException | BufferUnderflowException e) { + signerInfo.addError(Issue.V2_SIG_MALFORMED_SIGNER); + return; + } + } + if (signerCount > MAX_APK_SIGNERS) { + result.addError(Issue.V2_SIG_MAX_SIGNATURES_EXCEEDED, MAX_APK_SIGNERS, signerCount); + } + } + + /** + * Parses the provided signer block and populates the {@code result}. + * + * <p>This verifies signatures over {@code signed-data} contained in this block but does not + * verify the integrity of the rest of the APK. To facilitate APK integrity verification, this + * method adds the {@code contentDigestsToVerify}. These digests can then be used to verify the + * integrity of the APK. + * + * <p>This method adds one or more errors to the {@code result} if a verification error is + * expected to be encountered on an Android platform version in the + * {@code [minSdkVersion, maxSdkVersion]} range. + */ + private static void parseSigner( + ByteBuffer signerBlock, + CertificateFactory certFactory, + ApkSigningBlockUtils.Result.SignerInfo result, + Set<ContentDigestAlgorithm> contentDigestsToVerify, + Map<Integer, String> supportedApkSigSchemeNames, + Set<Integer> foundApkSigSchemeIds, + int minSdkVersion, + int maxSdkVersion) throws ApkFormatException, NoSuchAlgorithmException { + ByteBuffer signedData = ApkSigningBlockUtils.getLengthPrefixedSlice(signerBlock); + byte[] signedDataBytes = new byte[signedData.remaining()]; + signedData.get(signedDataBytes); + signedData.flip(); + result.signedData = signedDataBytes; + + ByteBuffer signatures = ApkSigningBlockUtils.getLengthPrefixedSlice(signerBlock); + byte[] publicKeyBytes = ApkSigningBlockUtils.readLengthPrefixedByteArray(signerBlock); + + // Parse the signatures block and identify supported signatures + int signatureCount = 0; + List<ApkSigningBlockUtils.SupportedSignature> supportedSignatures = new ArrayList<>(1); + while (signatures.hasRemaining()) { + signatureCount++; + try { + ByteBuffer signature = ApkSigningBlockUtils.getLengthPrefixedSlice(signatures); + int sigAlgorithmId = signature.getInt(); + byte[] sigBytes = ApkSigningBlockUtils.readLengthPrefixedByteArray(signature); + result.signatures.add( + new ApkSigningBlockUtils.Result.SignerInfo.Signature( + sigAlgorithmId, sigBytes)); + SignatureAlgorithm signatureAlgorithm = SignatureAlgorithm.findById(sigAlgorithmId); + if (signatureAlgorithm == null) { + result.addWarning(Issue.V2_SIG_UNKNOWN_SIG_ALGORITHM, sigAlgorithmId); + continue; + } + supportedSignatures.add( + new ApkSigningBlockUtils.SupportedSignature(signatureAlgorithm, sigBytes)); + } catch (ApkFormatException | BufferUnderflowException e) { + result.addError(Issue.V2_SIG_MALFORMED_SIGNATURE, signatureCount); + return; + } + } + if (result.signatures.isEmpty()) { + result.addError(Issue.V2_SIG_NO_SIGNATURES); + return; + } + + // Verify signatures over signed-data block using the public key + List<ApkSigningBlockUtils.SupportedSignature> signaturesToVerify = null; + try { + signaturesToVerify = + ApkSigningBlockUtils.getSignaturesToVerify( + supportedSignatures, minSdkVersion, maxSdkVersion); + } catch (ApkSigningBlockUtils.NoSupportedSignaturesException e) { + result.addError(Issue.V2_SIG_NO_SUPPORTED_SIGNATURES, e); + return; + } + for (ApkSigningBlockUtils.SupportedSignature signature : signaturesToVerify) { + SignatureAlgorithm signatureAlgorithm = signature.algorithm; + String jcaSignatureAlgorithm = + signatureAlgorithm.getJcaSignatureAlgorithmAndParams().getFirst(); + AlgorithmParameterSpec jcaSignatureAlgorithmParams = + signatureAlgorithm.getJcaSignatureAlgorithmAndParams().getSecond(); + String keyAlgorithm = signatureAlgorithm.getJcaKeyAlgorithm(); + PublicKey publicKey; + try { + publicKey = + KeyFactory.getInstance(keyAlgorithm).generatePublic( + new X509EncodedKeySpec(publicKeyBytes)); + } catch (Exception e) { + result.addError(Issue.V2_SIG_MALFORMED_PUBLIC_KEY, e); + return; + } + try { + Signature sig = Signature.getInstance(jcaSignatureAlgorithm); + sig.initVerify(publicKey); + if (jcaSignatureAlgorithmParams != null) { + sig.setParameter(jcaSignatureAlgorithmParams); + } + signedData.position(0); + sig.update(signedData); + byte[] sigBytes = signature.signature; + if (!sig.verify(sigBytes)) { + result.addError(Issue.V2_SIG_DID_NOT_VERIFY, signatureAlgorithm); + return; + } + result.verifiedSignatures.put(signatureAlgorithm, sigBytes); + contentDigestsToVerify.add(signatureAlgorithm.getContentDigestAlgorithm()); + } catch (InvalidKeyException | InvalidAlgorithmParameterException + | SignatureException e) { + result.addError(Issue.V2_SIG_VERIFY_EXCEPTION, signatureAlgorithm, e); + return; + } + } + + // At least one signature over signedData has verified. We can now parse signed-data. + signedData.position(0); + ByteBuffer digests = ApkSigningBlockUtils.getLengthPrefixedSlice(signedData); + ByteBuffer certificates = ApkSigningBlockUtils.getLengthPrefixedSlice(signedData); + ByteBuffer additionalAttributes = ApkSigningBlockUtils.getLengthPrefixedSlice(signedData); + + // Parse the certificates block + int certificateIndex = -1; + while (certificates.hasRemaining()) { + certificateIndex++; + byte[] encodedCert = ApkSigningBlockUtils.readLengthPrefixedByteArray(certificates); + X509Certificate certificate; + try { + certificate = X509CertificateUtils.generateCertificate(encodedCert, certFactory); + } catch (CertificateException e) { + result.addError( + Issue.V2_SIG_MALFORMED_CERTIFICATE, + certificateIndex, + certificateIndex + 1, + e); + return; + } + // Wrap the cert so that the result's getEncoded returns exactly the original encoded + // form. Without this, getEncoded may return a different form from what was stored in + // the signature. This is because some X509Certificate(Factory) implementations + // re-encode certificates. + certificate = new GuaranteedEncodedFormX509Certificate(certificate, encodedCert); + result.certs.add(certificate); + } + + if (result.certs.isEmpty()) { + result.addError(Issue.V2_SIG_NO_CERTIFICATES); + return; + } + X509Certificate mainCertificate = result.certs.get(0); + byte[] certificatePublicKeyBytes; + try { + certificatePublicKeyBytes = ApkSigningBlockUtils.encodePublicKey( + mainCertificate.getPublicKey()); + } catch (InvalidKeyException e) { + System.out.println("Caught an exception encoding the public key: " + e); + e.printStackTrace(); + certificatePublicKeyBytes = mainCertificate.getPublicKey().getEncoded(); + } + if (!Arrays.equals(publicKeyBytes, certificatePublicKeyBytes)) { + result.addError( + Issue.V2_SIG_PUBLIC_KEY_MISMATCH_BETWEEN_CERTIFICATE_AND_SIGNATURES_RECORD, + ApkSigningBlockUtils.toHex(certificatePublicKeyBytes), + ApkSigningBlockUtils.toHex(publicKeyBytes)); + return; + } + + // Parse the digests block + int digestCount = 0; + while (digests.hasRemaining()) { + digestCount++; + try { + ByteBuffer digest = ApkSigningBlockUtils.getLengthPrefixedSlice(digests); + int sigAlgorithmId = digest.getInt(); + byte[] digestBytes = ApkSigningBlockUtils.readLengthPrefixedByteArray(digest); + result.contentDigests.add( + new ApkSigningBlockUtils.Result.SignerInfo.ContentDigest( + sigAlgorithmId, digestBytes)); + } catch (ApkFormatException | BufferUnderflowException e) { + result.addError(Issue.V2_SIG_MALFORMED_DIGEST, digestCount); + return; + } + } + + List<Integer> sigAlgsFromSignaturesRecord = new ArrayList<>(result.signatures.size()); + for (ApkSigningBlockUtils.Result.SignerInfo.Signature signature : result.signatures) { + sigAlgsFromSignaturesRecord.add(signature.getAlgorithmId()); + } + List<Integer> sigAlgsFromDigestsRecord = new ArrayList<>(result.contentDigests.size()); + for (ApkSigningBlockUtils.Result.SignerInfo.ContentDigest digest : result.contentDigests) { + sigAlgsFromDigestsRecord.add(digest.getSignatureAlgorithmId()); + } + + if (!sigAlgsFromSignaturesRecord.equals(sigAlgsFromDigestsRecord)) { + result.addError( + Issue.V2_SIG_SIG_ALG_MISMATCH_BETWEEN_SIGNATURES_AND_DIGESTS_RECORDS, + sigAlgsFromSignaturesRecord, + sigAlgsFromDigestsRecord); + return; + } + + // Parse the additional attributes block. + int additionalAttributeCount = 0; + Set<Integer> supportedApkSigSchemeIds = supportedApkSigSchemeNames.keySet(); + Set<Integer> supportedExpectedApkSigSchemeIds = new HashSet<>(1); + while (additionalAttributes.hasRemaining()) { + additionalAttributeCount++; + try { + ByteBuffer attribute = + ApkSigningBlockUtils.getLengthPrefixedSlice(additionalAttributes); + int id = attribute.getInt(); + byte[] value = ByteBufferUtils.toByteArray(attribute); + result.additionalAttributes.add( + new ApkSigningBlockUtils.Result.SignerInfo.AdditionalAttribute(id, value)); + switch (id) { + case V2SchemeConstants.STRIPPING_PROTECTION_ATTR_ID: + // stripping protection added when signing with a newer scheme + int foundId = ByteBuffer.wrap(value).order( + ByteOrder.LITTLE_ENDIAN).getInt(); + if (supportedApkSigSchemeIds.contains(foundId)) { + supportedExpectedApkSigSchemeIds.add(foundId); + } else { + result.addWarning( + Issue.V2_SIG_UNKNOWN_APK_SIG_SCHEME_ID, result.index, foundId); + } + break; + default: + result.addWarning(Issue.V2_SIG_UNKNOWN_ADDITIONAL_ATTRIBUTE, id); + } + } catch (ApkFormatException | BufferUnderflowException e) { + result.addError( + Issue.V2_SIG_MALFORMED_ADDITIONAL_ATTRIBUTE, additionalAttributeCount); + return; + } + } + + // make sure that all known IDs indicated in stripping protection have already verified + for (int id : supportedExpectedApkSigSchemeIds) { + if (!foundApkSigSchemeIds.contains(id)) { + String apkSigSchemeName = supportedApkSigSchemeNames.get(id); + result.addError( + Issue.V2_SIG_MISSING_APK_SIG_REFERENCED, + result.index, + apkSigSchemeName); + } + } + } +} diff --git a/platform/android/java/editor/src/main/java/com/android/apksig/internal/apk/v3/V3SchemeConstants.java b/platform/android/java/editor/src/main/java/com/android/apksig/internal/apk/v3/V3SchemeConstants.java new file mode 100644 index 0000000000..dd92da344a --- /dev/null +++ b/platform/android/java/editor/src/main/java/com/android/apksig/internal/apk/v3/V3SchemeConstants.java @@ -0,0 +1,66 @@ +/* + * Copyright (C) 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.apksig.internal.apk.v3; + +import com.android.apksig.internal.util.AndroidSdkVersion; + +/** Constants used by the V3 Signature Scheme signing and verification. */ +public class V3SchemeConstants { + private V3SchemeConstants() {} + + public static final int APK_SIGNATURE_SCHEME_V3_BLOCK_ID = 0xf05368c0; + public static final int APK_SIGNATURE_SCHEME_V31_BLOCK_ID = 0x1b93ad61; + public static final int PROOF_OF_ROTATION_ATTR_ID = 0x3ba06f8c; + + public static final int MIN_SDK_WITH_V3_SUPPORT = AndroidSdkVersion.P; + public static final int MIN_SDK_WITH_V31_SUPPORT = AndroidSdkVersion.T; + /** + * By default, APK signing key rotation will target T, but packages that have previously + * rotated can continue rotating on pre-T by specifying an SDK version <= 32 as the + * --rotation-min-sdk-version parameter when using apksigner or when invoking + * {@link com.android.apksig.ApkSigner.Builder#setMinSdkVersionForRotation(int)}. + */ + public static final int DEFAULT_ROTATION_MIN_SDK_VERSION = AndroidSdkVersion.T; + + /** + * This attribute is intended to be written to the V3.0 signer block as an additional attribute + * whose value is the minimum SDK version supported for rotation by the V3.1 signing block. If + * this value is set to X and a v3.1 signing block does not exist, or the minimum SDK version + * for rotation in the v3.1 signing block is not X, then the APK should be rejected. + */ + public static final int ROTATION_MIN_SDK_VERSION_ATTR_ID = 0x559f8b02; + + /** + * This attribute is written to the V3.1 signer block as an additional attribute to signify that + * the rotation-min-sdk-version is targeting a development release. This is required to support + * testing rotation on new development releases as the previous platform release SDK version + * is used as the development release SDK version until the development release SDK is + * finalized. + */ + public static final int ROTATION_ON_DEV_RELEASE_ATTR_ID = 0xc2a6b3ba; + + /** + * The current development release; rotation / signing configs targeting this release should + * be written with the {@link #PROD_RELEASE} SDK version and the dev release attribute. + */ + public static final int DEV_RELEASE = AndroidSdkVersion.U; + + /** + * The current production release. + */ + public static final int PROD_RELEASE = AndroidSdkVersion.T; +} diff --git a/platform/android/java/editor/src/main/java/com/android/apksig/internal/apk/v3/V3SchemeSigner.java b/platform/android/java/editor/src/main/java/com/android/apksig/internal/apk/v3/V3SchemeSigner.java new file mode 100644 index 0000000000..28f6589710 --- /dev/null +++ b/platform/android/java/editor/src/main/java/com/android/apksig/internal/apk/v3/V3SchemeSigner.java @@ -0,0 +1,531 @@ +/* + * Copyright (C) 2018 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.apksig.internal.apk.v3; + +import static com.android.apksig.internal.apk.ApkSigningBlockUtils.encodeAsLengthPrefixedElement; +import static com.android.apksig.internal.apk.ApkSigningBlockUtils.encodeAsSequenceOfLengthPrefixedElements; +import static com.android.apksig.internal.apk.ApkSigningBlockUtils.encodeAsSequenceOfLengthPrefixedPairsOfIntAndLengthPrefixedBytes; +import static com.android.apksig.internal.apk.ApkSigningBlockUtils.encodeCertificates; +import static com.android.apksig.internal.apk.ApkSigningBlockUtils.encodePublicKey; + +import com.android.apksig.SigningCertificateLineage; +import com.android.apksig.internal.apk.ApkSigningBlockUtils; +import com.android.apksig.internal.apk.ApkSigningBlockUtils.SigningSchemeBlockAndDigests; +import com.android.apksig.internal.apk.ApkSigningBlockUtils.SignerConfig; +import com.android.apksig.internal.apk.ContentDigestAlgorithm; +import com.android.apksig.internal.apk.SignatureAlgorithm; +import com.android.apksig.internal.util.Pair; +import com.android.apksig.util.DataSource; +import com.android.apksig.util.RunnablesExecutor; + +import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.security.InvalidKeyException; +import java.security.NoSuchAlgorithmException; +import java.security.PublicKey; +import java.security.SignatureException; +import java.security.cert.CertificateEncodingException; +import java.security.interfaces.ECKey; +import java.security.interfaces.RSAKey; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.OptionalInt; + +/** + * APK Signature Scheme v3 signer. + * + * <p>APK Signature Scheme v3 builds upon APK Signature Scheme v3, and maintains all of the APK + * Signature Scheme v2 goals. + * + * @see <a href="https://source.android.com/security/apksigning/v2.html">APK Signature Scheme v2</a> + * <p>The main contribution of APK Signature Scheme v3 is the introduction of the {@link + * SigningCertificateLineage}, which enables an APK to change its signing certificate as long as + * it can prove the new siging certificate was signed by the old. + */ +public class V3SchemeSigner { + public static final int APK_SIGNATURE_SCHEME_V3_BLOCK_ID = + V3SchemeConstants.APK_SIGNATURE_SCHEME_V3_BLOCK_ID; + public static final int PROOF_OF_ROTATION_ATTR_ID = V3SchemeConstants.PROOF_OF_ROTATION_ATTR_ID; + + private final RunnablesExecutor mExecutor; + private final DataSource mBeforeCentralDir; + private final DataSource mCentralDir; + private final DataSource mEocd; + private final List<SignerConfig> mSignerConfigs; + private final int mBlockId; + private final OptionalInt mOptionalV31MinSdkVersion; + private final boolean mRotationTargetsDevRelease; + + private V3SchemeSigner(DataSource beforeCentralDir, + DataSource centralDir, + DataSource eocd, + List<SignerConfig> signerConfigs, + RunnablesExecutor executor, + int blockId, + OptionalInt optionalV31MinSdkVersion, + boolean rotationTargetsDevRelease) { + mBeforeCentralDir = beforeCentralDir; + mCentralDir = centralDir; + mEocd = eocd; + mSignerConfigs = signerConfigs; + mExecutor = executor; + mBlockId = blockId; + mOptionalV31MinSdkVersion = optionalV31MinSdkVersion; + mRotationTargetsDevRelease = rotationTargetsDevRelease; + } + + /** + * Gets the APK Signature Scheme v3 signature algorithms to be used for signing an APK using the + * provided key. + * + * @param minSdkVersion minimum API Level of the platform on which the APK may be installed (see + * AndroidManifest.xml minSdkVersion attribute). + * @throws InvalidKeyException if the provided key is not suitable for signing APKs using APK + * Signature Scheme v3 + */ + public static List<SignatureAlgorithm> getSuggestedSignatureAlgorithms(PublicKey signingKey, + int minSdkVersion, boolean verityEnabled, boolean deterministicDsaSigning) + throws InvalidKeyException { + String keyAlgorithm = signingKey.getAlgorithm(); + if ("RSA".equalsIgnoreCase(keyAlgorithm)) { + // Use RSASSA-PKCS1-v1_5 signature scheme instead of RSASSA-PSS to guarantee + // deterministic signatures which make life easier for OTA updates (fewer files + // changed when deterministic signature schemes are used). + + // Pick a digest which is no weaker than the key. + int modulusLengthBits = ((RSAKey) signingKey).getModulus().bitLength(); + if (modulusLengthBits <= 3072) { + // 3072-bit RSA is roughly 128-bit strong, meaning SHA-256 is a good fit. + List<SignatureAlgorithm> algorithms = new ArrayList<>(); + algorithms.add(SignatureAlgorithm.RSA_PKCS1_V1_5_WITH_SHA256); + if (verityEnabled) { + algorithms.add(SignatureAlgorithm.VERITY_RSA_PKCS1_V1_5_WITH_SHA256); + } + return algorithms; + } else { + // Keys longer than 3072 bit need to be paired with a stronger digest to avoid the + // digest being the weak link. SHA-512 is the next strongest supported digest. + return Collections.singletonList(SignatureAlgorithm.RSA_PKCS1_V1_5_WITH_SHA512); + } + } else if ("DSA".equalsIgnoreCase(keyAlgorithm)) { + // DSA is supported only with SHA-256. + List<SignatureAlgorithm> algorithms = new ArrayList<>(); + algorithms.add( + deterministicDsaSigning ? + SignatureAlgorithm.DETDSA_WITH_SHA256 : + SignatureAlgorithm.DSA_WITH_SHA256); + if (verityEnabled) { + algorithms.add(SignatureAlgorithm.VERITY_DSA_WITH_SHA256); + } + return algorithms; + } else if ("EC".equalsIgnoreCase(keyAlgorithm)) { + // Pick a digest which is no weaker than the key. + int keySizeBits = ((ECKey) signingKey).getParams().getOrder().bitLength(); + if (keySizeBits <= 256) { + // 256-bit Elliptic Curve is roughly 128-bit strong, meaning SHA-256 is a good fit. + List<SignatureAlgorithm> algorithms = new ArrayList<>(); + algorithms.add(SignatureAlgorithm.ECDSA_WITH_SHA256); + if (verityEnabled) { + algorithms.add(SignatureAlgorithm.VERITY_ECDSA_WITH_SHA256); + } + return algorithms; + } else { + // Keys longer than 256 bit need to be paired with a stronger digest to avoid the + // digest being the weak link. SHA-512 is the next strongest supported digest. + return Collections.singletonList(SignatureAlgorithm.ECDSA_WITH_SHA512); + } + } else { + throw new InvalidKeyException("Unsupported key algorithm: " + keyAlgorithm); + } + } + + public static SigningSchemeBlockAndDigests generateApkSignatureSchemeV3Block( + RunnablesExecutor executor, + DataSource beforeCentralDir, + DataSource centralDir, + DataSource eocd, + List<SignerConfig> signerConfigs) + throws IOException, InvalidKeyException, NoSuchAlgorithmException, SignatureException { + return new V3SchemeSigner.Builder(beforeCentralDir, centralDir, eocd, signerConfigs) + .setRunnablesExecutor(executor) + .setBlockId(V3SchemeConstants.APK_SIGNATURE_SCHEME_V3_BLOCK_ID) + .build() + .generateApkSignatureSchemeV3BlockAndDigests(); + } + + public static byte[] generateV3SignerAttribute( + SigningCertificateLineage signingCertificateLineage) { + // FORMAT (little endian): + // * length-prefixed bytes: attribute pair + // * uint32: ID + // * bytes: value - encoded V3 SigningCertificateLineage + byte[] encodedLineage = signingCertificateLineage.encodeSigningCertificateLineage(); + int payloadSize = 4 + 4 + encodedLineage.length; + ByteBuffer result = ByteBuffer.allocate(payloadSize); + result.order(ByteOrder.LITTLE_ENDIAN); + result.putInt(4 + encodedLineage.length); + result.putInt(V3SchemeConstants.PROOF_OF_ROTATION_ATTR_ID); + result.put(encodedLineage); + return result.array(); + } + + private static byte[] generateV3RotationMinSdkVersionStrippingProtectionAttribute( + int rotationMinSdkVersion) { + // FORMAT (little endian): + // * length-prefixed bytes: attribute pair + // * uint32: ID + // * bytes: value - int32 representing minimum SDK version for rotation + int payloadSize = 4 + 4 + 4; + ByteBuffer result = ByteBuffer.allocate(payloadSize); + result.order(ByteOrder.LITTLE_ENDIAN); + result.putInt(payloadSize - 4); + result.putInt(V3SchemeConstants.ROTATION_MIN_SDK_VERSION_ATTR_ID); + result.putInt(rotationMinSdkVersion); + return result.array(); + } + + private static byte[] generateV31RotationTargetsDevReleaseAttribute() { + // FORMAT (little endian): + // * length-prefixed bytes: attribute pair + // * uint32: ID + // * bytes: value - No value is used for this attribute + int payloadSize = 4 + 4; + ByteBuffer result = ByteBuffer.allocate(payloadSize); + result.order(ByteOrder.LITTLE_ENDIAN); + result.putInt(payloadSize - 4); + result.putInt(V3SchemeConstants.ROTATION_ON_DEV_RELEASE_ATTR_ID); + return result.array(); + } + + /** + * Generates and returns a new {@link SigningSchemeBlockAndDigests} containing the V3.x + * signing scheme block and digests based on the parameters provided to the {@link Builder}. + * + * @throws IOException if an I/O error occurs + * @throws NoSuchAlgorithmException if a required cryptographic algorithm implementation is + * missing + * @throws InvalidKeyException if the X.509 encoded form of the public key cannot be obtained + * @throws SignatureException if an error occurs when computing digests or generating + * signatures + */ + public SigningSchemeBlockAndDigests generateApkSignatureSchemeV3BlockAndDigests() + throws IOException, InvalidKeyException, NoSuchAlgorithmException, SignatureException { + Pair<List<SignerConfig>, Map<ContentDigestAlgorithm, byte[]>> digestInfo = + ApkSigningBlockUtils.computeContentDigests( + mExecutor, mBeforeCentralDir, mCentralDir, mEocd, mSignerConfigs); + return new SigningSchemeBlockAndDigests( + generateApkSignatureSchemeV3Block(digestInfo.getSecond()), digestInfo.getSecond()); + } + + private Pair<byte[], Integer> generateApkSignatureSchemeV3Block( + Map<ContentDigestAlgorithm, byte[]> contentDigests) + throws NoSuchAlgorithmException, InvalidKeyException, SignatureException { + // FORMAT: + // * length-prefixed sequence of length-prefixed signer blocks. + List<byte[]> signerBlocks = new ArrayList<>(mSignerConfigs.size()); + int signerNumber = 0; + for (SignerConfig signerConfig : mSignerConfigs) { + signerNumber++; + byte[] signerBlock; + try { + signerBlock = generateSignerBlock(signerConfig, contentDigests); + } catch (InvalidKeyException e) { + throw new InvalidKeyException("Signer #" + signerNumber + " failed", e); + } catch (SignatureException e) { + throw new SignatureException("Signer #" + signerNumber + " failed", e); + } + signerBlocks.add(signerBlock); + } + + return Pair.of( + encodeAsSequenceOfLengthPrefixedElements( + new byte[][] { + encodeAsSequenceOfLengthPrefixedElements(signerBlocks), + }), + mBlockId); + } + + private byte[] generateSignerBlock( + SignerConfig signerConfig, Map<ContentDigestAlgorithm, byte[]> contentDigests) + throws NoSuchAlgorithmException, InvalidKeyException, SignatureException { + if (signerConfig.certificates.isEmpty()) { + throw new SignatureException("No certificates configured for signer"); + } + PublicKey publicKey = signerConfig.certificates.get(0).getPublicKey(); + + byte[] encodedPublicKey = encodePublicKey(publicKey); + + V3SignatureSchemeBlock.SignedData signedData = new V3SignatureSchemeBlock.SignedData(); + try { + signedData.certificates = encodeCertificates(signerConfig.certificates); + } catch (CertificateEncodingException e) { + throw new SignatureException("Failed to encode certificates", e); + } + + List<Pair<Integer, byte[]>> digests = + new ArrayList<>(signerConfig.signatureAlgorithms.size()); + for (SignatureAlgorithm signatureAlgorithm : signerConfig.signatureAlgorithms) { + ContentDigestAlgorithm contentDigestAlgorithm = + signatureAlgorithm.getContentDigestAlgorithm(); + byte[] contentDigest = contentDigests.get(contentDigestAlgorithm); + if (contentDigest == null) { + throw new RuntimeException( + contentDigestAlgorithm + + " content digest for " + + signatureAlgorithm + + " not computed"); + } + digests.add(Pair.of(signatureAlgorithm.getId(), contentDigest)); + } + signedData.digests = digests; + signedData.minSdkVersion = signerConfig.minSdkVersion; + signedData.maxSdkVersion = signerConfig.maxSdkVersion; + signedData.additionalAttributes = generateAdditionalAttributes(signerConfig); + + V3SignatureSchemeBlock.Signer signer = new V3SignatureSchemeBlock.Signer(); + + signer.signedData = encodeSignedData(signedData); + + signer.minSdkVersion = signerConfig.minSdkVersion; + signer.maxSdkVersion = signerConfig.maxSdkVersion; + signer.publicKey = encodedPublicKey; + signer.signatures = + ApkSigningBlockUtils.generateSignaturesOverData(signerConfig, signer.signedData); + + return encodeSigner(signer); + } + + private byte[] encodeSigner(V3SignatureSchemeBlock.Signer signer) { + byte[] signedData = encodeAsLengthPrefixedElement(signer.signedData); + byte[] signatures = + encodeAsLengthPrefixedElement( + encodeAsSequenceOfLengthPrefixedPairsOfIntAndLengthPrefixedBytes( + signer.signatures)); + byte[] publicKey = encodeAsLengthPrefixedElement(signer.publicKey); + + // FORMAT: + // * length-prefixed signed data + // * uint32: minSdkVersion + // * uint32: maxSdkVersion + // * length-prefixed sequence of length-prefixed signatures: + // * uint32: signature algorithm ID + // * length-prefixed bytes: signature of signed data + // * length-prefixed bytes: public key (X.509 SubjectPublicKeyInfo, ASN.1 DER encoded) + int payloadSize = signedData.length + 4 + 4 + signatures.length + publicKey.length; + + ByteBuffer result = ByteBuffer.allocate(payloadSize); + result.order(ByteOrder.LITTLE_ENDIAN); + result.put(signedData); + result.putInt(signer.minSdkVersion); + result.putInt(signer.maxSdkVersion); + result.put(signatures); + result.put(publicKey); + + return result.array(); + } + + private byte[] encodeSignedData(V3SignatureSchemeBlock.SignedData signedData) { + byte[] digests = + encodeAsLengthPrefixedElement( + encodeAsSequenceOfLengthPrefixedPairsOfIntAndLengthPrefixedBytes( + signedData.digests)); + byte[] certs = + encodeAsLengthPrefixedElement( + encodeAsSequenceOfLengthPrefixedElements(signedData.certificates)); + byte[] attributes = encodeAsLengthPrefixedElement(signedData.additionalAttributes); + + // FORMAT: + // * length-prefixed sequence of length-prefixed digests: + // * uint32: signature algorithm ID + // * length-prefixed bytes: digest of contents + // * length-prefixed sequence of certificates: + // * length-prefixed bytes: X.509 certificate (ASN.1 DER encoded). + // * uint-32: minSdkVersion + // * uint-32: maxSdkVersion + // * length-prefixed sequence of length-prefixed additional attributes: + // * uint32: ID + // * (length - 4) bytes: value + // * uint32: Proof-of-rotation ID: 0x3ba06f8c + // * length-prefixed roof-of-rotation structure + int payloadSize = digests.length + certs.length + 4 + 4 + attributes.length; + + ByteBuffer result = ByteBuffer.allocate(payloadSize); + result.order(ByteOrder.LITTLE_ENDIAN); + result.put(digests); + result.put(certs); + result.putInt(signedData.minSdkVersion); + result.putInt(signedData.maxSdkVersion); + result.put(attributes); + + return result.array(); + } + + private byte[] generateAdditionalAttributes(SignerConfig signerConfig) { + List<byte[]> attributes = new ArrayList<>(); + if (signerConfig.signingCertificateLineage != null) { + attributes.add(generateV3SignerAttribute(signerConfig.signingCertificateLineage)); + } + if ((mRotationTargetsDevRelease || signerConfig.signerTargetsDevRelease) + && mBlockId == V3SchemeConstants.APK_SIGNATURE_SCHEME_V31_BLOCK_ID) { + attributes.add(generateV31RotationTargetsDevReleaseAttribute()); + } + if (mOptionalV31MinSdkVersion.isPresent() + && mBlockId == V3SchemeConstants.APK_SIGNATURE_SCHEME_V3_BLOCK_ID) { + attributes.add(generateV3RotationMinSdkVersionStrippingProtectionAttribute( + mOptionalV31MinSdkVersion.getAsInt())); + } + int attributesSize = attributes.stream().mapToInt(attribute -> attribute.length).sum(); + byte[] attributesBuffer = new byte[attributesSize]; + if (attributesSize == 0) { + return new byte[0]; + } + int index = 0; + for (byte[] attribute : attributes) { + System.arraycopy(attribute, 0, attributesBuffer, index, attribute.length); + index += attribute.length; + } + return attributesBuffer; + } + + private static final class V3SignatureSchemeBlock { + private static final class Signer { + public byte[] signedData; + public int minSdkVersion; + public int maxSdkVersion; + public List<Pair<Integer, byte[]>> signatures; + public byte[] publicKey; + } + + private static final class SignedData { + public List<Pair<Integer, byte[]>> digests; + public List<byte[]> certificates; + public int minSdkVersion; + public int maxSdkVersion; + public byte[] additionalAttributes; + } + } + + /** Builder of {@link V3SchemeSigner} instances. */ + public static class Builder { + private final DataSource mBeforeCentralDir; + private final DataSource mCentralDir; + private final DataSource mEocd; + private final List<SignerConfig> mSignerConfigs; + + private RunnablesExecutor mExecutor = RunnablesExecutor.MULTI_THREADED; + private int mBlockId = V3SchemeConstants.APK_SIGNATURE_SCHEME_V3_BLOCK_ID; + private OptionalInt mOptionalV31MinSdkVersion = OptionalInt.empty(); + private boolean mRotationTargetsDevRelease = false; + + /** + * Instantiates a new {@code Builder} with an APK's {@code beforeCentralDir}, {@code + * centralDir}, and {@code eocd}, along with a {@link List} of {@code signerConfigs} to + * be used to sign the APK. + */ + public Builder(DataSource beforeCentralDir, DataSource centralDir, DataSource eocd, + List<SignerConfig> signerConfigs) { + mBeforeCentralDir = beforeCentralDir; + mCentralDir = centralDir; + mEocd = eocd; + mSignerConfigs = signerConfigs; + } + + /** + * Sets the {@link RunnablesExecutor} to be used when computing the APK's content digests. + */ + public Builder setRunnablesExecutor(RunnablesExecutor executor) { + mExecutor = executor; + return this; + } + + /** + * Sets the {@code blockId} to be used for the V3 signature block. + * + * <p>This {@code V3SchemeSigner} currently supports the block IDs for the {@link + * V3SchemeConstants#APK_SIGNATURE_SCHEME_V3_BLOCK_ID v3.0} and {@link + * V3SchemeConstants#APK_SIGNATURE_SCHEME_V31_BLOCK_ID v3.1} signature schemes. + */ + public Builder setBlockId(int blockId) { + mBlockId = blockId; + return this; + } + + /** + * Sets the {@code rotationMinSdkVersion} to be written as an additional attribute in each + * signer's block. + * + * <p>This value provides stripping protection to ensure a v3.1 signing block with rotation + * is not modified or removed from the APK's signature block. + */ + public Builder setRotationMinSdkVersion(int rotationMinSdkVersion) { + return setMinSdkVersionForV31(rotationMinSdkVersion); + } + + /** + * Sets the {@code minSdkVersion} to be written as an additional attribute in each + * signer's block. + * + * <p>This value provides the stripping protection to ensure a v3.1 signing block is not + * modified or removed from the APK's signature block. + */ + public Builder setMinSdkVersionForV31(int minSdkVersion) { + if (minSdkVersion == V3SchemeConstants.DEV_RELEASE) { + minSdkVersion = V3SchemeConstants.PROD_RELEASE; + } + mOptionalV31MinSdkVersion = OptionalInt.of(minSdkVersion); + return this; + } + + /** + * Sets whether the minimum SDK version of a signer is intended to target a development + * release; this is primarily required after the T SDK is finalized, and an APK needs to + * target U during its development cycle for rotation. + * + * <p>This is only required after the T SDK is finalized since S and earlier releases do + * not know about the V3.1 block ID, but once T is released and work begins on U, U will + * use the SDK version of T during development. A signer with a minimum SDK version of T's + * SDK version along with setting {@code enabled} to true will allow an APK to use the + * rotated key on a device running U while causing this to be bypassed for T. + * + * <p><em>Note:</em>If the rotation-min-sdk-version is less than or equal to 32 (Android + * Sv2), then the rotated signing key will be used in the v3.0 signing block and this call + * will be a noop. + */ + public Builder setRotationTargetsDevRelease(boolean enabled) { + mRotationTargetsDevRelease = enabled; + return this; + } + + /** + * Returns a new {@link V3SchemeSigner} built with the configuration provided to this + * {@code Builder}. + */ + public V3SchemeSigner build() { + return new V3SchemeSigner(mBeforeCentralDir, + mCentralDir, + mEocd, + mSignerConfigs, + mExecutor, + mBlockId, + mOptionalV31MinSdkVersion, + mRotationTargetsDevRelease); + } + } +} diff --git a/platform/android/java/editor/src/main/java/com/android/apksig/internal/apk/v3/V3SchemeVerifier.java b/platform/android/java/editor/src/main/java/com/android/apksig/internal/apk/v3/V3SchemeVerifier.java new file mode 100644 index 0000000000..bd808f0e66 --- /dev/null +++ b/platform/android/java/editor/src/main/java/com/android/apksig/internal/apk/v3/V3SchemeVerifier.java @@ -0,0 +1,783 @@ +/* + * Copyright (C) 2018 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.apksig.internal.apk.v3; + +import static com.android.apksig.internal.apk.ApkSigningBlockUtils.getLengthPrefixedSlice; +import static com.android.apksig.internal.apk.ApkSigningBlockUtils.readLengthPrefixedByteArray; + +import com.android.apksig.ApkVerificationIssue; +import com.android.apksig.ApkVerifier.Issue; +import com.android.apksig.SigningCertificateLineage; +import com.android.apksig.apk.ApkFormatException; +import com.android.apksig.apk.ApkUtils; +import com.android.apksig.internal.apk.ApkSigningBlockUtils; +import com.android.apksig.internal.apk.ApkSigningBlockUtils.SignatureNotFoundException; +import com.android.apksig.internal.apk.ContentDigestAlgorithm; +import com.android.apksig.internal.apk.SignatureAlgorithm; +import com.android.apksig.internal.apk.SignatureInfo; +import com.android.apksig.internal.util.ByteBufferUtils; +import com.android.apksig.internal.util.GuaranteedEncodedFormX509Certificate; +import com.android.apksig.internal.util.X509CertificateUtils; +import com.android.apksig.util.DataSource; +import com.android.apksig.util.RunnablesExecutor; + +import java.io.IOException; +import java.nio.BufferUnderflowException; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.security.InvalidAlgorithmParameterException; +import java.security.InvalidKeyException; +import java.security.KeyFactory; +import java.security.NoSuchAlgorithmException; +import java.security.PublicKey; +import java.security.Signature; +import java.security.SignatureException; +import java.security.cert.CertificateException; +import java.security.cert.CertificateFactory; +import java.security.cert.X509Certificate; +import java.security.spec.AlgorithmParameterSpec; +import java.security.spec.X509EncodedKeySpec; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashSet; +import java.util.List; +import java.util.OptionalInt; +import java.util.Set; +import java.util.SortedMap; +import java.util.TreeMap; + +/** + * APK Signature Scheme v3 verifier. + * + * <p>APK Signature Scheme v3, like v2 is a whole-file signature scheme which aims to protect every + * single bit of the APK, as opposed to the JAR Signature Scheme which protects only the names and + * uncompressed contents of ZIP entries. + * + * @see <a href="https://source.android.com/security/apksigning/v2.html">APK Signature Scheme v2</a> + */ +public class V3SchemeVerifier { + private final RunnablesExecutor mExecutor; + private final DataSource mApk; + private final ApkUtils.ZipSections mZipSections; + private final ApkSigningBlockUtils.Result mResult; + private final Set<ContentDigestAlgorithm> mContentDigestsToVerify; + private final int mMinSdkVersion; + private final int mMaxSdkVersion; + private final int mBlockId; + private final OptionalInt mOptionalRotationMinSdkVersion; + private final boolean mFullVerification; + + private ByteBuffer mApkSignatureSchemeV3Block; + + private V3SchemeVerifier( + RunnablesExecutor executor, + DataSource apk, + ApkUtils.ZipSections zipSections, + Set<ContentDigestAlgorithm> contentDigestsToVerify, + ApkSigningBlockUtils.Result result, + int minSdkVersion, + int maxSdkVersion, + int blockId, + OptionalInt optionalRotationMinSdkVersion, + boolean fullVerification) { + mExecutor = executor; + mApk = apk; + mZipSections = zipSections; + mContentDigestsToVerify = contentDigestsToVerify; + mResult = result; + mMinSdkVersion = minSdkVersion; + mMaxSdkVersion = maxSdkVersion; + mBlockId = blockId; + mOptionalRotationMinSdkVersion = optionalRotationMinSdkVersion; + mFullVerification = fullVerification; + } + + /** + * Verifies the provided APK's APK Signature Scheme v3 signatures and returns the result of + * verification. The APK must be considered verified only if + * {@link ApkSigningBlockUtils.Result#verified} is + * {@code true}. If verification fails, the result will contain errors -- see + * {@link ApkSigningBlockUtils.Result#getErrors()}. + * + * <p>Verification succeeds iff the APK's APK Signature Scheme v3 signatures are expected to + * verify on all Android platform versions in the {@code [minSdkVersion, maxSdkVersion]} range. + * If the APK's signature is expected to not verify on any of the specified platform versions, + * this method returns a result with one or more errors and whose + * {@code Result.verified == false}, or this method throws an exception. + * + * <p>This method only verifies the v3.0 signing block without platform targeted rotation from + * a v3.1 signing block. To verify a v3.1 signing block, or a v3.0 signing block in the presence + * of a v3.1 block, configure a new {@link V3SchemeVerifier} using the {@code Builder}. + * + * @throws NoSuchAlgorithmException if the APK's signatures cannot be verified because a + * required cryptographic algorithm implementation is missing + * @throws SignatureNotFoundException if no APK Signature Scheme v3 + * signatures are found + * @throws IOException if an I/O error occurs when reading the APK + */ + public static ApkSigningBlockUtils.Result verify( + RunnablesExecutor executor, + DataSource apk, + ApkUtils.ZipSections zipSections, + int minSdkVersion, + int maxSdkVersion) + throws IOException, NoSuchAlgorithmException, SignatureNotFoundException { + return new V3SchemeVerifier.Builder(apk, zipSections, minSdkVersion, maxSdkVersion) + .setRunnablesExecutor(executor) + .setBlockId(V3SchemeConstants.APK_SIGNATURE_SCHEME_V3_BLOCK_ID) + .build() + .verify(); + } + + /** + * Verifies the provided APK's v3 signatures and outputs the results into the provided + * {@code result}. APK is considered verified only if there are no errors reported in the + * {@code result}. See {@link #verify(RunnablesExecutor, DataSource, ApkUtils.ZipSections, int, + * int)} for more information about the contract of this method. + * + * @return {@link ApkSigningBlockUtils.Result} populated with interesting information about the + * APK, such as information about signers, and verification errors and warnings + */ + public ApkSigningBlockUtils.Result verify() + throws IOException, NoSuchAlgorithmException, SignatureNotFoundException { + if (mApk == null || mZipSections == null) { + throw new IllegalStateException( + "A non-null apk and zip sections must be specified to verify an APK's v3 " + + "signatures"); + } + SignatureInfo signatureInfo = + ApkSigningBlockUtils.findSignature(mApk, mZipSections, mBlockId, mResult); + mApkSignatureSchemeV3Block = signatureInfo.signatureBlock; + + DataSource beforeApkSigningBlock = mApk.slice(0, signatureInfo.apkSigningBlockOffset); + DataSource centralDir = + mApk.slice( + signatureInfo.centralDirOffset, + signatureInfo.eocdOffset - signatureInfo.centralDirOffset); + ByteBuffer eocd = signatureInfo.eocd; + + parseSigners(); + + if (mResult.containsErrors()) { + return mResult; + } + ApkSigningBlockUtils.verifyIntegrity(mExecutor, beforeApkSigningBlock, centralDir, eocd, + mContentDigestsToVerify, mResult); + + // make sure that the v3 signers cover the entire targeted sdk version ranges and that the + // longest SigningCertificateHistory, if present, corresponds to the newest platform + // versions + SortedMap<Integer, ApkSigningBlockUtils.Result.SignerInfo> sortedSigners = new TreeMap<>(); + for (ApkSigningBlockUtils.Result.SignerInfo signer : mResult.signers) { + sortedSigners.put(signer.maxSdkVersion, signer); + } + + // first make sure there is neither overlap nor holes + int firstMin = 0; + int lastMax = 0; + int lastLineageSize = 0; + + // while we're iterating through the signers, build up the list of lineages + List<SigningCertificateLineage> lineages = new ArrayList<>(mResult.signers.size()); + + for (ApkSigningBlockUtils.Result.SignerInfo signer : sortedSigners.values()) { + int currentMin = signer.minSdkVersion; + int currentMax = signer.maxSdkVersion; + if (firstMin == 0) { + // first round sets up our basis + firstMin = currentMin; + } else { + // A signer's minimum SDK can equal the previous signer's maximum SDK if this signer + // is targeting a development release. + if (currentMin != (lastMax + 1) + && !(currentMin == lastMax && signerTargetsDevRelease(signer))) { + mResult.addError(Issue.V3_INCONSISTENT_SDK_VERSIONS); + break; + } + } + lastMax = currentMax; + + // also, while we're here, make sure that the lineage sizes only increase + if (signer.signingCertificateLineage != null) { + int currLineageSize = signer.signingCertificateLineage.size(); + if (currLineageSize < lastLineageSize) { + mResult.addError(Issue.V3_INCONSISTENT_LINEAGES); + break; + } + lastLineageSize = currLineageSize; + lineages.add(signer.signingCertificateLineage); + } + } + + // make sure we support our desired sdk ranges; if rotation is present in a v3.1 block + // then the max level only needs to support up to that sdk version for rotation. + if (firstMin > mMinSdkVersion + || lastMax < (mOptionalRotationMinSdkVersion.isPresent() + ? mOptionalRotationMinSdkVersion.getAsInt() - 1 : mMaxSdkVersion)) { + mResult.addError(Issue.V3_MISSING_SDK_VERSIONS, firstMin, lastMax); + } + + try { + mResult.signingCertificateLineage = + SigningCertificateLineage.consolidateLineages(lineages); + } catch (IllegalArgumentException e) { + mResult.addError(Issue.V3_INCONSISTENT_LINEAGES); + } + if (!mResult.containsErrors()) { + mResult.verified = true; + } + return mResult; + } + + /** + * Parses each signer in the provided APK Signature Scheme v3 block and populates corresponding + * {@code signerInfos} of the provided {@code result}. + * + * <p>This verifies signatures over {@code signed-data} block contained in each signer block. + * However, this does not verify the integrity of the rest of the APK but rather simply reports + * the expected digests of the rest of the APK (see {@code contentDigestsToVerify}). + * + * <p>This method adds one or more errors to the {@code result} if a verification error is + * expected to be encountered on an Android platform version in the + * {@code [minSdkVersion, maxSdkVersion]} range. + */ + public static void parseSigners( + ByteBuffer apkSignatureSchemeV3Block, + Set<ContentDigestAlgorithm> contentDigestsToVerify, + ApkSigningBlockUtils.Result result) throws NoSuchAlgorithmException { + try { + new V3SchemeVerifier.Builder(apkSignatureSchemeV3Block) + .setResult(result) + .setContentDigestsToVerify(contentDigestsToVerify) + .setFullVerification(false) + .build() + .parseSigners(); + } catch (IOException | SignatureNotFoundException e) { + // This should never occur since the apkSignatureSchemeV3Block was already provided. + throw new IllegalStateException("An exception was encountered when attempting to parse" + + " the signers from the provided APK Signature Scheme v3 block", e); + } + } + + /** + * Parses each signer in the APK Signature Scheme v3 block and populates corresponding + * {@link ApkSigningBlockUtils.Result.SignerInfo} instances in the + * returned {@link ApkSigningBlockUtils.Result}. + * + * <p>This verifies signatures over {@code signed-data} block contained in each signer block. + * However, this does not verify the integrity of the rest of the APK but rather simply reports + * the expected digests of the rest of the APK (see {@link Builder#setContentDigestsToVerify}). + * + * <p>This method adds one or more errors to the returned {@code Result} if a verification error + * is encountered when parsing the signers. + */ + public ApkSigningBlockUtils.Result parseSigners() + throws IOException, NoSuchAlgorithmException, SignatureNotFoundException { + ByteBuffer signers; + try { + if (mApkSignatureSchemeV3Block == null) { + SignatureInfo signatureInfo = + ApkSigningBlockUtils.findSignature(mApk, mZipSections, mBlockId, mResult); + mApkSignatureSchemeV3Block = signatureInfo.signatureBlock; + } + signers = getLengthPrefixedSlice(mApkSignatureSchemeV3Block); + } catch (ApkFormatException e) { + mResult.addError(Issue.V3_SIG_MALFORMED_SIGNERS); + return mResult; + } + if (!signers.hasRemaining()) { + mResult.addError(Issue.V3_SIG_NO_SIGNERS); + return mResult; + } + + CertificateFactory certFactory; + try { + certFactory = CertificateFactory.getInstance("X.509"); + } catch (CertificateException e) { + throw new RuntimeException("Failed to obtain X.509 CertificateFactory", e); + } + int signerCount = 0; + while (signers.hasRemaining()) { + int signerIndex = signerCount; + signerCount++; + ApkSigningBlockUtils.Result.SignerInfo signerInfo = + new ApkSigningBlockUtils.Result.SignerInfo(); + signerInfo.index = signerIndex; + mResult.signers.add(signerInfo); + try { + ByteBuffer signer = getLengthPrefixedSlice(signers); + parseSigner(signer, certFactory, signerInfo); + } catch (ApkFormatException | BufferUnderflowException e) { + signerInfo.addError(Issue.V3_SIG_MALFORMED_SIGNER); + return mResult; + } + } + return mResult; + } + + /** + * Parses the provided signer block and populates the {@code result}. + * + * <p>This verifies signatures over {@code signed-data} contained in this block, as well as + * the data contained therein, but does not verify the integrity of the rest of the APK. To + * facilitate APK integrity verification, this method adds the {@code contentDigestsToVerify}. + * These digests can then be used to verify the integrity of the APK. + * + * <p>This method adds one or more errors to the {@code result} if a verification error is + * expected to be encountered on an Android platform version in the + * {@code [minSdkVersion, maxSdkVersion]} range. + */ + private void parseSigner(ByteBuffer signerBlock, CertificateFactory certFactory, + ApkSigningBlockUtils.Result.SignerInfo result) + throws ApkFormatException, NoSuchAlgorithmException { + ByteBuffer signedData = getLengthPrefixedSlice(signerBlock); + byte[] signedDataBytes = new byte[signedData.remaining()]; + signedData.get(signedDataBytes); + signedData.flip(); + result.signedData = signedDataBytes; + + int parsedMinSdkVersion = signerBlock.getInt(); + int parsedMaxSdkVersion = signerBlock.getInt(); + result.minSdkVersion = parsedMinSdkVersion; + result.maxSdkVersion = parsedMaxSdkVersion; + if (parsedMinSdkVersion < 0 || parsedMinSdkVersion > parsedMaxSdkVersion) { + result.addError( + Issue.V3_SIG_INVALID_SDK_VERSIONS, parsedMinSdkVersion, parsedMaxSdkVersion); + } + ByteBuffer signatures = getLengthPrefixedSlice(signerBlock); + byte[] publicKeyBytes = readLengthPrefixedByteArray(signerBlock); + + // Parse the signatures block and identify supported signatures + int signatureCount = 0; + List<ApkSigningBlockUtils.SupportedSignature> supportedSignatures = new ArrayList<>(1); + while (signatures.hasRemaining()) { + signatureCount++; + try { + ByteBuffer signature = getLengthPrefixedSlice(signatures); + int sigAlgorithmId = signature.getInt(); + byte[] sigBytes = readLengthPrefixedByteArray(signature); + result.signatures.add( + new ApkSigningBlockUtils.Result.SignerInfo.Signature( + sigAlgorithmId, sigBytes)); + SignatureAlgorithm signatureAlgorithm = SignatureAlgorithm.findById(sigAlgorithmId); + if (signatureAlgorithm == null) { + result.addWarning(Issue.V3_SIG_UNKNOWN_SIG_ALGORITHM, sigAlgorithmId); + continue; + } + // TODO consider dropping deprecated signatures for v3 or modifying + // getSignaturesToVerify (called below) + supportedSignatures.add( + new ApkSigningBlockUtils.SupportedSignature(signatureAlgorithm, sigBytes)); + } catch (ApkFormatException | BufferUnderflowException e) { + result.addError(Issue.V3_SIG_MALFORMED_SIGNATURE, signatureCount); + return; + } + } + if (result.signatures.isEmpty()) { + result.addError(Issue.V3_SIG_NO_SIGNATURES); + return; + } + + // Verify signatures over signed-data block using the public key + List<ApkSigningBlockUtils.SupportedSignature> signaturesToVerify = null; + try { + signaturesToVerify = + ApkSigningBlockUtils.getSignaturesToVerify( + supportedSignatures, result.minSdkVersion, result.maxSdkVersion); + } catch (ApkSigningBlockUtils.NoSupportedSignaturesException e) { + result.addError(Issue.V3_SIG_NO_SUPPORTED_SIGNATURES); + return; + } + for (ApkSigningBlockUtils.SupportedSignature signature : signaturesToVerify) { + SignatureAlgorithm signatureAlgorithm = signature.algorithm; + String jcaSignatureAlgorithm = + signatureAlgorithm.getJcaSignatureAlgorithmAndParams().getFirst(); + AlgorithmParameterSpec jcaSignatureAlgorithmParams = + signatureAlgorithm.getJcaSignatureAlgorithmAndParams().getSecond(); + String keyAlgorithm = signatureAlgorithm.getJcaKeyAlgorithm(); + PublicKey publicKey; + try { + publicKey = + KeyFactory.getInstance(keyAlgorithm).generatePublic( + new X509EncodedKeySpec(publicKeyBytes)); + } catch (Exception e) { + result.addError(Issue.V3_SIG_MALFORMED_PUBLIC_KEY, e); + return; + } + try { + Signature sig = Signature.getInstance(jcaSignatureAlgorithm); + sig.initVerify(publicKey); + if (jcaSignatureAlgorithmParams != null) { + sig.setParameter(jcaSignatureAlgorithmParams); + } + signedData.position(0); + sig.update(signedData); + byte[] sigBytes = signature.signature; + if (!sig.verify(sigBytes)) { + result.addError(Issue.V3_SIG_DID_NOT_VERIFY, signatureAlgorithm); + return; + } + result.verifiedSignatures.put(signatureAlgorithm, sigBytes); + mContentDigestsToVerify.add(signatureAlgorithm.getContentDigestAlgorithm()); + } catch (InvalidKeyException | InvalidAlgorithmParameterException + | SignatureException e) { + result.addError(Issue.V3_SIG_VERIFY_EXCEPTION, signatureAlgorithm, e); + return; + } + } + + // At least one signature over signedData has verified. We can now parse signed-data. + signedData.position(0); + ByteBuffer digests = getLengthPrefixedSlice(signedData); + ByteBuffer certificates = getLengthPrefixedSlice(signedData); + + int signedMinSdkVersion = signedData.getInt(); + if (signedMinSdkVersion != parsedMinSdkVersion) { + result.addError( + Issue.V3_MIN_SDK_VERSION_MISMATCH_BETWEEN_SIGNER_AND_SIGNED_DATA_RECORD, + parsedMinSdkVersion, + signedMinSdkVersion); + } + int signedMaxSdkVersion = signedData.getInt(); + if (signedMaxSdkVersion != parsedMaxSdkVersion) { + result.addError( + Issue.V3_MAX_SDK_VERSION_MISMATCH_BETWEEN_SIGNER_AND_SIGNED_DATA_RECORD, + parsedMaxSdkVersion, + signedMaxSdkVersion); + } + ByteBuffer additionalAttributes = getLengthPrefixedSlice(signedData); + + // Parse the certificates block + int certificateIndex = -1; + while (certificates.hasRemaining()) { + certificateIndex++; + byte[] encodedCert = readLengthPrefixedByteArray(certificates); + X509Certificate certificate; + try { + certificate = X509CertificateUtils.generateCertificate(encodedCert, certFactory); + } catch (CertificateException e) { + result.addError( + Issue.V3_SIG_MALFORMED_CERTIFICATE, + certificateIndex, + certificateIndex + 1, + e); + return; + } + // Wrap the cert so that the result's getEncoded returns exactly the original encoded + // form. Without this, getEncoded may return a different form from what was stored in + // the signature. This is because some X509Certificate(Factory) implementations + // re-encode certificates. + certificate = new GuaranteedEncodedFormX509Certificate(certificate, encodedCert); + result.certs.add(certificate); + } + + if (result.certs.isEmpty()) { + result.addError(Issue.V3_SIG_NO_CERTIFICATES); + return; + } + X509Certificate mainCertificate = result.certs.get(0); + byte[] certificatePublicKeyBytes; + try { + certificatePublicKeyBytes = ApkSigningBlockUtils.encodePublicKey( + mainCertificate.getPublicKey()); + } catch (InvalidKeyException e) { + System.out.println("Caught an exception encoding the public key: " + e); + e.printStackTrace(); + certificatePublicKeyBytes = mainCertificate.getPublicKey().getEncoded(); + } + if (!Arrays.equals(publicKeyBytes, certificatePublicKeyBytes)) { + result.addError( + Issue.V3_SIG_PUBLIC_KEY_MISMATCH_BETWEEN_CERTIFICATE_AND_SIGNATURES_RECORD, + ApkSigningBlockUtils.toHex(certificatePublicKeyBytes), + ApkSigningBlockUtils.toHex(publicKeyBytes)); + return; + } + + // Parse the digests block + int digestCount = 0; + while (digests.hasRemaining()) { + digestCount++; + try { + ByteBuffer digest = getLengthPrefixedSlice(digests); + int sigAlgorithmId = digest.getInt(); + byte[] digestBytes = readLengthPrefixedByteArray(digest); + result.contentDigests.add( + new ApkSigningBlockUtils.Result.SignerInfo.ContentDigest( + sigAlgorithmId, digestBytes)); + } catch (ApkFormatException | BufferUnderflowException e) { + result.addError(Issue.V3_SIG_MALFORMED_DIGEST, digestCount); + return; + } + } + + List<Integer> sigAlgsFromSignaturesRecord = new ArrayList<>(result.signatures.size()); + for (ApkSigningBlockUtils.Result.SignerInfo.Signature signature : result.signatures) { + sigAlgsFromSignaturesRecord.add(signature.getAlgorithmId()); + } + List<Integer> sigAlgsFromDigestsRecord = new ArrayList<>(result.contentDigests.size()); + for (ApkSigningBlockUtils.Result.SignerInfo.ContentDigest digest : result.contentDigests) { + sigAlgsFromDigestsRecord.add(digest.getSignatureAlgorithmId()); + } + + if (!sigAlgsFromSignaturesRecord.equals(sigAlgsFromDigestsRecord)) { + result.addError( + Issue.V3_SIG_SIG_ALG_MISMATCH_BETWEEN_SIGNATURES_AND_DIGESTS_RECORDS, + sigAlgsFromSignaturesRecord, + sigAlgsFromDigestsRecord); + return; + } + + // Parse the additional attributes block. + int additionalAttributeCount = 0; + boolean rotationAttrFound = false; + while (additionalAttributes.hasRemaining()) { + additionalAttributeCount++; + try { + ByteBuffer attribute = + getLengthPrefixedSlice(additionalAttributes); + int id = attribute.getInt(); + byte[] value = ByteBufferUtils.toByteArray(attribute); + result.additionalAttributes.add( + new ApkSigningBlockUtils.Result.SignerInfo.AdditionalAttribute(id, value)); + if (id == V3SchemeConstants.PROOF_OF_ROTATION_ATTR_ID) { + try { + // SigningCertificateLineage is verified when built + result.signingCertificateLineage = + SigningCertificateLineage.readFromV3AttributeValue(value); + // make sure that the last cert in the chain matches this signer cert + SigningCertificateLineage subLineage = + result.signingCertificateLineage.getSubLineage(result.certs.get(0)); + if (result.signingCertificateLineage.size() != subLineage.size()) { + result.addError(Issue.V3_SIG_POR_CERT_MISMATCH); + } + } catch (SecurityException e) { + result.addError(Issue.V3_SIG_POR_DID_NOT_VERIFY); + } catch (IllegalArgumentException e) { + result.addError(Issue.V3_SIG_POR_CERT_MISMATCH); + } catch (Exception e) { + result.addError(Issue.V3_SIG_MALFORMED_LINEAGE); + } + } else if (id == V3SchemeConstants.ROTATION_MIN_SDK_VERSION_ATTR_ID) { + rotationAttrFound = true; + // API targeting for rotation was added with V3.1; if the maxSdkVersion + // does not support v3.1 then ignore this attribute. + if (mMaxSdkVersion >= V3SchemeConstants.MIN_SDK_WITH_V31_SUPPORT + && mFullVerification) { + int attrRotationMinSdkVersion = ByteBuffer.wrap(value) + .order(ByteOrder.LITTLE_ENDIAN).getInt(); + if (mOptionalRotationMinSdkVersion.isPresent()) { + int rotationMinSdkVersion = mOptionalRotationMinSdkVersion.getAsInt(); + if (attrRotationMinSdkVersion != rotationMinSdkVersion) { + result.addError(Issue.V31_ROTATION_MIN_SDK_MISMATCH, + attrRotationMinSdkVersion, rotationMinSdkVersion); + } + } else { + result.addError(Issue.V31_BLOCK_MISSING, attrRotationMinSdkVersion); + } + } + } else if (id == V3SchemeConstants.ROTATION_ON_DEV_RELEASE_ATTR_ID) { + // This attribute should only be used by a v3.1 signer to indicate rotation + // is targeting the development release that is using the SDK version of the + // previously released platform version. + if (mBlockId != V3SchemeConstants.APK_SIGNATURE_SCHEME_V31_BLOCK_ID) { + result.addWarning(Issue.V31_ROTATION_TARGETS_DEV_RELEASE_ATTR_ON_V3_SIGNER); + } + } else { + result.addWarning(Issue.V3_SIG_UNKNOWN_ADDITIONAL_ATTRIBUTE, id); + } + } catch (ApkFormatException | BufferUnderflowException e) { + result.addError( + Issue.V3_SIG_MALFORMED_ADDITIONAL_ATTRIBUTE, additionalAttributeCount); + return; + } + } + if (mFullVerification && mOptionalRotationMinSdkVersion.isPresent() && !rotationAttrFound) { + result.addWarning(Issue.V31_ROTATION_MIN_SDK_ATTR_MISSING, + mOptionalRotationMinSdkVersion.getAsInt()); + } + } + + /** + * Returns whether the specified {@code signerInfo} is targeting a development release. + */ + public static boolean signerTargetsDevRelease( + ApkSigningBlockUtils.Result.SignerInfo signerInfo) { + boolean result = signerInfo.additionalAttributes.stream() + .mapToInt(attribute -> attribute.getId()) + .anyMatch(attrId -> attrId == V3SchemeConstants.ROTATION_ON_DEV_RELEASE_ATTR_ID); + return result; + } + + /** Builder of {@link V3SchemeVerifier} instances. */ + public static class Builder { + private RunnablesExecutor mExecutor = RunnablesExecutor.SINGLE_THREADED; + private DataSource mApk; + private ApkUtils.ZipSections mZipSections; + private ByteBuffer mApkSignatureSchemeV3Block; + private Set<ContentDigestAlgorithm> mContentDigestsToVerify; + private ApkSigningBlockUtils.Result mResult; + private int mMinSdkVersion; + private int mMaxSdkVersion; + private int mBlockId = V3SchemeConstants.APK_SIGNATURE_SCHEME_V3_BLOCK_ID; + private boolean mFullVerification = true; + private OptionalInt mOptionalRotationMinSdkVersion = OptionalInt.empty(); + + /** + * Instantiates a new {@code Builder} for a {@code V3SchemeVerifier} that can be used to + * verify the V3 signing block of the provided {@code apk} with the specified {@code + * zipSections} over the range from {@code minSdkVersion} to {@code maxSdkVersion}. + */ + public Builder(DataSource apk, ApkUtils.ZipSections zipSections, int minSdkVersion, + int maxSdkVersion) { + mApk = apk; + mZipSections = zipSections; + mMinSdkVersion = minSdkVersion; + mMaxSdkVersion = maxSdkVersion; + } + + /** + * Instantiates a new {@code Builder} for a {@code V3SchemeVerifier} that can be used to + * parse the {@link ApkSigningBlockUtils.Result.SignerInfo} instances from the {@code + * apkSignatureSchemeV3Block}. + * + * <note>Full verification of the v3 signature is not possible when instantiating a new + * {@code V3SchemeVerifier} with this method.</note> + */ + public Builder(ByteBuffer apkSignatureSchemeV3Block) { + mApkSignatureSchemeV3Block = apkSignatureSchemeV3Block; + } + + /** + * Sets the {@link RunnablesExecutor} to be used when verifying the APK's content digests. + */ + public Builder setRunnablesExecutor(RunnablesExecutor executor) { + mExecutor = executor; + return this; + } + + /** + * Sets the V3 {code blockId} to be verified in the provided APK. + * + * <p>This {@code V3SchemeVerifier} currently supports the block IDs for the {@link + * V3SchemeConstants#APK_SIGNATURE_SCHEME_V3_BLOCK_ID v3.0} and {@link + * V3SchemeConstants#APK_SIGNATURE_SCHEME_V31_BLOCK_ID v3.1} signature schemes. + */ + public Builder setBlockId(int blockId) { + mBlockId = blockId; + return this; + } + + /** + * Sets the {@code rotationMinSdkVersion} to be verified in the v3.0 signer's additional + * attribute. + * + * <p>This value can be obtained from the signers returned when verifying the v3.1 signing + * block of an APK; in the case of multiple signers targeting different SDK versions in the + * v3.1 signing block, the minimum SDK version from all the signers should be used. + */ + public Builder setRotationMinSdkVersion(int rotationMinSdkVersion) { + mOptionalRotationMinSdkVersion = OptionalInt.of(rotationMinSdkVersion); + return this; + } + + /** + * Sets the {@code result} instance to be used when returning verification results. + * + * <p>This method can be used when the caller already has a {@link + * ApkSigningBlockUtils.Result} and wants to store the verification results in this + * instance. + */ + public Builder setResult(ApkSigningBlockUtils.Result result) { + mResult = result; + return this; + } + + /** + * Sets the instance to be used to store the {@code contentDigestsToVerify}. + * + * <p>This method can be used when the caller needs access to the {@code + * contentDigestsToVerify} computed by this {@code V3SchemeVerifier}. + */ + public Builder setContentDigestsToVerify( + Set<ContentDigestAlgorithm> contentDigestsToVerify) { + mContentDigestsToVerify = contentDigestsToVerify; + return this; + } + + /** + * Sets whether full verification should be performed by the {@code V3SchemeVerifier} built + * from this instance. + * + * <note>{@link #verify()} will always verify the content digests for the APK, but this + * allows verification of the rotation minimum SDK version stripping attribute to be skipped + * for scenarios where this value may not have been parsed from a V3.1 signing block (such + * as when only {@link #parseSigners()} will be invoked.</note> + */ + public Builder setFullVerification(boolean fullVerification) { + mFullVerification = fullVerification; + return this; + } + + /** + * Returns a new {@link V3SchemeVerifier} built with the configuration provided to this + * {@code Builder}. + */ + public V3SchemeVerifier build() { + int sigSchemeVersion; + switch (mBlockId) { + case V3SchemeConstants.APK_SIGNATURE_SCHEME_V3_BLOCK_ID: + sigSchemeVersion = ApkSigningBlockUtils.VERSION_APK_SIGNATURE_SCHEME_V3; + mMinSdkVersion = Math.max(mMinSdkVersion, + V3SchemeConstants.MIN_SDK_WITH_V3_SUPPORT); + break; + case V3SchemeConstants.APK_SIGNATURE_SCHEME_V31_BLOCK_ID: + sigSchemeVersion = ApkSigningBlockUtils.VERSION_APK_SIGNATURE_SCHEME_V31; + // V3.1 supports targeting an SDK version later than that of the initial release + // in which it is supported; allow any range for V3.1 as long as V3.0 covers the + // rest of the range. + mMinSdkVersion = mMaxSdkVersion; + break; + default: + throw new IllegalArgumentException( + String.format("Unsupported APK Signature Scheme V3 block ID: 0x%08x", + mBlockId)); + } + if (mResult == null) { + mResult = new ApkSigningBlockUtils.Result(sigSchemeVersion); + } + if (mContentDigestsToVerify == null) { + mContentDigestsToVerify = new HashSet<>(1); + } + + V3SchemeVerifier verifier = new V3SchemeVerifier( + mExecutor, + mApk, + mZipSections, + mContentDigestsToVerify, + mResult, + mMinSdkVersion, + mMaxSdkVersion, + mBlockId, + mOptionalRotationMinSdkVersion, + mFullVerification); + if (mApkSignatureSchemeV3Block != null) { + verifier.mApkSignatureSchemeV3Block = mApkSignatureSchemeV3Block; + } + return verifier; + } + } +} diff --git a/platform/android/java/editor/src/main/java/com/android/apksig/internal/apk/v3/V3SigningCertificateLineage.java b/platform/android/java/editor/src/main/java/com/android/apksig/internal/apk/v3/V3SigningCertificateLineage.java new file mode 100644 index 0000000000..4ae7a5365d --- /dev/null +++ b/platform/android/java/editor/src/main/java/com/android/apksig/internal/apk/v3/V3SigningCertificateLineage.java @@ -0,0 +1,314 @@ +/* + * Copyright (C) 2018 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.apksig.internal.apk.v3; + +import static com.android.apksig.internal.apk.ApkSigningBlockUtils.encodeAsLengthPrefixedElement; +import static com.android.apksig.internal.apk.ApkSigningBlockUtils.encodeAsSequenceOfLengthPrefixedElements; +import static com.android.apksig.internal.apk.ApkSigningBlockUtils.getLengthPrefixedSlice; +import static com.android.apksig.internal.apk.ApkSigningBlockUtils.readLengthPrefixedByteArray; + +import com.android.apksig.apk.ApkFormatException; +import com.android.apksig.internal.apk.ApkSigningBlockUtils; +import com.android.apksig.internal.apk.SignatureAlgorithm; +import com.android.apksig.internal.util.GuaranteedEncodedFormX509Certificate; +import com.android.apksig.internal.util.X509CertificateUtils; + +import java.io.IOException; +import java.nio.BufferUnderflowException; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.security.InvalidAlgorithmParameterException; +import java.security.InvalidKeyException; +import java.security.NoSuchAlgorithmException; +import java.security.PublicKey; +import java.security.Signature; +import java.security.SignatureException; +import java.security.cert.CertificateEncodingException; +import java.security.cert.CertificateException; +import java.security.cert.X509Certificate; +import java.security.spec.AlgorithmParameterSpec; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashSet; +import java.util.List; +import java.util.Objects; + +/** + * APK Signer Lineage. + * + * <p>The signer lineage contains a history of signing certificates with each ancestor attesting to + * the validity of its descendant. Each additional descendant represents a new identity that can be + * used to sign an APK, and each generation has accompanying attributes which represent how the + * APK would like to view the older signing certificates, specifically how they should be trusted in + * certain situations. + * + * <p> Its primary use is to enable APK Signing Certificate Rotation. The Android platform verifies + * the APK Signer Lineage, and if the current signing certificate for the APK is in the Signer + * Lineage, and the Lineage contains the certificate the platform associates with the APK, it will + * allow upgrades to the new certificate. + * + * @see <a href="https://source.android.com/security/apksigning/index.html">Application Signing</a> + */ +public class V3SigningCertificateLineage { + + private final static int FIRST_VERSION = 1; + private final static int CURRENT_VERSION = FIRST_VERSION; + + /** + * Deserializes the binary representation of an {@link V3SigningCertificateLineage}. Also + * verifies that the structure is well-formed, e.g. that the signature for each node is from its + * parent. + */ + public static List<SigningCertificateNode> readSigningCertificateLineage(ByteBuffer inputBytes) + throws IOException { + List<SigningCertificateNode> result = new ArrayList<>(); + int nodeCount = 0; + if (inputBytes == null || !inputBytes.hasRemaining()) { + return null; + } + + ApkSigningBlockUtils.checkByteOrderLittleEndian(inputBytes); + + // FORMAT (little endian): + // * uint32: version code + // * sequence of length-prefixed (uint32): nodes + // * length-prefixed bytes: signed data + // * length-prefixed bytes: certificate + // * uint32: signature algorithm id + // * uint32: flags + // * uint32: signature algorithm id (used by to sign next cert in lineage) + // * length-prefixed bytes: signature over above signed data + + X509Certificate lastCert = null; + int lastSigAlgorithmId = 0; + + try { + int version = inputBytes.getInt(); + if (version != CURRENT_VERSION) { + // we only have one version to worry about right now, so just check it + throw new IllegalArgumentException("Encoded SigningCertificateLineage has a version" + + " different than any of which we are aware"); + } + HashSet<X509Certificate> certHistorySet = new HashSet<>(); + while (inputBytes.hasRemaining()) { + nodeCount++; + ByteBuffer nodeBytes = getLengthPrefixedSlice(inputBytes); + ByteBuffer signedData = getLengthPrefixedSlice(nodeBytes); + int flags = nodeBytes.getInt(); + int sigAlgorithmId = nodeBytes.getInt(); + SignatureAlgorithm sigAlgorithm = SignatureAlgorithm.findById(lastSigAlgorithmId); + byte[] signature = readLengthPrefixedByteArray(nodeBytes); + + if (lastCert != null) { + // Use previous level cert to verify current level + String jcaSignatureAlgorithm = + sigAlgorithm.getJcaSignatureAlgorithmAndParams().getFirst(); + AlgorithmParameterSpec jcaSignatureAlgorithmParams = + sigAlgorithm.getJcaSignatureAlgorithmAndParams().getSecond(); + PublicKey publicKey = lastCert.getPublicKey(); + Signature sig = Signature.getInstance(jcaSignatureAlgorithm); + sig.initVerify(publicKey); + if (jcaSignatureAlgorithmParams != null) { + sig.setParameter(jcaSignatureAlgorithmParams); + } + sig.update(signedData); + if (!sig.verify(signature)) { + throw new SecurityException("Unable to verify signature of certificate #" + + nodeCount + " using " + jcaSignatureAlgorithm + " when verifying" + + " V3SigningCertificateLineage object"); + } + } + + signedData.rewind(); + byte[] encodedCert = readLengthPrefixedByteArray(signedData); + int signedSigAlgorithm = signedData.getInt(); + if (lastCert != null && lastSigAlgorithmId != signedSigAlgorithm) { + throw new SecurityException("Signing algorithm ID mismatch for certificate #" + + nodeBytes + " when verifying V3SigningCertificateLineage object"); + } + lastCert = X509CertificateUtils.generateCertificate(encodedCert); + lastCert = new GuaranteedEncodedFormX509Certificate(lastCert, encodedCert); + if (certHistorySet.contains(lastCert)) { + throw new SecurityException("Encountered duplicate entries in " + + "SigningCertificateLineage at certificate #" + nodeCount + ". All " + + "signing certificates should be unique"); + } + certHistorySet.add(lastCert); + lastSigAlgorithmId = sigAlgorithmId; + result.add(new SigningCertificateNode( + lastCert, SignatureAlgorithm.findById(signedSigAlgorithm), + SignatureAlgorithm.findById(sigAlgorithmId), signature, flags)); + } + } catch(ApkFormatException | BufferUnderflowException e){ + throw new IOException("Failed to parse V3SigningCertificateLineage object", e); + } catch(NoSuchAlgorithmException | InvalidKeyException + | InvalidAlgorithmParameterException | SignatureException e){ + throw new SecurityException( + "Failed to verify signature over signed data for certificate #" + nodeCount + + " when parsing V3SigningCertificateLineage object", e); + } catch(CertificateException e){ + throw new SecurityException("Failed to decode certificate #" + nodeCount + + " when parsing V3SigningCertificateLineage object", e); + } + return result; + } + + /** + * encode the in-memory representation of this {@code V3SigningCertificateLineage} + */ + public static byte[] encodeSigningCertificateLineage( + List<SigningCertificateNode> signingCertificateLineage) { + // FORMAT (little endian): + // * version code + // * sequence of length-prefixed (uint32): nodes + // * length-prefixed bytes: signed data + // * length-prefixed bytes: certificate + // * uint32: signature algorithm id + // * uint32: flags + // * uint32: signature algorithm id (used by to sign next cert in lineage) + + List<byte[]> nodes = new ArrayList<>(); + for (SigningCertificateNode node : signingCertificateLineage) { + nodes.add(encodeSigningCertificateNode(node)); + } + byte [] encodedSigningCertificateLineage = encodeAsSequenceOfLengthPrefixedElements(nodes); + + // add the version code (uint32) on top of the encoded nodes + int payloadSize = 4 + encodedSigningCertificateLineage.length; + ByteBuffer encodedWithVersion = ByteBuffer.allocate(payloadSize); + encodedWithVersion.order(ByteOrder.LITTLE_ENDIAN); + encodedWithVersion.putInt(CURRENT_VERSION); + encodedWithVersion.put(encodedSigningCertificateLineage); + return encodedWithVersion.array(); + } + + public static byte[] encodeSigningCertificateNode(SigningCertificateNode node) { + // FORMAT (little endian): + // * length-prefixed bytes: signed data + // * length-prefixed bytes: certificate + // * uint32: signature algorithm id + // * uint32: flags + // * uint32: signature algorithm id (used by to sign next cert in lineage) + // * length-prefixed bytes: signature over signed data + int parentSigAlgorithmId = 0; + if (node.parentSigAlgorithm != null) { + parentSigAlgorithmId = node.parentSigAlgorithm.getId(); + } + int sigAlgorithmId = 0; + if (node.sigAlgorithm != null) { + sigAlgorithmId = node.sigAlgorithm.getId(); + } + byte[] prefixedSignedData = encodeSignedData(node.signingCert, parentSigAlgorithmId); + byte[] prefixedSignature = encodeAsLengthPrefixedElement(node.signature); + int payloadSize = prefixedSignedData.length + 4 + 4 + prefixedSignature.length; + ByteBuffer result = ByteBuffer.allocate(payloadSize); + result.order(ByteOrder.LITTLE_ENDIAN); + result.put(prefixedSignedData); + result.putInt(node.flags); + result.putInt(sigAlgorithmId); + result.put(prefixedSignature); + return result.array(); + } + + public static byte[] encodeSignedData(X509Certificate certificate, int flags) { + try { + byte[] prefixedCertificate = encodeAsLengthPrefixedElement(certificate.getEncoded()); + int payloadSize = 4 + prefixedCertificate.length; + ByteBuffer result = ByteBuffer.allocate(payloadSize); + result.order(ByteOrder.LITTLE_ENDIAN); + result.put(prefixedCertificate); + result.putInt(flags); + return encodeAsLengthPrefixedElement(result.array()); + } catch (CertificateEncodingException e) { + throw new RuntimeException( + "Failed to encode V3SigningCertificateLineage certificate", e); + } + } + + /** + * Represents one signing certificate in the {@link V3SigningCertificateLineage}, which + * generally means it is/was used at some point to sign the same APK of the others in the + * lineage. + */ + public static class SigningCertificateNode { + + public SigningCertificateNode( + X509Certificate signingCert, + SignatureAlgorithm parentSigAlgorithm, + SignatureAlgorithm sigAlgorithm, + byte[] signature, + int flags) { + this.signingCert = signingCert; + this.parentSigAlgorithm = parentSigAlgorithm; + this.sigAlgorithm = sigAlgorithm; + this.signature = signature; + this.flags = flags; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof SigningCertificateNode)) return false; + + SigningCertificateNode that = (SigningCertificateNode) o; + if (!signingCert.equals(that.signingCert)) return false; + if (parentSigAlgorithm != that.parentSigAlgorithm) return false; + if (sigAlgorithm != that.sigAlgorithm) return false; + if (!Arrays.equals(signature, that.signature)) return false; + if (flags != that.flags) return false; + + // we made it + return true; + } + + @Override + public int hashCode() { + int result = Objects.hash(signingCert, parentSigAlgorithm, sigAlgorithm, flags); + result = 31 * result + Arrays.hashCode(signature); + return result; + } + + /** + * the signing cert for this node. This is part of the data signed by the parent node. + */ + public final X509Certificate signingCert; + + /** + * the algorithm used by the this node's parent to bless this data. Its ID value is part of + * the data signed by the parent node. {@code null} for first node. + */ + public final SignatureAlgorithm parentSigAlgorithm; + + /** + * the algorithm used by the this nodeto bless the next node's data. Its ID value is part + * of the signed data of the next node. {@code null} for the last node. + */ + public SignatureAlgorithm sigAlgorithm; + + /** + * signature over the signed data (above). The signature is from this node's parent + * signing certificate, which should correspond to the signing certificate used to sign an + * APK before rotating to this one, and is formed using {@code signatureAlgorithm}. + */ + public final byte[] signature; + + /** + * the flags detailing how the platform should treat this signing cert + */ + public int flags; + } +} diff --git a/platform/android/java/editor/src/main/java/com/android/apksig/internal/apk/v4/V4SchemeSigner.java b/platform/android/java/editor/src/main/java/com/android/apksig/internal/apk/v4/V4SchemeSigner.java new file mode 100644 index 0000000000..7bf952d82a --- /dev/null +++ b/platform/android/java/editor/src/main/java/com/android/apksig/internal/apk/v4/V4SchemeSigner.java @@ -0,0 +1,440 @@ +/* + * Copyright (C) 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.apksig.internal.apk.v4; + +import static com.android.apksig.internal.apk.ApkSigningBlockUtils.VERSION_APK_SIGNATURE_SCHEME_V2; +import static com.android.apksig.internal.apk.ApkSigningBlockUtils.VERSION_APK_SIGNATURE_SCHEME_V3; +import static com.android.apksig.internal.apk.ApkSigningBlockUtils.VERSION_APK_SIGNATURE_SCHEME_V31; +import static com.android.apksig.internal.apk.ApkSigningBlockUtils.encodeCertificates; +import static com.android.apksig.internal.apk.v2.V2SchemeConstants.APK_SIGNATURE_SCHEME_V2_BLOCK_ID; +import static com.android.apksig.internal.apk.v3.V3SchemeConstants.APK_SIGNATURE_SCHEME_V31_BLOCK_ID; +import static com.android.apksig.internal.apk.v3.V3SchemeConstants.APK_SIGNATURE_SCHEME_V3_BLOCK_ID; + +import com.android.apksig.apk.ApkUtils; +import com.android.apksig.internal.apk.ApkSigningBlockUtils; +import com.android.apksig.internal.apk.ContentDigestAlgorithm; +import com.android.apksig.internal.apk.SignatureAlgorithm; +import com.android.apksig.internal.apk.SignatureInfo; +import com.android.apksig.internal.apk.v2.V2SchemeVerifier; +import com.android.apksig.internal.apk.v3.V3SchemeSigner; +import com.android.apksig.internal.apk.v3.V3SchemeVerifier; +import com.android.apksig.internal.util.Pair; +import com.android.apksig.util.DataSource; +import com.android.apksig.zip.ZipFormatException; + +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.OutputStream; +import java.nio.ByteBuffer; +import java.security.InvalidKeyException; +import java.security.NoSuchAlgorithmException; +import java.security.PublicKey; +import java.security.SignatureException; +import java.security.cert.CertificateEncodingException; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Iterator; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * APK Signature Scheme V4 signer. V4 scheme file contains 2 mandatory fields - used during + * installation. And optional verity tree - has to be present during session commit. + * <p> + * The fields: + * <p> + * 1. hashingInfo - verity root hash and hashing info, + * 2. signingInfo - certificate, public key and signature, + * For more details see V4Signature. + * </p> + * (optional) verityTree: integer size prepended bytes of the verity hash tree. + * <p> + */ +public abstract class V4SchemeSigner { + /** + * Hidden constructor to prevent instantiation. + */ + private V4SchemeSigner() { + } + + public static class SignerConfig { + final public ApkSigningBlockUtils.SignerConfig v4Config; + final public ApkSigningBlockUtils.SignerConfig v41Config; + + public SignerConfig(List<ApkSigningBlockUtils.SignerConfig> v4Configs, + List<ApkSigningBlockUtils.SignerConfig> v41Configs) throws InvalidKeyException { + if (v4Configs == null || v4Configs.size() != 1) { + throw new InvalidKeyException("Only accepting one signer config for V4 Signature."); + } + if (v41Configs != null && v41Configs.size() != 1) { + throw new InvalidKeyException("Only accepting one signer config for V4.1 Signature."); + } + this.v4Config = v4Configs.get(0); + this.v41Config = v41Configs != null ? v41Configs.get(0) : null; + } + } + + /** + * Based on a public key, return a signing algorithm that supports verity. + */ + public static List<SignatureAlgorithm> getSuggestedSignatureAlgorithms(PublicKey signingKey, + int minSdkVersion, boolean apkSigningBlockPaddingSupported, + boolean deterministicDsaSigning) + throws InvalidKeyException { + List<SignatureAlgorithm> algorithms = V3SchemeSigner.getSuggestedSignatureAlgorithms( + signingKey, minSdkVersion, + apkSigningBlockPaddingSupported, deterministicDsaSigning); + // Keeping only supported algorithms. + for (Iterator<SignatureAlgorithm> iter = algorithms.listIterator(); iter.hasNext(); ) { + final SignatureAlgorithm algorithm = iter.next(); + if (!isSupported(algorithm.getContentDigestAlgorithm(), false)) { + iter.remove(); + } + } + return algorithms; + } + + /** + * Compute hash tree and generate v4 signature for a given APK. Write the serialized data to + * output file. + */ + public static void generateV4Signature( + DataSource apkContent, SignerConfig signerConfig, File outputFile) + throws IOException, InvalidKeyException, NoSuchAlgorithmException { + Pair<V4Signature, byte[]> pair = generateV4Signature(apkContent, signerConfig); + try (final OutputStream output = new FileOutputStream(outputFile)) { + pair.getFirst().writeTo(output); + V4Signature.writeBytes(output, pair.getSecond()); + } catch (IOException e) { + outputFile.delete(); + throw e; + } + } + + /** Generate v4 signature and hash tree for a given APK. */ + public static Pair<V4Signature, byte[]> generateV4Signature( + DataSource apkContent, + SignerConfig signerConfig) + throws IOException, InvalidKeyException, NoSuchAlgorithmException { + // Salt has to stay empty for fs-verity compatibility. + final byte[] salt = null; + // Not used by apksigner. + final byte[] additionalData = null; + + final long fileSize = apkContent.size(); + + // Obtaining the strongest supported digest for each of the v2/v3/v3.1 blocks + // (CHUNKED_SHA256 or CHUNKED_SHA512). + final Map<Integer, byte[]> apkDigests = getApkDigests(apkContent); + + // Obtaining the merkle tree and the root hash in verity format. + ApkSigningBlockUtils.VerityTreeAndDigest verityContentDigestInfo = + ApkSigningBlockUtils.computeChunkVerityTreeAndDigest(apkContent); + + final ContentDigestAlgorithm verityContentDigestAlgorithm = + verityContentDigestInfo.contentDigestAlgorithm; + final byte[] rootHash = verityContentDigestInfo.rootHash; + final byte[] tree = verityContentDigestInfo.tree; + + final Pair<Integer, Byte> hashingAlgorithmBlockSizePair = convertToV4HashingInfo( + verityContentDigestAlgorithm); + final V4Signature.HashingInfo hashingInfo = new V4Signature.HashingInfo( + hashingAlgorithmBlockSizePair.getFirst(), hashingAlgorithmBlockSizePair.getSecond(), + salt, rootHash); + + // Generating SigningInfo and combining everything into V4Signature. + final V4Signature signature; + try { + signature = generateSignature(signerConfig, hashingInfo, apkDigests, additionalData, + fileSize); + } catch (InvalidKeyException | SignatureException | CertificateEncodingException e) { + throw new InvalidKeyException("Signer failed", e); + } + + return Pair.of(signature, tree); + } + + private static V4Signature.SigningInfo generateSigningInfo( + ApkSigningBlockUtils.SignerConfig signerConfig, + V4Signature.HashingInfo hashingInfo, + byte[] apkDigest, byte[] additionalData, long fileSize) + throws NoSuchAlgorithmException, InvalidKeyException, SignatureException, + CertificateEncodingException { + if (signerConfig.certificates.isEmpty()) { + throw new SignatureException("No certificates configured for signer"); + } + if (signerConfig.certificates.size() != 1) { + throw new CertificateEncodingException("Should only have one certificate"); + } + + // Collecting data for signing. + final PublicKey publicKey = signerConfig.certificates.get(0).getPublicKey(); + + final List<byte[]> encodedCertificates = encodeCertificates(signerConfig.certificates); + final byte[] encodedCertificate = encodedCertificates.get(0); + + final V4Signature.SigningInfo signingInfoNoSignature = new V4Signature.SigningInfo(apkDigest, + encodedCertificate, additionalData, publicKey.getEncoded(), -1, null); + + final byte[] data = V4Signature.getSignedData(fileSize, hashingInfo, + signingInfoNoSignature); + + // Signing. + final List<Pair<Integer, byte[]>> signatures = + ApkSigningBlockUtils.generateSignaturesOverData(signerConfig, data); + if (signatures.size() != 1) { + throw new SignatureException("Should only be one signature generated"); + } + + final int signatureAlgorithmId = signatures.get(0).getFirst(); + final byte[] signature = signatures.get(0).getSecond(); + + return new V4Signature.SigningInfo(apkDigest, + encodedCertificate, additionalData, publicKey.getEncoded(), signatureAlgorithmId, + signature); + } + + private static V4Signature generateSignature( + SignerConfig signerConfig, + V4Signature.HashingInfo hashingInfo, + Map<Integer, byte[]> apkDigests, byte[] additionalData, long fileSize) + throws NoSuchAlgorithmException, InvalidKeyException, SignatureException, + CertificateEncodingException { + byte[] apkDigest = apkDigests.containsKey(VERSION_APK_SIGNATURE_SCHEME_V3) + ? apkDigests.get(VERSION_APK_SIGNATURE_SCHEME_V3) + : apkDigests.get(VERSION_APK_SIGNATURE_SCHEME_V2); + final V4Signature.SigningInfo signingInfo = generateSigningInfo(signerConfig.v4Config, + hashingInfo, apkDigest, additionalData, fileSize); + + final V4Signature.SigningInfos signingInfos; + if (signerConfig.v41Config != null) { + if (!apkDigests.containsKey(VERSION_APK_SIGNATURE_SCHEME_V31)) { + throw new IllegalStateException( + "V4.1 cannot be signed without a V3.1 content digest"); + } + apkDigest = apkDigests.get(VERSION_APK_SIGNATURE_SCHEME_V31); + final V4Signature.SigningInfoBlock extSigningBlock = new V4Signature.SigningInfoBlock( + APK_SIGNATURE_SCHEME_V31_BLOCK_ID, + generateSigningInfo(signerConfig.v41Config, hashingInfo, apkDigest, + additionalData, fileSize).toByteArray()); + signingInfos = new V4Signature.SigningInfos(signingInfo, extSigningBlock); + } else { + signingInfos = new V4Signature.SigningInfos(signingInfo); + } + + return new V4Signature(V4Signature.CURRENT_VERSION, hashingInfo.toByteArray(), + signingInfos.toByteArray()); + } + + /** + * Returns a {@code Map} from the APK signature scheme version to a {@code byte[]} of the + * strongest supported content digest found in that version's signature block for the V2, + * V3, and V3.1 signatures in the provided {@code apk}. + * + * <p>If a supported content digest algorithm is not found in any of the signature blocks, + * or if the APK is not signed by any of these signature schemes, then an {@code IOException} + * is thrown. + */ + private static Map<Integer, byte[]> getApkDigests(DataSource apk) throws IOException { + ApkUtils.ZipSections zipSections; + try { + zipSections = ApkUtils.findZipSections(apk); + } catch (ZipFormatException e) { + throw new IOException("Malformed APK: not a ZIP archive", e); + } + + Map<Integer, byte[]> sigSchemeToDigest = new HashMap<>(1); + try { + byte[] digest = getBestV3Digest(apk, zipSections, VERSION_APK_SIGNATURE_SCHEME_V31); + sigSchemeToDigest.put(VERSION_APK_SIGNATURE_SCHEME_V31, digest); + } catch (SignatureException expected) { + // It is expected to catch a SignatureException if the APK does not have a v3.1 + // signature. + } + + SignatureException v3Exception = null; + try { + byte[] digest = getBestV3Digest(apk, zipSections, VERSION_APK_SIGNATURE_SCHEME_V3); + sigSchemeToDigest.put(VERSION_APK_SIGNATURE_SCHEME_V3, digest); + } catch (SignatureException e) { + v3Exception = e; + } + + SignatureException v2Exception = null; + try { + byte[] digest = getBestV2Digest(apk, zipSections); + sigSchemeToDigest.put(VERSION_APK_SIGNATURE_SCHEME_V2, digest); + } catch (SignatureException e) { + v2Exception = e; + } + + if (sigSchemeToDigest.size() > 0) { + return sigSchemeToDigest; + } + + throw new IOException( + "Failed to obtain v2/v3 digest, v3 exception: " + v3Exception + ", v2 exception: " + + v2Exception); + } + + private static byte[] getBestV3Digest(DataSource apk, ApkUtils.ZipSections zipSections, + int v3SchemeVersion) throws SignatureException { + final Set<ContentDigestAlgorithm> contentDigestsToVerify = new HashSet<>(1); + final ApkSigningBlockUtils.Result result = new ApkSigningBlockUtils.Result( + v3SchemeVersion); + final int blockId; + switch (v3SchemeVersion) { + case VERSION_APK_SIGNATURE_SCHEME_V31: + blockId = APK_SIGNATURE_SCHEME_V31_BLOCK_ID; + break; + case VERSION_APK_SIGNATURE_SCHEME_V3: + blockId = APK_SIGNATURE_SCHEME_V3_BLOCK_ID; + break; + default: + throw new IllegalArgumentException( + "Invalid V3 scheme provided: " + v3SchemeVersion); + } + try { + final SignatureInfo signatureInfo = + ApkSigningBlockUtils.findSignature(apk, zipSections, blockId, result); + final ByteBuffer apkSignatureSchemeV3Block = signatureInfo.signatureBlock; + V3SchemeVerifier.parseSigners(apkSignatureSchemeV3Block, contentDigestsToVerify, + result); + } catch (Exception e) { + throw new SignatureException("Failed to extract and parse v3 block", e); + } + + if (result.signers.size() != 1) { + throw new SignatureException("Should only have one signer, errors: " + result.getErrors()); + } + + ApkSigningBlockUtils.Result.SignerInfo signer = result.signers.get(0); + if (signer.containsErrors()) { + throw new SignatureException("Parsing failed: " + signer.getErrors()); + } + + final List<ApkSigningBlockUtils.Result.SignerInfo.ContentDigest> contentDigests = + result.signers.get(0).contentDigests; + return pickBestDigest(contentDigests); + } + + private static byte[] getBestV2Digest(DataSource apk, ApkUtils.ZipSections zipSections) + throws SignatureException { + final Set<ContentDigestAlgorithm> contentDigestsToVerify = new HashSet<>(1); + final Set<Integer> foundApkSigSchemeIds = new HashSet<>(1); + final ApkSigningBlockUtils.Result result = new ApkSigningBlockUtils.Result( + ApkSigningBlockUtils.VERSION_APK_SIGNATURE_SCHEME_V2); + try { + final SignatureInfo signatureInfo = + ApkSigningBlockUtils.findSignature(apk, zipSections, + APK_SIGNATURE_SCHEME_V2_BLOCK_ID, result); + final ByteBuffer apkSignatureSchemeV2Block = signatureInfo.signatureBlock; + V2SchemeVerifier.parseSigners( + apkSignatureSchemeV2Block, + contentDigestsToVerify, + Collections.emptyMap(), + foundApkSigSchemeIds, + Integer.MAX_VALUE, + Integer.MAX_VALUE, + result); + } catch (Exception e) { + throw new SignatureException("Failed to extract and parse v2 block", e); + } + + if (result.signers.size() != 1) { + throw new SignatureException("Should only have one signer, errors: " + result.getErrors()); + } + + ApkSigningBlockUtils.Result.SignerInfo signer = result.signers.get(0); + if (signer.containsErrors()) { + throw new SignatureException("Parsing failed: " + signer.getErrors()); + } + + final List<ApkSigningBlockUtils.Result.SignerInfo.ContentDigest> contentDigests = + signer.contentDigests; + return pickBestDigest(contentDigests); + } + + private static byte[] pickBestDigest(List<ApkSigningBlockUtils.Result.SignerInfo.ContentDigest> contentDigests) throws SignatureException { + if (contentDigests == null || contentDigests.isEmpty()) { + throw new SignatureException("Should have at least one digest"); + } + + int bestAlgorithmOrder = -1; + byte[] bestDigest = null; + for (ApkSigningBlockUtils.Result.SignerInfo.ContentDigest contentDigest : contentDigests) { + final SignatureAlgorithm signatureAlgorithm = + SignatureAlgorithm.findById(contentDigest.getSignatureAlgorithmId()); + final ContentDigestAlgorithm contentDigestAlgorithm = + signatureAlgorithm.getContentDigestAlgorithm(); + if (!isSupported(contentDigestAlgorithm, true)) { + continue; + } + final int algorithmOrder = digestAlgorithmSortingOrder(contentDigestAlgorithm); + if (bestAlgorithmOrder < algorithmOrder) { + bestAlgorithmOrder = algorithmOrder; + bestDigest = contentDigest.getValue(); + } + } + if (bestDigest == null) { + throw new SignatureException("Failed to find a supported digest in the source APK"); + } + return bestDigest; + } + + public static int digestAlgorithmSortingOrder(ContentDigestAlgorithm contentDigestAlgorithm) { + switch (contentDigestAlgorithm) { + case CHUNKED_SHA256: + return 0; + case VERITY_CHUNKED_SHA256: + return 1; + case CHUNKED_SHA512: + return 2; + default: + return -1; + } + } + + private static boolean isSupported(final ContentDigestAlgorithm contentDigestAlgorithm, + boolean forV3Digest) { + if (contentDigestAlgorithm == null) { + return false; + } + if (contentDigestAlgorithm == ContentDigestAlgorithm.CHUNKED_SHA256 + || contentDigestAlgorithm == ContentDigestAlgorithm.CHUNKED_SHA512 + || (forV3Digest + && contentDigestAlgorithm == ContentDigestAlgorithm.VERITY_CHUNKED_SHA256)) { + return true; + } + return false; + } + + private static Pair<Integer, Byte> convertToV4HashingInfo(ContentDigestAlgorithm algorithm) + throws NoSuchAlgorithmException { + switch (algorithm) { + case VERITY_CHUNKED_SHA256: + return Pair.of(V4Signature.HASHING_ALGORITHM_SHA256, + V4Signature.LOG2_BLOCK_SIZE_4096_BYTES); + default: + throw new NoSuchAlgorithmException( + "Invalid hash algorithm, only SHA2-256 over 4 KB chunks supported."); + } + } +} diff --git a/platform/android/java/editor/src/main/java/com/android/apksig/internal/apk/v4/V4SchemeVerifier.java b/platform/android/java/editor/src/main/java/com/android/apksig/internal/apk/v4/V4SchemeVerifier.java new file mode 100644 index 0000000000..c0a9013624 --- /dev/null +++ b/platform/android/java/editor/src/main/java/com/android/apksig/internal/apk/v4/V4SchemeVerifier.java @@ -0,0 +1,267 @@ +/* + * Copyright (C) 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.apksig.internal.apk.v4; + +import static com.android.apksig.internal.apk.ApkSigningBlockUtils.toHex; + +import com.android.apksig.ApkVerifier; +import com.android.apksig.ApkVerifier.Issue; +import com.android.apksig.internal.apk.ApkSigningBlockUtils; +import com.android.apksig.internal.apk.ContentDigestAlgorithm; +import com.android.apksig.internal.apk.SignatureAlgorithm; +import com.android.apksig.internal.util.GuaranteedEncodedFormX509Certificate; +import com.android.apksig.internal.util.X509CertificateUtils; +import com.android.apksig.util.DataSource; + +import java.io.File; +import java.io.FileInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.security.InvalidAlgorithmParameterException; +import java.security.InvalidKeyException; +import java.security.KeyFactory; +import java.security.NoSuchAlgorithmException; +import java.security.PublicKey; +import java.security.Signature; +import java.security.SignatureException; +import java.security.cert.CertificateException; +import java.security.cert.X509Certificate; +import java.security.spec.AlgorithmParameterSpec; +import java.security.spec.X509EncodedKeySpec; +import java.util.Arrays; + +/** + * APK Signature Scheme V4 verifier. + * <p> + * Verifies the serialized V4Signature file against an APK. + */ +public abstract class V4SchemeVerifier { + /** + * Hidden constructor to prevent instantiation. + */ + private V4SchemeVerifier() { + } + + /** + * <p> + * The main goals of the verifier are: 1) parse V4Signature file fields 2) verifies the PKCS7 + * signature block against the raw root hash bytes in the proto field 3) verifies that the raw + * root hash matches with the actual hash tree root of the give APK 4) if the file contains a + * verity tree, verifies that it matches with the actual verity tree computed from the given + * APK. + * </p> + */ + public static ApkSigningBlockUtils.Result verify(DataSource apk, File v4SignatureFile) + throws IOException, NoSuchAlgorithmException { + final V4Signature signature; + final byte[] tree; + try (InputStream input = new FileInputStream(v4SignatureFile)) { + signature = V4Signature.readFrom(input); + tree = V4Signature.readBytes(input); + } + + final ApkSigningBlockUtils.Result result = new ApkSigningBlockUtils.Result( + ApkSigningBlockUtils.VERSION_APK_SIGNATURE_SCHEME_V4); + + if (signature == null) { + result.addError(Issue.V4_SIG_NO_SIGNATURES, + "Signature file does not contain a v4 signature."); + return result; + } + + if (signature.version != V4Signature.CURRENT_VERSION) { + result.addWarning(Issue.V4_SIG_VERSION_NOT_CURRENT, signature.version, + V4Signature.CURRENT_VERSION); + } + + V4Signature.HashingInfo hashingInfo = V4Signature.HashingInfo.fromByteArray( + signature.hashingInfo); + + V4Signature.SigningInfos signingInfos = V4Signature.SigningInfos.fromByteArray( + signature.signingInfos); + + final ApkSigningBlockUtils.Result.SignerInfo signerInfo; + + // Verify the primary signature over signedData. + { + V4Signature.SigningInfo signingInfo = signingInfos.signingInfo; + final byte[] signedData = V4Signature.getSignedData(apk.size(), hashingInfo, + signingInfo); + signerInfo = parseAndVerifySignatureBlock(signingInfo, signedData); + result.signers.add(signerInfo); + if (result.containsErrors()) { + return result; + } + } + + // Verify all subsequent signatures. + for (V4Signature.SigningInfoBlock signingInfoBlock : signingInfos.signingInfoBlocks) { + V4Signature.SigningInfo signingInfo = V4Signature.SigningInfo.fromByteArray( + signingInfoBlock.signingInfo); + final byte[] signedData = V4Signature.getSignedData(apk.size(), hashingInfo, + signingInfo); + result.signers.add(parseAndVerifySignatureBlock(signingInfo, signedData)); + if (result.containsErrors()) { + return result; + } + } + + // Check if the root hash and the tree are correct. + verifyRootHashAndTree(apk, signerInfo, hashingInfo.rawRootHash, tree); + if (!result.containsErrors()) { + result.verified = true; + } + + return result; + } + + /** + * Parses the provided signature block and populates the {@code result}. + * <p> + * This verifies {@signingInfo} over {@code signedData}, as well as parsing the certificate + * contained in the signature block. This method adds one or more errors to the {@code result}. + */ + private static ApkSigningBlockUtils.Result.SignerInfo parseAndVerifySignatureBlock( + V4Signature.SigningInfo signingInfo, + final byte[] signedData) throws NoSuchAlgorithmException { + final ApkSigningBlockUtils.Result.SignerInfo result = + new ApkSigningBlockUtils.Result.SignerInfo(); + result.index = 0; + + final int sigAlgorithmId = signingInfo.signatureAlgorithmId; + final byte[] sigBytes = signingInfo.signature; + result.signatures.add( + new ApkSigningBlockUtils.Result.SignerInfo.Signature(sigAlgorithmId, sigBytes)); + + SignatureAlgorithm signatureAlgorithm = SignatureAlgorithm.findById(sigAlgorithmId); + if (signatureAlgorithm == null) { + result.addError(Issue.V4_SIG_UNKNOWN_SIG_ALGORITHM, sigAlgorithmId); + return result; + } + + String jcaSignatureAlgorithm = + signatureAlgorithm.getJcaSignatureAlgorithmAndParams().getFirst(); + AlgorithmParameterSpec jcaSignatureAlgorithmParams = + signatureAlgorithm.getJcaSignatureAlgorithmAndParams().getSecond(); + + String keyAlgorithm = signatureAlgorithm.getJcaKeyAlgorithm(); + + final byte[] publicKeyBytes = signingInfo.publicKey; + PublicKey publicKey; + try { + publicKey = KeyFactory.getInstance(keyAlgorithm).generatePublic( + new X509EncodedKeySpec(publicKeyBytes)); + } catch (Exception e) { + result.addError(Issue.V4_SIG_MALFORMED_PUBLIC_KEY, e); + return result; + } + + try { + Signature sig = Signature.getInstance(jcaSignatureAlgorithm); + sig.initVerify(publicKey); + if (jcaSignatureAlgorithmParams != null) { + sig.setParameter(jcaSignatureAlgorithmParams); + } + sig.update(signedData); + if (!sig.verify(sigBytes)) { + result.addError(Issue.V4_SIG_DID_NOT_VERIFY, signatureAlgorithm); + return result; + } + result.verifiedSignatures.put(signatureAlgorithm, sigBytes); + } catch (InvalidKeyException | InvalidAlgorithmParameterException + | SignatureException e) { + result.addError(Issue.V4_SIG_VERIFY_EXCEPTION, signatureAlgorithm, e); + return result; + } + + if (signingInfo.certificate == null) { + result.addError(Issue.V4_SIG_NO_CERTIFICATE); + return result; + } + + final X509Certificate certificate; + try { + // Wrap the cert so that the result's getEncoded returns exactly the original encoded + // form. Without this, getEncoded may return a different form from what was stored in + // the signature. This is because some X509Certificate(Factory) implementations + // re-encode certificates. + certificate = new GuaranteedEncodedFormX509Certificate( + X509CertificateUtils.generateCertificate(signingInfo.certificate), + signingInfo.certificate); + } catch (CertificateException e) { + result.addError(Issue.V4_SIG_MALFORMED_CERTIFICATE, e); + return result; + } + result.certs.add(certificate); + + byte[] certificatePublicKeyBytes; + try { + certificatePublicKeyBytes = ApkSigningBlockUtils.encodePublicKey( + certificate.getPublicKey()); + } catch (InvalidKeyException e) { + System.out.println("Caught an exception encoding the public key: " + e); + e.printStackTrace(); + certificatePublicKeyBytes = certificate.getPublicKey().getEncoded(); + } + if (!Arrays.equals(publicKeyBytes, certificatePublicKeyBytes)) { + result.addError( + Issue.V4_SIG_PUBLIC_KEY_MISMATCH_BETWEEN_CERTIFICATE_AND_SIGNATURES_RECORD, + ApkSigningBlockUtils.toHex(certificatePublicKeyBytes), + ApkSigningBlockUtils.toHex(publicKeyBytes)); + return result; + } + + // Add apk digest from the file to the result. + ApkSigningBlockUtils.Result.SignerInfo.ContentDigest contentDigest = + new ApkSigningBlockUtils.Result.SignerInfo.ContentDigest( + 0 /* signature algorithm id doesn't matter here */, signingInfo.apkDigest); + result.contentDigests.add(contentDigest); + + return result; + } + + private static void verifyRootHashAndTree(DataSource apkContent, + ApkSigningBlockUtils.Result.SignerInfo signerInfo, byte[] expectedDigest, + byte[] expectedTree) throws IOException, NoSuchAlgorithmException { + ApkSigningBlockUtils.VerityTreeAndDigest actualContentDigestInfo = + ApkSigningBlockUtils.computeChunkVerityTreeAndDigest(apkContent); + + ContentDigestAlgorithm algorithm = actualContentDigestInfo.contentDigestAlgorithm; + final byte[] actualDigest = actualContentDigestInfo.rootHash; + final byte[] actualTree = actualContentDigestInfo.tree; + + if (!Arrays.equals(expectedDigest, actualDigest)) { + signerInfo.addError( + ApkVerifier.Issue.V4_SIG_APK_ROOT_DID_NOT_VERIFY, + algorithm, + toHex(expectedDigest), + toHex(actualDigest)); + return; + } + // Only check verity tree if it is not empty + if (expectedTree != null && !Arrays.equals(expectedTree, actualTree)) { + signerInfo.addError( + ApkVerifier.Issue.V4_SIG_APK_TREE_DID_NOT_VERIFY, + algorithm, + toHex(expectedDigest), + toHex(actualDigest)); + return; + } + + signerInfo.verifiedContentDigests.put(algorithm, actualDigest); + } +} diff --git a/platform/android/java/editor/src/main/java/com/android/apksig/internal/apk/v4/V4Signature.java b/platform/android/java/editor/src/main/java/com/android/apksig/internal/apk/v4/V4Signature.java new file mode 100644 index 0000000000..1eac5a2604 --- /dev/null +++ b/platform/android/java/editor/src/main/java/com/android/apksig/internal/apk/v4/V4Signature.java @@ -0,0 +1,311 @@ +/* + * Copyright (C) 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.apksig.internal.apk.v4; + +import java.io.EOFException; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.util.ArrayList; +import java.util.Arrays; + +public class V4Signature { + public static final int CURRENT_VERSION = 2; + + public static final int HASHING_ALGORITHM_SHA256 = 1; + public static final byte LOG2_BLOCK_SIZE_4096_BYTES = 12; + + public static final int MAX_SIGNING_INFOS_SIZE = 7168; + + public static class HashingInfo { + public final int hashAlgorithm; // only 1 == SHA256 supported + public final byte log2BlockSize; // only 12 (block size 4096) supported now + public final byte[] salt; // used exactly as in fs-verity, 32 bytes max + public final byte[] rawRootHash; // salted digest of the first Merkle tree page + + HashingInfo(int hashAlgorithm, byte log2BlockSize, byte[] salt, byte[] rawRootHash) { + this.hashAlgorithm = hashAlgorithm; + this.log2BlockSize = log2BlockSize; + this.salt = salt; + this.rawRootHash = rawRootHash; + } + + static HashingInfo fromByteArray(byte[] bytes) throws IOException { + ByteBuffer buffer = ByteBuffer.wrap(bytes).order(ByteOrder.LITTLE_ENDIAN); + final int hashAlgorithm = buffer.getInt(); + final byte log2BlockSize = buffer.get(); + byte[] salt = readBytes(buffer); + byte[] rawRootHash = readBytes(buffer); + return new HashingInfo(hashAlgorithm, log2BlockSize, salt, rawRootHash); + } + + byte[] toByteArray() { + final int size = 4/*hashAlgorithm*/ + 1/*log2BlockSize*/ + bytesSize(this.salt) + + bytesSize(this.rawRootHash); + ByteBuffer buffer = ByteBuffer.allocate(size).order(ByteOrder.LITTLE_ENDIAN); + buffer.putInt(this.hashAlgorithm); + buffer.put(this.log2BlockSize); + writeBytes(buffer, this.salt); + writeBytes(buffer, this.rawRootHash); + return buffer.array(); + } + } + + public static class SigningInfo { + public final byte[] apkDigest; // used to match with the corresponding APK + public final byte[] certificate; // ASN.1 DER form + public final byte[] additionalData; // a free-form binary data blob + public final byte[] publicKey; // ASN.1 DER, must match the certificate + public final int signatureAlgorithmId; // see the APK v2 doc for the list + public final byte[] signature; + + SigningInfo(byte[] apkDigest, byte[] certificate, byte[] additionalData, + byte[] publicKey, int signatureAlgorithmId, byte[] signature) { + this.apkDigest = apkDigest; + this.certificate = certificate; + this.additionalData = additionalData; + this.publicKey = publicKey; + this.signatureAlgorithmId = signatureAlgorithmId; + this.signature = signature; + } + + static SigningInfo fromByteArray(byte[] bytes) throws IOException { + return fromByteBuffer(ByteBuffer.wrap(bytes).order(ByteOrder.LITTLE_ENDIAN)); + } + + static SigningInfo fromByteBuffer(ByteBuffer buffer) throws IOException { + byte[] apkDigest = readBytes(buffer); + byte[] certificate = readBytes(buffer); + byte[] additionalData = readBytes(buffer); + byte[] publicKey = readBytes(buffer); + int signatureAlgorithmId = buffer.getInt(); + byte[] signature = readBytes(buffer); + return new SigningInfo(apkDigest, certificate, additionalData, publicKey, + signatureAlgorithmId, signature); + } + + byte[] toByteArray() { + final int size = bytesSize(this.apkDigest) + bytesSize(this.certificate) + bytesSize( + this.additionalData) + bytesSize(this.publicKey) + 4/*signatureAlgorithmId*/ + + bytesSize(this.signature); + ByteBuffer buffer = ByteBuffer.allocate(size).order(ByteOrder.LITTLE_ENDIAN); + writeBytes(buffer, this.apkDigest); + writeBytes(buffer, this.certificate); + writeBytes(buffer, this.additionalData); + writeBytes(buffer, this.publicKey); + buffer.putInt(this.signatureAlgorithmId); + writeBytes(buffer, this.signature); + return buffer.array(); + } + } + + public static class SigningInfoBlock { + public final int blockId; + public final byte[] signingInfo; + + public SigningInfoBlock(int blockId, byte[] signingInfo) { + this.blockId = blockId; + this.signingInfo = signingInfo; + } + + static SigningInfoBlock fromByteBuffer(ByteBuffer buffer) throws IOException { + int blockId = buffer.getInt(); + byte[] signingInfo = readBytes(buffer); + return new SigningInfoBlock(blockId, signingInfo); + } + + byte[] toByteArray() { + final int size = 4/*blockId*/ + bytesSize(this.signingInfo); + ByteBuffer buffer = ByteBuffer.allocate(size).order(ByteOrder.LITTLE_ENDIAN); + buffer.putInt(this.blockId); + writeBytes(buffer, this.signingInfo); + return buffer.array(); + } + } + + public static class SigningInfos { + public final SigningInfo signingInfo; + public final SigningInfoBlock[] signingInfoBlocks; + + public SigningInfos(SigningInfo signingInfo) { + this.signingInfo = signingInfo; + this.signingInfoBlocks = new SigningInfoBlock[0]; + } + + public SigningInfos(SigningInfo signingInfo, SigningInfoBlock... signingInfoBlocks) { + this.signingInfo = signingInfo; + this.signingInfoBlocks = signingInfoBlocks; + } + + public static SigningInfos fromByteArray(byte[] bytes) throws IOException { + ByteBuffer buffer = ByteBuffer.wrap(bytes).order(ByteOrder.LITTLE_ENDIAN); + SigningInfo signingInfo = SigningInfo.fromByteBuffer(buffer); + if (!buffer.hasRemaining()) { + return new SigningInfos(signingInfo); + } + ArrayList<SigningInfoBlock> signingInfoBlocks = new ArrayList<>(1); + while (buffer.hasRemaining()) { + signingInfoBlocks.add(SigningInfoBlock.fromByteBuffer(buffer)); + } + return new SigningInfos(signingInfo, + signingInfoBlocks.toArray(new SigningInfoBlock[signingInfoBlocks.size()])); + } + + byte[] toByteArray() { + byte[][] arrays = new byte[1 + this.signingInfoBlocks.length][]; + arrays[0] = this.signingInfo.toByteArray(); + int size = arrays[0].length; + for (int i = 0, isize = this.signingInfoBlocks.length; i < isize; ++i) { + arrays[i + 1] = this.signingInfoBlocks[i].toByteArray(); + size += arrays[i + 1].length; + } + if (size > MAX_SIGNING_INFOS_SIZE) { + throw new IllegalArgumentException( + "Combined SigningInfos length exceeded limit of 7K: " + size); + } + + // Combine all arrays into one. + byte[] result = Arrays.copyOf(arrays[0], size); + int offset = arrays[0].length; + for (int i = 0, isize = this.signingInfoBlocks.length; i < isize; ++i) { + System.arraycopy(arrays[i + 1], 0, result, offset, arrays[i + 1].length); + offset += arrays[i + 1].length; + } + return result; + } + } + + // Always 2 for now. + public final int version; + public final byte[] hashingInfo; + // Can contain either SigningInfo or SigningInfo + one or multiple SigningInfoBlock. + // Passed as-is to the kernel. Can be retrieved later. + public final byte[] signingInfos; + + V4Signature(int version, byte[] hashingInfo, byte[] signingInfos) { + this.version = version; + this.hashingInfo = hashingInfo; + this.signingInfos = signingInfos; + } + + static V4Signature readFrom(InputStream stream) throws IOException { + final int version = readIntLE(stream); + if (version != CURRENT_VERSION) { + throw new IOException("Invalid signature version."); + } + final byte[] hashingInfo = readBytes(stream); + final byte[] signingInfo = readBytes(stream); + return new V4Signature(version, hashingInfo, signingInfo); + } + + public void writeTo(OutputStream stream) throws IOException { + writeIntLE(stream, this.version); + writeBytes(stream, this.hashingInfo); + writeBytes(stream, this.signingInfos); + } + + static byte[] getSignedData(long fileSize, HashingInfo hashingInfo, SigningInfo signingInfo) { + final int size = + 4/*size*/ + 8/*fileSize*/ + 4/*hash_algorithm*/ + 1/*log2_blocksize*/ + bytesSize( + hashingInfo.salt) + bytesSize(hashingInfo.rawRootHash) + bytesSize( + signingInfo.apkDigest) + bytesSize(signingInfo.certificate) + bytesSize( + signingInfo.additionalData); + ByteBuffer buffer = ByteBuffer.allocate(size).order(ByteOrder.LITTLE_ENDIAN); + buffer.putInt(size); + buffer.putLong(fileSize); + buffer.putInt(hashingInfo.hashAlgorithm); + buffer.put(hashingInfo.log2BlockSize); + writeBytes(buffer, hashingInfo.salt); + writeBytes(buffer, hashingInfo.rawRootHash); + writeBytes(buffer, signingInfo.apkDigest); + writeBytes(buffer, signingInfo.certificate); + writeBytes(buffer, signingInfo.additionalData); + return buffer.array(); + } + + // Utility methods. + static int bytesSize(byte[] bytes) { + return 4/*length*/ + (bytes == null ? 0 : bytes.length); + } + + static void readFully(InputStream stream, byte[] buffer) throws IOException { + int len = buffer.length; + int n = 0; + while (n < len) { + int count = stream.read(buffer, n, len - n); + if (count < 0) { + throw new EOFException(); + } + n += count; + } + } + + static int readIntLE(InputStream stream) throws IOException { + final byte[] buffer = new byte[4]; + readFully(stream, buffer); + return ByteBuffer.wrap(buffer).order(ByteOrder.LITTLE_ENDIAN).getInt(); + } + + static void writeIntLE(OutputStream stream, int v) throws IOException { + final byte[] buffer = ByteBuffer.wrap(new byte[4]).order(ByteOrder.LITTLE_ENDIAN).putInt(v).array(); + stream.write(buffer); + } + + static byte[] readBytes(InputStream stream) throws IOException { + try { + final int size = readIntLE(stream); + final byte[] bytes = new byte[size]; + readFully(stream, bytes); + return bytes; + } catch (EOFException ignored) { + return null; + } + } + + static byte[] readBytes(ByteBuffer buffer) throws IOException { + if (buffer.remaining() < 4) { + throw new EOFException(); + } + final int size = buffer.getInt(); + if (buffer.remaining() < size) { + throw new EOFException(); + } + final byte[] bytes = new byte[size]; + buffer.get(bytes); + return bytes; + } + + static void writeBytes(OutputStream stream, byte[] bytes) throws IOException { + if (bytes == null) { + writeIntLE(stream, 0); + return; + } + writeIntLE(stream, bytes.length); + stream.write(bytes); + } + + static void writeBytes(ByteBuffer buffer, byte[] bytes) { + if (bytes == null) { + buffer.putInt(0); + return; + } + buffer.putInt(bytes.length); + buffer.put(bytes); + } +} diff --git a/platform/android/java/editor/src/main/java/com/android/apksig/internal/asn1/Asn1BerParser.java b/platform/android/java/editor/src/main/java/com/android/apksig/internal/asn1/Asn1BerParser.java new file mode 100644 index 0000000000..160dc4e233 --- /dev/null +++ b/platform/android/java/editor/src/main/java/com/android/apksig/internal/asn1/Asn1BerParser.java @@ -0,0 +1,673 @@ +/* + * Copyright (C) 2017 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.apksig.internal.asn1; + +import com.android.apksig.internal.asn1.ber.BerDataValue; +import com.android.apksig.internal.asn1.ber.BerDataValueFormatException; +import com.android.apksig.internal.asn1.ber.BerDataValueReader; +import com.android.apksig.internal.asn1.ber.BerEncoding; +import com.android.apksig.internal.asn1.ber.ByteBufferBerDataValueReader; +import com.android.apksig.internal.util.ByteBufferUtils; + +import java.lang.reflect.Field; +import java.lang.reflect.Modifier; +import java.math.BigInteger; +import java.nio.ByteBuffer; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +/** + * Parser of ASN.1 BER-encoded structures. + * + * <p>Structure is described to the parser by providing a class annotated with {@link Asn1Class}, + * containing fields annotated with {@link Asn1Field}. + */ +public final class Asn1BerParser { + private Asn1BerParser() {} + + /** + * Returns the ASN.1 structure contained in the BER encoded input. + * + * @param encoded encoded input. If the decoding operation succeeds, the position of this buffer + * is advanced to the first position following the end of the consumed structure. + * @param containerClass class describing the structure of the input. The class must meet the + * following requirements: + * <ul> + * <li>The class must be annotated with {@link Asn1Class}.</li> + * <li>The class must expose a public no-arg constructor.</li> + * <li>Member fields of the class which are populated with parsed input must be + * annotated with {@link Asn1Field} and be public and non-final.</li> + * </ul> + * + * @throws Asn1DecodingException if the input could not be decoded into the specified Java + * object + */ + public static <T> T parse(ByteBuffer encoded, Class<T> containerClass) + throws Asn1DecodingException { + BerDataValue containerDataValue; + try { + containerDataValue = new ByteBufferBerDataValueReader(encoded).readDataValue(); + } catch (BerDataValueFormatException e) { + throw new Asn1DecodingException("Failed to decode top-level data value", e); + } + if (containerDataValue == null) { + throw new Asn1DecodingException("Empty input"); + } + return parse(containerDataValue, containerClass); + } + + /** + * Returns the implicit {@code SET OF} contained in the provided ASN.1 BER input. Implicit means + * that this method does not care whether the tag number of this data structure is + * {@code SET OF} and whether the tag class is {@code UNIVERSAL}. + * + * <p>Note: The returned type is {@link List} rather than {@link java.util.Set} because ASN.1 + * SET may contain duplicate elements. + * + * @param encoded encoded input. If the decoding operation succeeds, the position of this buffer + * is advanced to the first position following the end of the consumed structure. + * @param elementClass class describing the structure of the values/elements contained in this + * container. The class must meet the following requirements: + * <ul> + * <li>The class must be annotated with {@link Asn1Class}.</li> + * <li>The class must expose a public no-arg constructor.</li> + * <li>Member fields of the class which are populated with parsed input must be + * annotated with {@link Asn1Field} and be public and non-final.</li> + * </ul> + * + * @throws Asn1DecodingException if the input could not be decoded into the specified Java + * object + */ + public static <T> List<T> parseImplicitSetOf(ByteBuffer encoded, Class<T> elementClass) + throws Asn1DecodingException { + BerDataValue containerDataValue; + try { + containerDataValue = new ByteBufferBerDataValueReader(encoded).readDataValue(); + } catch (BerDataValueFormatException e) { + throw new Asn1DecodingException("Failed to decode top-level data value", e); + } + if (containerDataValue == null) { + throw new Asn1DecodingException("Empty input"); + } + return parseSetOf(containerDataValue, elementClass); + } + + private static <T> T parse(BerDataValue container, Class<T> containerClass) + throws Asn1DecodingException { + if (container == null) { + throw new NullPointerException("container == null"); + } + if (containerClass == null) { + throw new NullPointerException("containerClass == null"); + } + + Asn1Type dataType = getContainerAsn1Type(containerClass); + switch (dataType) { + case CHOICE: + return parseChoice(container, containerClass); + + case SEQUENCE: + { + int expectedTagClass = BerEncoding.TAG_CLASS_UNIVERSAL; + int expectedTagNumber = BerEncoding.getTagNumber(dataType); + if ((container.getTagClass() != expectedTagClass) + || (container.getTagNumber() != expectedTagNumber)) { + throw new Asn1UnexpectedTagException( + "Unexpected data value read as " + containerClass.getName() + + ". Expected " + BerEncoding.tagClassAndNumberToString( + expectedTagClass, expectedTagNumber) + + ", but read: " + BerEncoding.tagClassAndNumberToString( + container.getTagClass(), container.getTagNumber())); + } + return parseSequence(container, containerClass); + } + case UNENCODED_CONTAINER: + return parseSequence(container, containerClass, true); + default: + throw new Asn1DecodingException("Parsing container " + dataType + " not supported"); + } + } + + private static <T> T parseChoice(BerDataValue dataValue, Class<T> containerClass) + throws Asn1DecodingException { + List<AnnotatedField> fields = getAnnotatedFields(containerClass); + if (fields.isEmpty()) { + throw new Asn1DecodingException( + "No fields annotated with " + Asn1Field.class.getName() + + " in CHOICE class " + containerClass.getName()); + } + + // Check that class + tagNumber don't clash between the choices + for (int i = 0; i < fields.size() - 1; i++) { + AnnotatedField f1 = fields.get(i); + int tagNumber1 = f1.getBerTagNumber(); + int tagClass1 = f1.getBerTagClass(); + for (int j = i + 1; j < fields.size(); j++) { + AnnotatedField f2 = fields.get(j); + int tagNumber2 = f2.getBerTagNumber(); + int tagClass2 = f2.getBerTagClass(); + if ((tagNumber1 == tagNumber2) && (tagClass1 == tagClass2)) { + throw new Asn1DecodingException( + "CHOICE fields are indistinguishable because they have the same tag" + + " class and number: " + containerClass.getName() + + "." + f1.getField().getName() + + " and ." + f2.getField().getName()); + } + } + } + + // Instantiate the container object / result + T obj; + try { + obj = containerClass.getConstructor().newInstance(); + } catch (IllegalArgumentException | ReflectiveOperationException e) { + throw new Asn1DecodingException("Failed to instantiate " + containerClass.getName(), e); + } + // Set the matching field's value from the data value + for (AnnotatedField field : fields) { + try { + field.setValueFrom(dataValue, obj); + return obj; + } catch (Asn1UnexpectedTagException expected) { + // not a match + } + } + + throw new Asn1DecodingException( + "No options of CHOICE " + containerClass.getName() + " matched"); + } + + private static <T> T parseSequence(BerDataValue container, Class<T> containerClass) + throws Asn1DecodingException { + return parseSequence(container, containerClass, false); + } + + private static <T> T parseSequence(BerDataValue container, Class<T> containerClass, + boolean isUnencodedContainer) throws Asn1DecodingException { + List<AnnotatedField> fields = getAnnotatedFields(containerClass); + Collections.sort( + fields, (f1, f2) -> f1.getAnnotation().index() - f2.getAnnotation().index()); + // Check that there are no fields with the same index + if (fields.size() > 1) { + AnnotatedField lastField = null; + for (AnnotatedField field : fields) { + if ((lastField != null) + && (lastField.getAnnotation().index() == field.getAnnotation().index())) { + throw new Asn1DecodingException( + "Fields have the same index: " + containerClass.getName() + + "." + lastField.getField().getName() + + " and ." + field.getField().getName()); + } + lastField = field; + } + } + + // Instantiate the container object / result + T t; + try { + t = containerClass.getConstructor().newInstance(); + } catch (IllegalArgumentException | ReflectiveOperationException e) { + throw new Asn1DecodingException("Failed to instantiate " + containerClass.getName(), e); + } + + // Parse fields one by one. A complication is that there may be optional fields. + int nextUnreadFieldIndex = 0; + BerDataValueReader elementsReader = container.contentsReader(); + while (nextUnreadFieldIndex < fields.size()) { + BerDataValue dataValue; + try { + // if this is the first field of an unencoded container then the entire contents of + // the container should be used when assigning to this field. + if (isUnencodedContainer && nextUnreadFieldIndex == 0) { + dataValue = container; + } else { + dataValue = elementsReader.readDataValue(); + } + } catch (BerDataValueFormatException e) { + throw new Asn1DecodingException("Malformed data value", e); + } + if (dataValue == null) { + break; + } + + for (int i = nextUnreadFieldIndex; i < fields.size(); i++) { + AnnotatedField field = fields.get(i); + try { + if (field.isOptional()) { + // Optional field -- might not be present and we may thus be trying to set + // it from the wrong tag. + try { + field.setValueFrom(dataValue, t); + nextUnreadFieldIndex = i + 1; + break; + } catch (Asn1UnexpectedTagException e) { + // This field is not present, attempt to use this data value for the + // next / iteration of the loop + continue; + } + } else { + // Mandatory field -- if we can't set its value from this data value, then + // it's an error + field.setValueFrom(dataValue, t); + nextUnreadFieldIndex = i + 1; + break; + } + } catch (Asn1DecodingException e) { + throw new Asn1DecodingException( + "Failed to parse " + containerClass.getName() + + "." + field.getField().getName(), + e); + } + } + } + + return t; + } + + // NOTE: This method returns List rather than Set because ASN.1 SET_OF does require uniqueness + // of elements -- it's an unordered collection. + @SuppressWarnings("unchecked") + private static <T> List<T> parseSetOf(BerDataValue container, Class<T> elementClass) + throws Asn1DecodingException { + List<T> result = new ArrayList<>(); + BerDataValueReader elementsReader = container.contentsReader(); + while (true) { + BerDataValue dataValue; + try { + dataValue = elementsReader.readDataValue(); + } catch (BerDataValueFormatException e) { + throw new Asn1DecodingException("Malformed data value", e); + } + if (dataValue == null) { + break; + } + T element; + if (ByteBuffer.class.equals(elementClass)) { + element = (T) dataValue.getEncodedContents(); + } else if (Asn1OpaqueObject.class.equals(elementClass)) { + element = (T) new Asn1OpaqueObject(dataValue.getEncoded()); + } else { + element = parse(dataValue, elementClass); + } + result.add(element); + } + return result; + } + + private static Asn1Type getContainerAsn1Type(Class<?> containerClass) + throws Asn1DecodingException { + Asn1Class containerAnnotation = containerClass.getDeclaredAnnotation(Asn1Class.class); + if (containerAnnotation == null) { + throw new Asn1DecodingException( + containerClass.getName() + " is not annotated with " + + Asn1Class.class.getName()); + } + + switch (containerAnnotation.type()) { + case CHOICE: + case SEQUENCE: + case UNENCODED_CONTAINER: + return containerAnnotation.type(); + default: + throw new Asn1DecodingException( + "Unsupported ASN.1 container annotation type: " + + containerAnnotation.type()); + } + } + + private static Class<?> getElementType(Field field) + throws Asn1DecodingException, ClassNotFoundException { + String type = field.getGenericType().getTypeName(); + int delimiterIndex = type.indexOf('<'); + if (delimiterIndex == -1) { + throw new Asn1DecodingException("Not a container type: " + field.getGenericType()); + } + int startIndex = delimiterIndex + 1; + int endIndex = type.indexOf('>', startIndex); + // TODO: handle comma? + if (endIndex == -1) { + throw new Asn1DecodingException("Not a container type: " + field.getGenericType()); + } + String elementClassName = type.substring(startIndex, endIndex); + return Class.forName(elementClassName); + } + + private static final class AnnotatedField { + private final Field mField; + private final Asn1Field mAnnotation; + private final Asn1Type mDataType; + private final Asn1TagClass mTagClass; + private final int mBerTagClass; + private final int mBerTagNumber; + private final Asn1Tagging mTagging; + private final boolean mOptional; + + public AnnotatedField(Field field, Asn1Field annotation) throws Asn1DecodingException { + mField = field; + mAnnotation = annotation; + mDataType = annotation.type(); + + Asn1TagClass tagClass = annotation.cls(); + if (tagClass == Asn1TagClass.AUTOMATIC) { + if (annotation.tagNumber() != -1) { + tagClass = Asn1TagClass.CONTEXT_SPECIFIC; + } else { + tagClass = Asn1TagClass.UNIVERSAL; + } + } + mTagClass = tagClass; + mBerTagClass = BerEncoding.getTagClass(mTagClass); + + int tagNumber; + if (annotation.tagNumber() != -1) { + tagNumber = annotation.tagNumber(); + } else if ((mDataType == Asn1Type.CHOICE) || (mDataType == Asn1Type.ANY)) { + tagNumber = -1; + } else { + tagNumber = BerEncoding.getTagNumber(mDataType); + } + mBerTagNumber = tagNumber; + + mTagging = annotation.tagging(); + if (((mTagging == Asn1Tagging.EXPLICIT) || (mTagging == Asn1Tagging.IMPLICIT)) + && (annotation.tagNumber() == -1)) { + throw new Asn1DecodingException( + "Tag number must be specified when tagging mode is " + mTagging); + } + + mOptional = annotation.optional(); + } + + public Field getField() { + return mField; + } + + public Asn1Field getAnnotation() { + return mAnnotation; + } + + public boolean isOptional() { + return mOptional; + } + + public int getBerTagClass() { + return mBerTagClass; + } + + public int getBerTagNumber() { + return mBerTagNumber; + } + + public void setValueFrom(BerDataValue dataValue, Object obj) throws Asn1DecodingException { + int readTagClass = dataValue.getTagClass(); + if (mBerTagNumber != -1) { + int readTagNumber = dataValue.getTagNumber(); + if ((readTagClass != mBerTagClass) || (readTagNumber != mBerTagNumber)) { + throw new Asn1UnexpectedTagException( + "Tag mismatch. Expected: " + + BerEncoding.tagClassAndNumberToString(mBerTagClass, mBerTagNumber) + + ", but found " + + BerEncoding.tagClassAndNumberToString(readTagClass, readTagNumber)); + } + } else { + if (readTagClass != mBerTagClass) { + throw new Asn1UnexpectedTagException( + "Tag mismatch. Expected class: " + + BerEncoding.tagClassToString(mBerTagClass) + + ", but found " + + BerEncoding.tagClassToString(readTagClass)); + } + } + + if (mTagging == Asn1Tagging.EXPLICIT) { + try { + dataValue = dataValue.contentsReader().readDataValue(); + } catch (BerDataValueFormatException e) { + throw new Asn1DecodingException( + "Failed to read contents of EXPLICIT data value", e); + } + } + + BerToJavaConverter.setFieldValue(obj, mField, mDataType, dataValue); + } + } + + private static class Asn1UnexpectedTagException extends Asn1DecodingException { + private static final long serialVersionUID = 1L; + + public Asn1UnexpectedTagException(String message) { + super(message); + } + } + + private static String oidToString(ByteBuffer encodedOid) throws Asn1DecodingException { + if (!encodedOid.hasRemaining()) { + throw new Asn1DecodingException("Empty OBJECT IDENTIFIER"); + } + + // First component encodes the first two nodes, X.Y, as X * 40 + Y, with 0 <= X <= 2 + long firstComponent = decodeBase128UnsignedLong(encodedOid); + int firstNode = (int) Math.min(firstComponent / 40, 2); + long secondNode = firstComponent - firstNode * 40; + StringBuilder result = new StringBuilder(); + result.append(Long.toString(firstNode)).append('.') + .append(Long.toString(secondNode)); + + // Each consecutive node is encoded as a separate component + while (encodedOid.hasRemaining()) { + long node = decodeBase128UnsignedLong(encodedOid); + result.append('.').append(Long.toString(node)); + } + + return result.toString(); + } + + private static long decodeBase128UnsignedLong(ByteBuffer encoded) throws Asn1DecodingException { + if (!encoded.hasRemaining()) { + return 0; + } + long result = 0; + while (encoded.hasRemaining()) { + if (result > Long.MAX_VALUE >>> 7) { + throw new Asn1DecodingException("Base-128 number too large"); + } + int b = encoded.get() & 0xff; + result <<= 7; + result |= b & 0x7f; + if ((b & 0x80) == 0) { + return result; + } + } + throw new Asn1DecodingException( + "Truncated base-128 encoded input: missing terminating byte, with highest bit not" + + " set"); + } + + private static BigInteger integerToBigInteger(ByteBuffer encoded) { + if (!encoded.hasRemaining()) { + return BigInteger.ZERO; + } + return new BigInteger(ByteBufferUtils.toByteArray(encoded)); + } + + private static int integerToInt(ByteBuffer encoded) throws Asn1DecodingException { + BigInteger value = integerToBigInteger(encoded); + if (value.compareTo(BigInteger.valueOf(Integer.MIN_VALUE)) < 0 + || value.compareTo(BigInteger.valueOf(Integer.MAX_VALUE)) > 0) { + throw new Asn1DecodingException( + String.format("INTEGER cannot be represented as int: %1$d (0x%1$x)", value)); + } + return value.intValue(); + } + + private static long integerToLong(ByteBuffer encoded) throws Asn1DecodingException { + BigInteger value = integerToBigInteger(encoded); + if (value.compareTo(BigInteger.valueOf(Long.MIN_VALUE)) < 0 + || value.compareTo(BigInteger.valueOf(Long.MAX_VALUE)) > 0) { + throw new Asn1DecodingException( + String.format("INTEGER cannot be represented as long: %1$d (0x%1$x)", value)); + } + return value.longValue(); + } + + private static List<AnnotatedField> getAnnotatedFields(Class<?> containerClass) + throws Asn1DecodingException { + Field[] declaredFields = containerClass.getDeclaredFields(); + List<AnnotatedField> result = new ArrayList<>(declaredFields.length); + for (Field field : declaredFields) { + Asn1Field annotation = field.getDeclaredAnnotation(Asn1Field.class); + if (annotation == null) { + continue; + } + if (Modifier.isStatic(field.getModifiers())) { + throw new Asn1DecodingException( + Asn1Field.class.getName() + " used on a static field: " + + containerClass.getName() + "." + field.getName()); + } + + AnnotatedField annotatedField; + try { + annotatedField = new AnnotatedField(field, annotation); + } catch (Asn1DecodingException e) { + throw new Asn1DecodingException( + "Invalid ASN.1 annotation on " + + containerClass.getName() + "." + field.getName(), + e); + } + result.add(annotatedField); + } + return result; + } + + private static final class BerToJavaConverter { + private BerToJavaConverter() {} + + public static void setFieldValue( + Object obj, Field field, Asn1Type type, BerDataValue dataValue) + throws Asn1DecodingException { + try { + switch (type) { + case SET_OF: + case SEQUENCE_OF: + if (Asn1OpaqueObject.class.equals(field.getType())) { + field.set(obj, convert(type, dataValue, field.getType())); + } else { + field.set(obj, parseSetOf(dataValue, getElementType(field))); + } + return; + default: + field.set(obj, convert(type, dataValue, field.getType())); + break; + } + } catch (ReflectiveOperationException e) { + throw new Asn1DecodingException( + "Failed to set value of " + obj.getClass().getName() + + "." + field.getName(), + e); + } + } + + private static final byte[] EMPTY_BYTE_ARRAY = new byte[0]; + + @SuppressWarnings("unchecked") + public static <T> T convert( + Asn1Type sourceType, + BerDataValue dataValue, + Class<T> targetType) throws Asn1DecodingException { + if (ByteBuffer.class.equals(targetType)) { + return (T) dataValue.getEncodedContents(); + } else if (byte[].class.equals(targetType)) { + ByteBuffer resultBuf = dataValue.getEncodedContents(); + if (!resultBuf.hasRemaining()) { + return (T) EMPTY_BYTE_ARRAY; + } + byte[] result = new byte[resultBuf.remaining()]; + resultBuf.get(result); + return (T) result; + } else if (Asn1OpaqueObject.class.equals(targetType)) { + return (T) new Asn1OpaqueObject(dataValue.getEncoded()); + } + ByteBuffer encodedContents = dataValue.getEncodedContents(); + switch (sourceType) { + case INTEGER: + if ((int.class.equals(targetType)) || (Integer.class.equals(targetType))) { + return (T) Integer.valueOf(integerToInt(encodedContents)); + } else if ((long.class.equals(targetType)) || (Long.class.equals(targetType))) { + return (T) Long.valueOf(integerToLong(encodedContents)); + } else if (BigInteger.class.equals(targetType)) { + return (T) integerToBigInteger(encodedContents); + } + break; + case OBJECT_IDENTIFIER: + if (String.class.equals(targetType)) { + return (T) oidToString(encodedContents); + } + break; + case UTC_TIME: + case GENERALIZED_TIME: + if (String.class.equals(targetType)) { + return (T) new String(ByteBufferUtils.toByteArray(encodedContents)); + } + break; + case BOOLEAN: + // A boolean should be encoded in a single byte with a value of 0 for false and + // any non-zero value for true. + if (boolean.class.equals(targetType)) { + if (encodedContents.remaining() != 1) { + throw new Asn1DecodingException( + "Incorrect encoded size of boolean value: " + + encodedContents.remaining()); + } + boolean result; + if (encodedContents.get() == 0) { + result = false; + } else { + result = true; + } + return (T) new Boolean(result); + } + break; + case SEQUENCE: + { + Asn1Class containerAnnotation = + targetType.getDeclaredAnnotation(Asn1Class.class); + if ((containerAnnotation != null) + && (containerAnnotation.type() == Asn1Type.SEQUENCE)) { + return parseSequence(dataValue, targetType); + } + break; + } + case CHOICE: + { + Asn1Class containerAnnotation = + targetType.getDeclaredAnnotation(Asn1Class.class); + if ((containerAnnotation != null) + && (containerAnnotation.type() == Asn1Type.CHOICE)) { + return parseChoice(dataValue, targetType); + } + break; + } + default: + break; + } + + throw new Asn1DecodingException( + "Unsupported conversion: ASN.1 " + sourceType + " to " + targetType.getName()); + } + } +} diff --git a/platform/android/java/editor/src/main/java/com/android/apksig/internal/asn1/Asn1Class.java b/platform/android/java/editor/src/main/java/com/android/apksig/internal/asn1/Asn1Class.java new file mode 100644 index 0000000000..4841296c6b --- /dev/null +++ b/platform/android/java/editor/src/main/java/com/android/apksig/internal/asn1/Asn1Class.java @@ -0,0 +1,28 @@ +/* + * Copyright (C) 2017 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.apksig.internal.asn1; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Target({ElementType.TYPE}) +@Retention(RetentionPolicy.RUNTIME) +public @interface Asn1Class { + public Asn1Type type(); +} diff --git a/platform/android/java/editor/src/main/java/com/android/apksig/internal/asn1/Asn1DecodingException.java b/platform/android/java/editor/src/main/java/com/android/apksig/internal/asn1/Asn1DecodingException.java new file mode 100644 index 0000000000..07886429bf --- /dev/null +++ b/platform/android/java/editor/src/main/java/com/android/apksig/internal/asn1/Asn1DecodingException.java @@ -0,0 +1,32 @@ +/* + * Copyright (C) 2017 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.apksig.internal.asn1; + +/** + * Indicates that input could not be decoded into intended ASN.1 structure. + */ +public class Asn1DecodingException extends Exception { + private static final long serialVersionUID = 1L; + + public Asn1DecodingException(String message) { + super(message); + } + + public Asn1DecodingException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/platform/android/java/editor/src/main/java/com/android/apksig/internal/asn1/Asn1DerEncoder.java b/platform/android/java/editor/src/main/java/com/android/apksig/internal/asn1/Asn1DerEncoder.java new file mode 100644 index 0000000000..901f5f30c0 --- /dev/null +++ b/platform/android/java/editor/src/main/java/com/android/apksig/internal/asn1/Asn1DerEncoder.java @@ -0,0 +1,596 @@ +/* + * Copyright (C) 2017 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.apksig.internal.asn1; + +import com.android.apksig.internal.asn1.ber.BerEncoding; + +import java.io.ByteArrayOutputStream; +import java.lang.reflect.Field; +import java.lang.reflect.Modifier; +import java.math.BigInteger; +import java.nio.ByteBuffer; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.Comparator; +import java.util.List; + +/** + * Encoder of ASN.1 structures into DER-encoded form. + * + * <p>Structure is described to the encoder by providing a class annotated with {@link Asn1Class}, + * containing fields annotated with {@link Asn1Field}. + */ +public final class Asn1DerEncoder { + private Asn1DerEncoder() {} + + /** + * Returns the DER-encoded form of the provided ASN.1 structure. + * + * @param container container to be encoded. The container's class must meet the following + * requirements: + * <ul> + * <li>The class must be annotated with {@link Asn1Class}.</li> + * <li>Member fields of the class which are to be encoded must be annotated with + * {@link Asn1Field} and be public.</li> + * </ul> + * + * @throws Asn1EncodingException if the input could not be encoded + */ + public static byte[] encode(Object container) throws Asn1EncodingException { + Class<?> containerClass = container.getClass(); + Asn1Class containerAnnotation = containerClass.getDeclaredAnnotation(Asn1Class.class); + if (containerAnnotation == null) { + throw new Asn1EncodingException( + containerClass.getName() + " not annotated with " + Asn1Class.class.getName()); + } + + Asn1Type containerType = containerAnnotation.type(); + switch (containerType) { + case CHOICE: + return toChoice(container); + case SEQUENCE: + return toSequence(container); + case UNENCODED_CONTAINER: + return toSequence(container, true); + default: + throw new Asn1EncodingException("Unsupported container type: " + containerType); + } + } + + private static byte[] toChoice(Object container) throws Asn1EncodingException { + Class<?> containerClass = container.getClass(); + List<AnnotatedField> fields = getAnnotatedFields(container); + if (fields.isEmpty()) { + throw new Asn1EncodingException( + "No fields annotated with " + Asn1Field.class.getName() + + " in CHOICE class " + containerClass.getName()); + } + + AnnotatedField resultField = null; + for (AnnotatedField field : fields) { + Object fieldValue = getMemberFieldValue(container, field.getField()); + if (fieldValue != null) { + if (resultField != null) { + throw new Asn1EncodingException( + "Multiple non-null fields in CHOICE class " + containerClass.getName() + + ": " + resultField.getField().getName() + + ", " + field.getField().getName()); + } + resultField = field; + } + } + + if (resultField == null) { + throw new Asn1EncodingException( + "No non-null fields in CHOICE class " + containerClass.getName()); + } + + return resultField.toDer(); + } + + private static byte[] toSequence(Object container) throws Asn1EncodingException { + return toSequence(container, false); + } + + private static byte[] toSequence(Object container, boolean omitTag) + throws Asn1EncodingException { + Class<?> containerClass = container.getClass(); + List<AnnotatedField> fields = getAnnotatedFields(container); + Collections.sort( + fields, (f1, f2) -> f1.getAnnotation().index() - f2.getAnnotation().index()); + if (fields.size() > 1) { + AnnotatedField lastField = null; + for (AnnotatedField field : fields) { + if ((lastField != null) + && (lastField.getAnnotation().index() == field.getAnnotation().index())) { + throw new Asn1EncodingException( + "Fields have the same index: " + containerClass.getName() + + "." + lastField.getField().getName() + + " and ." + field.getField().getName()); + } + lastField = field; + } + } + + List<byte[]> serializedFields = new ArrayList<>(fields.size()); + int contentLen = 0; + for (AnnotatedField field : fields) { + byte[] serializedField; + try { + serializedField = field.toDer(); + } catch (Asn1EncodingException e) { + throw new Asn1EncodingException( + "Failed to encode " + containerClass.getName() + + "." + field.getField().getName(), + e); + } + if (serializedField != null) { + serializedFields.add(serializedField); + contentLen += serializedField.length; + } + } + + if (omitTag) { + byte[] unencodedResult = new byte[contentLen]; + int index = 0; + for (byte[] serializedField : serializedFields) { + System.arraycopy(serializedField, 0, unencodedResult, index, serializedField.length); + index += serializedField.length; + } + return unencodedResult; + } else { + return createTag( + BerEncoding.TAG_CLASS_UNIVERSAL, true, BerEncoding.TAG_NUMBER_SEQUENCE, + serializedFields.toArray(new byte[0][])); + } + } + + private static byte[] toSetOf(Collection<?> values, Asn1Type elementType) throws Asn1EncodingException { + return toSequenceOrSetOf(values, elementType, true); + } + + private static byte[] toSequenceOf(Collection<?> values, Asn1Type elementType) throws Asn1EncodingException { + return toSequenceOrSetOf(values, elementType, false); + } + + private static byte[] toSequenceOrSetOf(Collection<?> values, Asn1Type elementType, boolean toSet) + throws Asn1EncodingException { + List<byte[]> serializedValues = new ArrayList<>(values.size()); + for (Object value : values) { + serializedValues.add(JavaToDerConverter.toDer(value, elementType, null)); + } + int tagNumber; + if (toSet) { + if (serializedValues.size() > 1) { + Collections.sort(serializedValues, ByteArrayLexicographicComparator.INSTANCE); + } + tagNumber = BerEncoding.TAG_NUMBER_SET; + } else { + tagNumber = BerEncoding.TAG_NUMBER_SEQUENCE; + } + return createTag( + BerEncoding.TAG_CLASS_UNIVERSAL, true, tagNumber, + serializedValues.toArray(new byte[0][])); + } + + /** + * Compares two bytes arrays based on their lexicographic order. Corresponding elements of the + * two arrays are compared in ascending order. Elements at out of range indices are assumed to + * be smaller than the smallest possible value for an element. + */ + private static class ByteArrayLexicographicComparator implements Comparator<byte[]> { + private static final ByteArrayLexicographicComparator INSTANCE = + new ByteArrayLexicographicComparator(); + + @Override + public int compare(byte[] arr1, byte[] arr2) { + int commonLength = Math.min(arr1.length, arr2.length); + for (int i = 0; i < commonLength; i++) { + int diff = (arr1[i] & 0xff) - (arr2[i] & 0xff); + if (diff != 0) { + return diff; + } + } + return arr1.length - arr2.length; + } + } + + private static List<AnnotatedField> getAnnotatedFields(Object container) + throws Asn1EncodingException { + Class<?> containerClass = container.getClass(); + Field[] declaredFields = containerClass.getDeclaredFields(); + List<AnnotatedField> result = new ArrayList<>(declaredFields.length); + for (Field field : declaredFields) { + Asn1Field annotation = field.getDeclaredAnnotation(Asn1Field.class); + if (annotation == null) { + continue; + } + if (Modifier.isStatic(field.getModifiers())) { + throw new Asn1EncodingException( + Asn1Field.class.getName() + " used on a static field: " + + containerClass.getName() + "." + field.getName()); + } + + AnnotatedField annotatedField; + try { + annotatedField = new AnnotatedField(container, field, annotation); + } catch (Asn1EncodingException e) { + throw new Asn1EncodingException( + "Invalid ASN.1 annotation on " + + containerClass.getName() + "." + field.getName(), + e); + } + result.add(annotatedField); + } + return result; + } + + private static byte[] toInteger(int value) { + return toInteger((long) value); + } + + private static byte[] toInteger(long value) { + return toInteger(BigInteger.valueOf(value)); + } + + private static byte[] toInteger(BigInteger value) { + return createTag( + BerEncoding.TAG_CLASS_UNIVERSAL, false, BerEncoding.TAG_NUMBER_INTEGER, + value.toByteArray()); + } + + private static byte[] toBoolean(boolean value) { + // A boolean should be encoded in a single byte with a value of 0 for false and any non-zero + // value for true. + byte[] result = new byte[1]; + if (value == false) { + result[0] = 0; + } else { + result[0] = 1; + } + return createTag(BerEncoding.TAG_CLASS_UNIVERSAL, false, BerEncoding.TAG_NUMBER_BOOLEAN, result); + } + + private static byte[] toOid(String oid) throws Asn1EncodingException { + ByteArrayOutputStream encodedValue = new ByteArrayOutputStream(); + String[] nodes = oid.split("\\."); + if (nodes.length < 2) { + throw new Asn1EncodingException( + "OBJECT IDENTIFIER must contain at least two nodes: " + oid); + } + int firstNode; + try { + firstNode = Integer.parseInt(nodes[0]); + } catch (NumberFormatException e) { + throw new Asn1EncodingException("Node #1 not numeric: " + nodes[0]); + } + if ((firstNode > 6) || (firstNode < 0)) { + throw new Asn1EncodingException("Invalid value for node #1: " + firstNode); + } + + int secondNode; + try { + secondNode = Integer.parseInt(nodes[1]); + } catch (NumberFormatException e) { + throw new Asn1EncodingException("Node #2 not numeric: " + nodes[1]); + } + if ((secondNode >= 40) || (secondNode < 0)) { + throw new Asn1EncodingException("Invalid value for node #2: " + secondNode); + } + int firstByte = firstNode * 40 + secondNode; + if (firstByte > 0xff) { + throw new Asn1EncodingException( + "First two nodes out of range: " + firstNode + "." + secondNode); + } + + encodedValue.write(firstByte); + for (int i = 2; i < nodes.length; i++) { + String nodeString = nodes[i]; + int node; + try { + node = Integer.parseInt(nodeString); + } catch (NumberFormatException e) { + throw new Asn1EncodingException("Node #" + (i + 1) + " not numeric: " + nodeString); + } + if (node < 0) { + throw new Asn1EncodingException("Invalid value for node #" + (i + 1) + ": " + node); + } + if (node <= 0x7f) { + encodedValue.write(node); + continue; + } + if (node < 1 << 14) { + encodedValue.write(0x80 | (node >> 7)); + encodedValue.write(node & 0x7f); + continue; + } + if (node < 1 << 21) { + encodedValue.write(0x80 | (node >> 14)); + encodedValue.write(0x80 | ((node >> 7) & 0x7f)); + encodedValue.write(node & 0x7f); + continue; + } + throw new Asn1EncodingException("Node #" + (i + 1) + " too large: " + node); + } + + return createTag( + BerEncoding.TAG_CLASS_UNIVERSAL, false, BerEncoding.TAG_NUMBER_OBJECT_IDENTIFIER, + encodedValue.toByteArray()); + } + + private static Object getMemberFieldValue(Object obj, Field field) + throws Asn1EncodingException { + try { + return field.get(obj); + } catch (ReflectiveOperationException e) { + throw new Asn1EncodingException( + "Failed to read " + obj.getClass().getName() + "." + field.getName(), e); + } + } + + private static final class AnnotatedField { + private final Field mField; + private final Object mObject; + private final Asn1Field mAnnotation; + private final Asn1Type mDataType; + private final Asn1Type mElementDataType; + private final Asn1TagClass mTagClass; + private final int mDerTagClass; + private final int mDerTagNumber; + private final Asn1Tagging mTagging; + private final boolean mOptional; + + public AnnotatedField(Object obj, Field field, Asn1Field annotation) + throws Asn1EncodingException { + mObject = obj; + mField = field; + mAnnotation = annotation; + mDataType = annotation.type(); + mElementDataType = annotation.elementType(); + + Asn1TagClass tagClass = annotation.cls(); + if (tagClass == Asn1TagClass.AUTOMATIC) { + if (annotation.tagNumber() != -1) { + tagClass = Asn1TagClass.CONTEXT_SPECIFIC; + } else { + tagClass = Asn1TagClass.UNIVERSAL; + } + } + mTagClass = tagClass; + mDerTagClass = BerEncoding.getTagClass(mTagClass); + + int tagNumber; + if (annotation.tagNumber() != -1) { + tagNumber = annotation.tagNumber(); + } else if ((mDataType == Asn1Type.CHOICE) || (mDataType == Asn1Type.ANY)) { + tagNumber = -1; + } else { + tagNumber = BerEncoding.getTagNumber(mDataType); + } + mDerTagNumber = tagNumber; + + mTagging = annotation.tagging(); + if (((mTagging == Asn1Tagging.EXPLICIT) || (mTagging == Asn1Tagging.IMPLICIT)) + && (annotation.tagNumber() == -1)) { + throw new Asn1EncodingException( + "Tag number must be specified when tagging mode is " + mTagging); + } + + mOptional = annotation.optional(); + } + + public Field getField() { + return mField; + } + + public Asn1Field getAnnotation() { + return mAnnotation; + } + + public byte[] toDer() throws Asn1EncodingException { + Object fieldValue = getMemberFieldValue(mObject, mField); + if (fieldValue == null) { + if (mOptional) { + return null; + } + throw new Asn1EncodingException("Required field not set"); + } + + byte[] encoded = JavaToDerConverter.toDer(fieldValue, mDataType, mElementDataType); + switch (mTagging) { + case NORMAL: + return encoded; + case EXPLICIT: + return createTag(mDerTagClass, true, mDerTagNumber, encoded); + case IMPLICIT: + int originalTagNumber = BerEncoding.getTagNumber(encoded[0]); + if (originalTagNumber == 0x1f) { + throw new Asn1EncodingException("High-tag-number form not supported"); + } + if (mDerTagNumber >= 0x1f) { + throw new Asn1EncodingException( + "Unsupported high tag number: " + mDerTagNumber); + } + encoded[0] = BerEncoding.setTagNumber(encoded[0], mDerTagNumber); + encoded[0] = BerEncoding.setTagClass(encoded[0], mDerTagClass); + return encoded; + default: + throw new RuntimeException("Unknown tagging mode: " + mTagging); + } + } + } + + private static byte[] createTag( + int tagClass, boolean constructed, int tagNumber, byte[]... contents) { + if (tagNumber >= 0x1f) { + throw new IllegalArgumentException("High tag numbers not supported: " + tagNumber); + } + // tag class & number fit into the first byte + byte firstIdentifierByte = + (byte) ((tagClass << 6) | (constructed ? 1 << 5 : 0) | tagNumber); + + int contentsLength = 0; + for (byte[] c : contents) { + contentsLength += c.length; + } + int contentsPosInResult; + byte[] result; + if (contentsLength < 0x80) { + // Length fits into one byte + contentsPosInResult = 2; + result = new byte[contentsPosInResult + contentsLength]; + result[0] = firstIdentifierByte; + result[1] = (byte) contentsLength; + } else { + // Length is represented as multiple bytes + // The low 7 bits of the first byte represent the number of length bytes (following the + // first byte) in which the length is in big-endian base-256 form + if (contentsLength <= 0xff) { + contentsPosInResult = 3; + result = new byte[contentsPosInResult + contentsLength]; + result[1] = (byte) 0x81; // 1 length byte + result[2] = (byte) contentsLength; + } else if (contentsLength <= 0xffff) { + contentsPosInResult = 4; + result = new byte[contentsPosInResult + contentsLength]; + result[1] = (byte) 0x82; // 2 length bytes + result[2] = (byte) (contentsLength >> 8); + result[3] = (byte) (contentsLength & 0xff); + } else if (contentsLength <= 0xffffff) { + contentsPosInResult = 5; + result = new byte[contentsPosInResult + contentsLength]; + result[1] = (byte) 0x83; // 3 length bytes + result[2] = (byte) (contentsLength >> 16); + result[3] = (byte) ((contentsLength >> 8) & 0xff); + result[4] = (byte) (contentsLength & 0xff); + } else { + contentsPosInResult = 6; + result = new byte[contentsPosInResult + contentsLength]; + result[1] = (byte) 0x84; // 4 length bytes + result[2] = (byte) (contentsLength >> 24); + result[3] = (byte) ((contentsLength >> 16) & 0xff); + result[4] = (byte) ((contentsLength >> 8) & 0xff); + result[5] = (byte) (contentsLength & 0xff); + } + result[0] = firstIdentifierByte; + } + for (byte[] c : contents) { + System.arraycopy(c, 0, result, contentsPosInResult, c.length); + contentsPosInResult += c.length; + } + return result; + } + + private static final class JavaToDerConverter { + private JavaToDerConverter() {} + + public static byte[] toDer(Object source, Asn1Type targetType, Asn1Type targetElementType) + throws Asn1EncodingException { + Class<?> sourceType = source.getClass(); + if (Asn1OpaqueObject.class.equals(sourceType)) { + ByteBuffer buf = ((Asn1OpaqueObject) source).getEncoded(); + byte[] result = new byte[buf.remaining()]; + buf.get(result); + return result; + } + + if ((targetType == null) || (targetType == Asn1Type.ANY)) { + return encode(source); + } + + switch (targetType) { + case OCTET_STRING: + case BIT_STRING: + byte[] value = null; + if (source instanceof ByteBuffer) { + ByteBuffer buf = (ByteBuffer) source; + value = new byte[buf.remaining()]; + buf.slice().get(value); + } else if (source instanceof byte[]) { + value = (byte[]) source; + } + if (value != null) { + return createTag( + BerEncoding.TAG_CLASS_UNIVERSAL, + false, + BerEncoding.getTagNumber(targetType), + value); + } + break; + case INTEGER: + if (source instanceof Integer) { + return toInteger((Integer) source); + } else if (source instanceof Long) { + return toInteger((Long) source); + } else if (source instanceof BigInteger) { + return toInteger((BigInteger) source); + } + break; + case BOOLEAN: + if (source instanceof Boolean) { + return toBoolean((Boolean) (source)); + } + break; + case UTC_TIME: + case GENERALIZED_TIME: + if (source instanceof String) { + return createTag(BerEncoding.TAG_CLASS_UNIVERSAL, false, + BerEncoding.getTagNumber(targetType), ((String) source).getBytes()); + } + break; + case OBJECT_IDENTIFIER: + if (source instanceof String) { + return toOid((String) source); + } + break; + case SEQUENCE: + { + Asn1Class containerAnnotation = + sourceType.getDeclaredAnnotation(Asn1Class.class); + if ((containerAnnotation != null) + && (containerAnnotation.type() == Asn1Type.SEQUENCE)) { + return toSequence(source); + } + break; + } + case CHOICE: + { + Asn1Class containerAnnotation = + sourceType.getDeclaredAnnotation(Asn1Class.class); + if ((containerAnnotation != null) + && (containerAnnotation.type() == Asn1Type.CHOICE)) { + return toChoice(source); + } + break; + } + case SET_OF: + return toSetOf((Collection<?>) source, targetElementType); + case SEQUENCE_OF: + return toSequenceOf((Collection<?>) source, targetElementType); + default: + break; + } + + throw new Asn1EncodingException( + "Unsupported conversion: " + sourceType.getName() + " to ASN.1 " + targetType); + } + } + /** ASN.1 DER-encoded {@code NULL}. */ + public static final Asn1OpaqueObject ASN1_DER_NULL = + new Asn1OpaqueObject(new byte[] {BerEncoding.TAG_NUMBER_NULL, 0}); +} diff --git a/platform/android/java/editor/src/main/java/com/android/apksig/internal/asn1/Asn1EncodingException.java b/platform/android/java/editor/src/main/java/com/android/apksig/internal/asn1/Asn1EncodingException.java new file mode 100644 index 0000000000..0002c25cba --- /dev/null +++ b/platform/android/java/editor/src/main/java/com/android/apksig/internal/asn1/Asn1EncodingException.java @@ -0,0 +1,32 @@ +/* + * Copyright (C) 2017 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.apksig.internal.asn1; + +/** + * Indicates that an ASN.1 structure could not be encoded. + */ +public class Asn1EncodingException extends Exception { + private static final long serialVersionUID = 1L; + + public Asn1EncodingException(String message) { + super(message); + } + + public Asn1EncodingException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/platform/android/java/editor/src/main/java/com/android/apksig/internal/asn1/Asn1Field.java b/platform/android/java/editor/src/main/java/com/android/apksig/internal/asn1/Asn1Field.java new file mode 100644 index 0000000000..d2d3ce049e --- /dev/null +++ b/platform/android/java/editor/src/main/java/com/android/apksig/internal/asn1/Asn1Field.java @@ -0,0 +1,45 @@ +/* + * Copyright (C) 2017 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.apksig.internal.asn1; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Target({ElementType.FIELD}) +@Retention(RetentionPolicy.RUNTIME) +public @interface Asn1Field { + /** Index used to order fields in a container. Required for fields of SEQUENCE containers. */ + public int index() default 0; + + public Asn1TagClass cls() default Asn1TagClass.AUTOMATIC; + + public Asn1Type type(); + + /** Tagging mode. Default: NORMAL. */ + public Asn1Tagging tagging() default Asn1Tagging.NORMAL; + + /** Tag number. Required when IMPLICIT and EXPLICIT tagging mode is used.*/ + public int tagNumber() default -1; + + /** {@code true} if this field is optional. Ignored for fields of CHOICE containers. */ + public boolean optional() default false; + + /** Type of elements. Used only for SET_OF or SEQUENCE_OF. */ + public Asn1Type elementType() default Asn1Type.ANY; +} diff --git a/platform/android/java/editor/src/main/java/com/android/apksig/internal/asn1/Asn1OpaqueObject.java b/platform/android/java/editor/src/main/java/com/android/apksig/internal/asn1/Asn1OpaqueObject.java new file mode 100644 index 0000000000..672d0e74c6 --- /dev/null +++ b/platform/android/java/editor/src/main/java/com/android/apksig/internal/asn1/Asn1OpaqueObject.java @@ -0,0 +1,38 @@ +/* + * Copyright (C) 2017 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.apksig.internal.asn1; + +import java.nio.ByteBuffer; + +/** + * Opaque holder of encoded ASN.1 stuff. + */ +public class Asn1OpaqueObject { + private final ByteBuffer mEncoded; + + public Asn1OpaqueObject(ByteBuffer encoded) { + mEncoded = encoded.slice(); + } + + public Asn1OpaqueObject(byte[] encoded) { + mEncoded = ByteBuffer.wrap(encoded); + } + + public ByteBuffer getEncoded() { + return mEncoded.slice(); + } +} diff --git a/platform/android/java/editor/src/main/java/com/android/apksig/internal/asn1/Asn1TagClass.java b/platform/android/java/editor/src/main/java/com/android/apksig/internal/asn1/Asn1TagClass.java new file mode 100644 index 0000000000..6cdfcf014c --- /dev/null +++ b/platform/android/java/editor/src/main/java/com/android/apksig/internal/asn1/Asn1TagClass.java @@ -0,0 +1,30 @@ +/* + * Copyright (C) 2017 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.apksig.internal.asn1; + +public enum Asn1TagClass { + UNIVERSAL, + APPLICATION, + CONTEXT_SPECIFIC, + PRIVATE, + + /** + * Not really an actual tag class: decoder/encoder will attempt to deduce the correct tag class + * automatically. + */ + AUTOMATIC, +} diff --git a/platform/android/java/editor/src/main/java/com/android/apksig/internal/asn1/Asn1Tagging.java b/platform/android/java/editor/src/main/java/com/android/apksig/internal/asn1/Asn1Tagging.java new file mode 100644 index 0000000000..35fa3744e1 --- /dev/null +++ b/platform/android/java/editor/src/main/java/com/android/apksig/internal/asn1/Asn1Tagging.java @@ -0,0 +1,23 @@ +/* + * Copyright (C) 2017 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.apksig.internal.asn1; + +public enum Asn1Tagging { + NORMAL, + EXPLICIT, + IMPLICIT, +} diff --git a/platform/android/java/editor/src/main/java/com/android/apksig/internal/asn1/Asn1Type.java b/platform/android/java/editor/src/main/java/com/android/apksig/internal/asn1/Asn1Type.java new file mode 100644 index 0000000000..73006222b2 --- /dev/null +++ b/platform/android/java/editor/src/main/java/com/android/apksig/internal/asn1/Asn1Type.java @@ -0,0 +1,35 @@ +/* + * Copyright (C) 2017 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.apksig.internal.asn1; + +public enum Asn1Type { + ANY, + CHOICE, + INTEGER, + OBJECT_IDENTIFIER, + OCTET_STRING, + SEQUENCE, + SEQUENCE_OF, + SET_OF, + BIT_STRING, + UTC_TIME, + GENERALIZED_TIME, + BOOLEAN, + // This type can be used to annotate classes that encapsulate ASN.1 structures that are not + // classified as a SEQUENCE or SET. + UNENCODED_CONTAINER +} diff --git a/platform/android/java/editor/src/main/java/com/android/apksig/internal/asn1/ber/BerDataValue.java b/platform/android/java/editor/src/main/java/com/android/apksig/internal/asn1/ber/BerDataValue.java new file mode 100644 index 0000000000..f5604ffdfb --- /dev/null +++ b/platform/android/java/editor/src/main/java/com/android/apksig/internal/asn1/ber/BerDataValue.java @@ -0,0 +1,115 @@ +/* + * Copyright (C) 2017 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.apksig.internal.asn1.ber; + +import java.nio.ByteBuffer; + +/** + * ASN.1 Basic Encoding Rules (BER) data value -- see {@code X.690}. + */ +public class BerDataValue { + private final ByteBuffer mEncoded; + private final ByteBuffer mEncodedContents; + private final int mTagClass; + private final boolean mConstructed; + private final int mTagNumber; + + BerDataValue( + ByteBuffer encoded, + ByteBuffer encodedContents, + int tagClass, + boolean constructed, + int tagNumber) { + mEncoded = encoded; + mEncodedContents = encodedContents; + mTagClass = tagClass; + mConstructed = constructed; + mTagNumber = tagNumber; + } + + /** + * Returns the tag class of this data value. See {@link BerEncoding} {@code TAG_CLASS} + * constants. + */ + public int getTagClass() { + return mTagClass; + } + + /** + * Returns {@code true} if the content octets of this data value are the complete BER encoding + * of one or more data values, {@code false} if the content octets of this data value directly + * represent the value. + */ + public boolean isConstructed() { + return mConstructed; + } + + /** + * Returns the tag number of this data value. See {@link BerEncoding} {@code TAG_NUMBER} + * constants. + */ + public int getTagNumber() { + return mTagNumber; + } + + /** + * Returns the encoded form of this data value. + */ + public ByteBuffer getEncoded() { + return mEncoded.slice(); + } + + /** + * Returns the encoded contents of this data value. + */ + public ByteBuffer getEncodedContents() { + return mEncodedContents.slice(); + } + + /** + * Returns a new reader of the contents of this data value. + */ + public BerDataValueReader contentsReader() { + return new ByteBufferBerDataValueReader(getEncodedContents()); + } + + /** + * Returns a new reader which returns just this data value. This may be useful for re-reading + * this value in different contexts. + */ + public BerDataValueReader dataValueReader() { + return new ParsedValueReader(this); + } + + private static final class ParsedValueReader implements BerDataValueReader { + private final BerDataValue mValue; + private boolean mValueOutput; + + public ParsedValueReader(BerDataValue value) { + mValue = value; + } + + @Override + public BerDataValue readDataValue() throws BerDataValueFormatException { + if (mValueOutput) { + return null; + } + mValueOutput = true; + return mValue; + } + } +} diff --git a/platform/android/java/editor/src/main/java/com/android/apksig/internal/asn1/ber/BerDataValueFormatException.java b/platform/android/java/editor/src/main/java/com/android/apksig/internal/asn1/ber/BerDataValueFormatException.java new file mode 100644 index 0000000000..11ef6c3672 --- /dev/null +++ b/platform/android/java/editor/src/main/java/com/android/apksig/internal/asn1/ber/BerDataValueFormatException.java @@ -0,0 +1,34 @@ +/* + * Copyright (C) 2017 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.apksig.internal.asn1.ber; + +/** + * Indicates that an ASN.1 data value being read could not be decoded using + * Basic Encoding Rules (BER). + */ +public class BerDataValueFormatException extends Exception { + + private static final long serialVersionUID = 1L; + + public BerDataValueFormatException(String message) { + super(message); + } + + public BerDataValueFormatException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/platform/android/java/editor/src/main/java/com/android/apksig/internal/asn1/ber/BerDataValueReader.java b/platform/android/java/editor/src/main/java/com/android/apksig/internal/asn1/ber/BerDataValueReader.java new file mode 100644 index 0000000000..8da0a428be --- /dev/null +++ b/platform/android/java/editor/src/main/java/com/android/apksig/internal/asn1/ber/BerDataValueReader.java @@ -0,0 +1,34 @@ +/* + * Copyright (C) 2017 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.apksig.internal.asn1.ber; + +/** + * Reader of ASN.1 Basic Encoding Rules (BER) data values. + * + * <p>BER data value reader returns data values, one by one, from a source. The interpretation of + * data values (e.g., how to obtain a numeric value from an INTEGER data value, or how to extract + * the elements of a SEQUENCE value) is left to clients of the reader. + */ +public interface BerDataValueReader { + + /** + * Returns the next data value or {@code null} if end of input has been reached. + * + * @throws BerDataValueFormatException if the value being read is malformed. + */ + BerDataValue readDataValue() throws BerDataValueFormatException; +} diff --git a/platform/android/java/editor/src/main/java/com/android/apksig/internal/asn1/ber/BerEncoding.java b/platform/android/java/editor/src/main/java/com/android/apksig/internal/asn1/ber/BerEncoding.java new file mode 100644 index 0000000000..d32330c0ad --- /dev/null +++ b/platform/android/java/editor/src/main/java/com/android/apksig/internal/asn1/ber/BerEncoding.java @@ -0,0 +1,225 @@ +/* + * Copyright (C) 2017 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.apksig.internal.asn1.ber; + +import com.android.apksig.internal.asn1.Asn1Type; +import com.android.apksig.internal.asn1.Asn1TagClass; + +/** + * ASN.1 Basic Encoding Rules (BER) constants and helper methods. See {@code X.690}. + */ +public abstract class BerEncoding { + private BerEncoding() {} + + /** + * Constructed vs primitive flag in the first identifier byte. + */ + public static final int ID_FLAG_CONSTRUCTED_ENCODING = 1 << 5; + + /** + * Tag class: UNIVERSAL + */ + public static final int TAG_CLASS_UNIVERSAL = 0; + + /** + * Tag class: APPLICATION + */ + public static final int TAG_CLASS_APPLICATION = 1; + + /** + * Tag class: CONTEXT SPECIFIC + */ + public static final int TAG_CLASS_CONTEXT_SPECIFIC = 2; + + /** + * Tag class: PRIVATE + */ + public static final int TAG_CLASS_PRIVATE = 3; + + /** + * Tag number: BOOLEAN + */ + public static final int TAG_NUMBER_BOOLEAN = 0x1; + + /** + * Tag number: INTEGER + */ + public static final int TAG_NUMBER_INTEGER = 0x2; + + /** + * Tag number: BIT STRING + */ + public static final int TAG_NUMBER_BIT_STRING = 0x3; + + /** + * Tag number: OCTET STRING + */ + public static final int TAG_NUMBER_OCTET_STRING = 0x4; + + /** + * Tag number: NULL + */ + public static final int TAG_NUMBER_NULL = 0x05; + + /** + * Tag number: OBJECT IDENTIFIER + */ + public static final int TAG_NUMBER_OBJECT_IDENTIFIER = 0x6; + + /** + * Tag number: SEQUENCE + */ + public static final int TAG_NUMBER_SEQUENCE = 0x10; + + /** + * Tag number: SET + */ + public static final int TAG_NUMBER_SET = 0x11; + + /** + * Tag number: UTC_TIME + */ + public final static int TAG_NUMBER_UTC_TIME = 0x17; + + /** + * Tag number: GENERALIZED_TIME + */ + public final static int TAG_NUMBER_GENERALIZED_TIME = 0x18; + + public static int getTagNumber(Asn1Type dataType) { + switch (dataType) { + case INTEGER: + return TAG_NUMBER_INTEGER; + case OBJECT_IDENTIFIER: + return TAG_NUMBER_OBJECT_IDENTIFIER; + case OCTET_STRING: + return TAG_NUMBER_OCTET_STRING; + case BIT_STRING: + return TAG_NUMBER_BIT_STRING; + case SET_OF: + return TAG_NUMBER_SET; + case SEQUENCE: + case SEQUENCE_OF: + return TAG_NUMBER_SEQUENCE; + case UTC_TIME: + return TAG_NUMBER_UTC_TIME; + case GENERALIZED_TIME: + return TAG_NUMBER_GENERALIZED_TIME; + case BOOLEAN: + return TAG_NUMBER_BOOLEAN; + default: + throw new IllegalArgumentException("Unsupported data type: " + dataType); + } + } + + public static int getTagClass(Asn1TagClass tagClass) { + switch (tagClass) { + case APPLICATION: + return TAG_CLASS_APPLICATION; + case CONTEXT_SPECIFIC: + return TAG_CLASS_CONTEXT_SPECIFIC; + case PRIVATE: + return TAG_CLASS_PRIVATE; + case UNIVERSAL: + return TAG_CLASS_UNIVERSAL; + default: + throw new IllegalArgumentException("Unsupported tag class: " + tagClass); + } + } + + public static String tagClassToString(int typeClass) { + switch (typeClass) { + case TAG_CLASS_APPLICATION: + return "APPLICATION"; + case TAG_CLASS_CONTEXT_SPECIFIC: + return ""; + case TAG_CLASS_PRIVATE: + return "PRIVATE"; + case TAG_CLASS_UNIVERSAL: + return "UNIVERSAL"; + default: + throw new IllegalArgumentException("Unsupported type class: " + typeClass); + } + } + + public static String tagClassAndNumberToString(int tagClass, int tagNumber) { + String classString = tagClassToString(tagClass); + String numberString = tagNumberToString(tagNumber); + return classString.isEmpty() ? numberString : classString + " " + numberString; + } + + + public static String tagNumberToString(int tagNumber) { + switch (tagNumber) { + case TAG_NUMBER_INTEGER: + return "INTEGER"; + case TAG_NUMBER_OCTET_STRING: + return "OCTET STRING"; + case TAG_NUMBER_BIT_STRING: + return "BIT STRING"; + case TAG_NUMBER_NULL: + return "NULL"; + case TAG_NUMBER_OBJECT_IDENTIFIER: + return "OBJECT IDENTIFIER"; + case TAG_NUMBER_SEQUENCE: + return "SEQUENCE"; + case TAG_NUMBER_SET: + return "SET"; + case TAG_NUMBER_BOOLEAN: + return "BOOLEAN"; + case TAG_NUMBER_GENERALIZED_TIME: + return "GENERALIZED TIME"; + case TAG_NUMBER_UTC_TIME: + return "UTC TIME"; + default: + return "0x" + Integer.toHexString(tagNumber); + } + } + + /** + * Returns {@code true} if the provided first identifier byte indicates that the data value uses + * constructed encoding for its contents, or {@code false} if the data value uses primitive + * encoding for its contents. + */ + public static boolean isConstructed(byte firstIdentifierByte) { + return (firstIdentifierByte & ID_FLAG_CONSTRUCTED_ENCODING) != 0; + } + + /** + * Returns the tag class encoded in the provided first identifier byte. See {@code TAG_CLASS} + * constants. + */ + public static int getTagClass(byte firstIdentifierByte) { + return (firstIdentifierByte & 0xff) >> 6; + } + + public static byte setTagClass(byte firstIdentifierByte, int tagClass) { + return (byte) ((firstIdentifierByte & 0x3f) | (tagClass << 6)); + } + + /** + * Returns the tag number encoded in the provided first identifier byte. See {@code TAG_NUMBER} + * constants. + */ + public static int getTagNumber(byte firstIdentifierByte) { + return firstIdentifierByte & 0x1f; + } + + public static byte setTagNumber(byte firstIdentifierByte, int tagNumber) { + return (byte) ((firstIdentifierByte & ~0x1f) | tagNumber); + } +} diff --git a/platform/android/java/editor/src/main/java/com/android/apksig/internal/asn1/ber/ByteBufferBerDataValueReader.java b/platform/android/java/editor/src/main/java/com/android/apksig/internal/asn1/ber/ByteBufferBerDataValueReader.java new file mode 100644 index 0000000000..3fd5291f06 --- /dev/null +++ b/platform/android/java/editor/src/main/java/com/android/apksig/internal/asn1/ber/ByteBufferBerDataValueReader.java @@ -0,0 +1,208 @@ +/* + * Copyright (C) 2017 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.apksig.internal.asn1.ber; + +import java.nio.ByteBuffer; + +/** + * {@link BerDataValueReader} which reads from a {@link ByteBuffer} containing BER-encoded data + * values. See {@code X.690} for the encoding. + */ +public class ByteBufferBerDataValueReader implements BerDataValueReader { + private final ByteBuffer mBuf; + + public ByteBufferBerDataValueReader(ByteBuffer buf) { + if (buf == null) { + throw new NullPointerException("buf == null"); + } + mBuf = buf; + } + + @Override + public BerDataValue readDataValue() throws BerDataValueFormatException { + int startPosition = mBuf.position(); + if (!mBuf.hasRemaining()) { + return null; + } + byte firstIdentifierByte = mBuf.get(); + int tagNumber = readTagNumber(firstIdentifierByte); + boolean constructed = BerEncoding.isConstructed(firstIdentifierByte); + + if (!mBuf.hasRemaining()) { + throw new BerDataValueFormatException("Missing length"); + } + int firstLengthByte = mBuf.get() & 0xff; + int contentsLength; + int contentsOffsetInTag; + if ((firstLengthByte & 0x80) == 0) { + // short form length + contentsLength = readShortFormLength(firstLengthByte); + contentsOffsetInTag = mBuf.position() - startPosition; + skipDefiniteLengthContents(contentsLength); + } else if (firstLengthByte != 0x80) { + // long form length + contentsLength = readLongFormLength(firstLengthByte); + contentsOffsetInTag = mBuf.position() - startPosition; + skipDefiniteLengthContents(contentsLength); + } else { + // indefinite length -- value ends with 0x00 0x00 + contentsOffsetInTag = mBuf.position() - startPosition; + contentsLength = + constructed + ? skipConstructedIndefiniteLengthContents() + : skipPrimitiveIndefiniteLengthContents(); + } + + // Create the encoded data value ByteBuffer + int endPosition = mBuf.position(); + mBuf.position(startPosition); + int bufOriginalLimit = mBuf.limit(); + mBuf.limit(endPosition); + ByteBuffer encoded = mBuf.slice(); + mBuf.position(mBuf.limit()); + mBuf.limit(bufOriginalLimit); + + // Create the encoded contents ByteBuffer + encoded.position(contentsOffsetInTag); + encoded.limit(contentsOffsetInTag + contentsLength); + ByteBuffer encodedContents = encoded.slice(); + encoded.clear(); + + return new BerDataValue( + encoded, + encodedContents, + BerEncoding.getTagClass(firstIdentifierByte), + constructed, + tagNumber); + } + + private int readTagNumber(byte firstIdentifierByte) throws BerDataValueFormatException { + int tagNumber = BerEncoding.getTagNumber(firstIdentifierByte); + if (tagNumber == 0x1f) { + // high-tag-number form, where the tag number follows this byte in base-128 + // big-endian form, where each byte has the highest bit set, except for the last + // byte + return readHighTagNumber(); + } else { + // low-tag-number form + return tagNumber; + } + } + + private int readHighTagNumber() throws BerDataValueFormatException { + // Base-128 big-endian form, where each byte has the highest bit set, except for the last + // byte + int b; + int result = 0; + do { + if (!mBuf.hasRemaining()) { + throw new BerDataValueFormatException("Truncated tag number"); + } + b = mBuf.get(); + if (result > Integer.MAX_VALUE >>> 7) { + throw new BerDataValueFormatException("Tag number too large"); + } + result <<= 7; + result |= b & 0x7f; + } while ((b & 0x80) != 0); + return result; + } + + private int readShortFormLength(int firstLengthByte) { + return firstLengthByte & 0x7f; + } + + private int readLongFormLength(int firstLengthByte) throws BerDataValueFormatException { + // The low 7 bits of the first byte represent the number of bytes (following the first + // byte) in which the length is in big-endian base-256 form + int byteCount = firstLengthByte & 0x7f; + if (byteCount > 4) { + throw new BerDataValueFormatException("Length too large: " + byteCount + " bytes"); + } + int result = 0; + for (int i = 0; i < byteCount; i++) { + if (!mBuf.hasRemaining()) { + throw new BerDataValueFormatException("Truncated length"); + } + int b = mBuf.get(); + if (result > Integer.MAX_VALUE >>> 8) { + throw new BerDataValueFormatException("Length too large"); + } + result <<= 8; + result |= b & 0xff; + } + return result; + } + + private void skipDefiniteLengthContents(int contentsLength) throws BerDataValueFormatException { + if (mBuf.remaining() < contentsLength) { + throw new BerDataValueFormatException( + "Truncated contents. Need: " + contentsLength + " bytes, available: " + + mBuf.remaining()); + } + mBuf.position(mBuf.position() + contentsLength); + } + + private int skipPrimitiveIndefiniteLengthContents() throws BerDataValueFormatException { + // Contents are terminated by 0x00 0x00 + boolean prevZeroByte = false; + int bytesRead = 0; + while (true) { + if (!mBuf.hasRemaining()) { + throw new BerDataValueFormatException( + "Truncated indefinite-length contents: " + bytesRead + " bytes read"); + + } + int b = mBuf.get(); + bytesRead++; + if (bytesRead < 0) { + throw new BerDataValueFormatException("Indefinite-length contents too long"); + } + if (b == 0) { + if (prevZeroByte) { + // End of contents reached -- we've read the value and its terminator 0x00 0x00 + return bytesRead - 2; + } + prevZeroByte = true; + } else { + prevZeroByte = false; + } + } + } + + private int skipConstructedIndefiniteLengthContents() throws BerDataValueFormatException { + // Contents are terminated by 0x00 0x00. However, this data value is constructed, meaning it + // can contain data values which are themselves indefinite length encoded. As a result, we + // must parse the direct children of this data value to correctly skip over the contents of + // this data value. + int startPos = mBuf.position(); + while (mBuf.hasRemaining()) { + // Check whether the 0x00 0x00 terminator is at current position + if ((mBuf.remaining() > 1) && (mBuf.getShort(mBuf.position()) == 0)) { + int contentsLength = mBuf.position() - startPos; + mBuf.position(mBuf.position() + 2); + return contentsLength; + } + // No luck. This must be a BER-encoded data value -- skip over it by parsing it + readDataValue(); + } + + throw new BerDataValueFormatException( + "Truncated indefinite-length contents: " + + (mBuf.position() - startPos) + " bytes read"); + } +} diff --git a/platform/android/java/editor/src/main/java/com/android/apksig/internal/asn1/ber/InputStreamBerDataValueReader.java b/platform/android/java/editor/src/main/java/com/android/apksig/internal/asn1/ber/InputStreamBerDataValueReader.java new file mode 100644 index 0000000000..5fbca51db3 --- /dev/null +++ b/platform/android/java/editor/src/main/java/com/android/apksig/internal/asn1/ber/InputStreamBerDataValueReader.java @@ -0,0 +1,313 @@ +/* + * Copyright (C) 2017 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.apksig.internal.asn1.ber; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.nio.ByteBuffer; + +/** + * {@link BerDataValueReader} which reads from an {@link InputStream} returning BER-encoded data + * values. See {@code X.690} for the encoding. + */ +public class InputStreamBerDataValueReader implements BerDataValueReader { + private final InputStream mIn; + + public InputStreamBerDataValueReader(InputStream in) { + if (in == null) { + throw new NullPointerException("in == null"); + } + mIn = in; + } + + @Override + public BerDataValue readDataValue() throws BerDataValueFormatException { + return readDataValue(mIn); + } + + /** + * Returns the next data value or {@code null} if end of input has been reached. + * + * @throws BerDataValueFormatException if the value being read is malformed. + */ + @SuppressWarnings("resource") + private static BerDataValue readDataValue(InputStream input) + throws BerDataValueFormatException { + RecordingInputStream in = new RecordingInputStream(input); + + try { + int firstIdentifierByte = in.read(); + if (firstIdentifierByte == -1) { + // End of input + return null; + } + int tagNumber = readTagNumber(in, firstIdentifierByte); + + int firstLengthByte = in.read(); + if (firstLengthByte == -1) { + throw new BerDataValueFormatException("Missing length"); + } + + boolean constructed = BerEncoding.isConstructed((byte) firstIdentifierByte); + int contentsLength; + int contentsOffsetInDataValue; + if ((firstLengthByte & 0x80) == 0) { + // short form length + contentsLength = readShortFormLength(firstLengthByte); + contentsOffsetInDataValue = in.getReadByteCount(); + skipDefiniteLengthContents(in, contentsLength); + } else if ((firstLengthByte & 0xff) != 0x80) { + // long form length + contentsLength = readLongFormLength(in, firstLengthByte); + contentsOffsetInDataValue = in.getReadByteCount(); + skipDefiniteLengthContents(in, contentsLength); + } else { + // indefinite length + contentsOffsetInDataValue = in.getReadByteCount(); + contentsLength = + constructed + ? skipConstructedIndefiniteLengthContents(in) + : skipPrimitiveIndefiniteLengthContents(in); + } + + byte[] encoded = in.getReadBytes(); + ByteBuffer encodedContents = + ByteBuffer.wrap(encoded, contentsOffsetInDataValue, contentsLength); + return new BerDataValue( + ByteBuffer.wrap(encoded), + encodedContents, + BerEncoding.getTagClass((byte) firstIdentifierByte), + constructed, + tagNumber); + } catch (IOException e) { + throw new BerDataValueFormatException("Failed to read data value", e); + } + } + + private static int readTagNumber(InputStream in, int firstIdentifierByte) + throws IOException, BerDataValueFormatException { + int tagNumber = BerEncoding.getTagNumber((byte) firstIdentifierByte); + if (tagNumber == 0x1f) { + // high-tag-number form + return readHighTagNumber(in); + } else { + // low-tag-number form + return tagNumber; + } + } + + private static int readHighTagNumber(InputStream in) + throws IOException, BerDataValueFormatException { + // Base-128 big-endian form, where each byte has the highest bit set, except for the last + // byte where the highest bit is not set + int b; + int result = 0; + do { + b = in.read(); + if (b == -1) { + throw new BerDataValueFormatException("Truncated tag number"); + } + if (result > Integer.MAX_VALUE >>> 7) { + throw new BerDataValueFormatException("Tag number too large"); + } + result <<= 7; + result |= b & 0x7f; + } while ((b & 0x80) != 0); + return result; + } + + private static int readShortFormLength(int firstLengthByte) { + return firstLengthByte & 0x7f; + } + + private static int readLongFormLength(InputStream in, int firstLengthByte) + throws IOException, BerDataValueFormatException { + // The low 7 bits of the first byte represent the number of bytes (following the first + // byte) in which the length is in big-endian base-256 form + int byteCount = firstLengthByte & 0x7f; + if (byteCount > 4) { + throw new BerDataValueFormatException("Length too large: " + byteCount + " bytes"); + } + int result = 0; + for (int i = 0; i < byteCount; i++) { + int b = in.read(); + if (b == -1) { + throw new BerDataValueFormatException("Truncated length"); + } + if (result > Integer.MAX_VALUE >>> 8) { + throw new BerDataValueFormatException("Length too large"); + } + result <<= 8; + result |= b & 0xff; + } + return result; + } + + private static void skipDefiniteLengthContents(InputStream in, int len) + throws IOException, BerDataValueFormatException { + long bytesRead = 0; + while (len > 0) { + int skipped = (int) in.skip(len); + if (skipped <= 0) { + throw new BerDataValueFormatException( + "Truncated definite-length contents: " + bytesRead + " bytes read" + + ", " + len + " missing"); + } + len -= skipped; + bytesRead += skipped; + } + } + + private static int skipPrimitiveIndefiniteLengthContents(InputStream in) + throws IOException, BerDataValueFormatException { + // Contents are terminated by 0x00 0x00 + boolean prevZeroByte = false; + int bytesRead = 0; + while (true) { + int b = in.read(); + if (b == -1) { + throw new BerDataValueFormatException( + "Truncated indefinite-length contents: " + bytesRead + " bytes read"); + } + bytesRead++; + if (bytesRead < 0) { + throw new BerDataValueFormatException("Indefinite-length contents too long"); + } + if (b == 0) { + if (prevZeroByte) { + // End of contents reached -- we've read the value and its terminator 0x00 0x00 + return bytesRead - 2; + } + prevZeroByte = true; + continue; + } else { + prevZeroByte = false; + } + } + } + + private static int skipConstructedIndefiniteLengthContents(RecordingInputStream in) + throws BerDataValueFormatException { + // Contents are terminated by 0x00 0x00. However, this data value is constructed, meaning it + // can contain data values which are indefinite length encoded as well. As a result, we + // must parse the direct children of this data value to correctly skip over the contents of + // this data value. + int readByteCountBefore = in.getReadByteCount(); + while (true) { + // We can't easily peek for the 0x00 0x00 terminator using the provided InputStream. + // Thus, we use the fact that 0x00 0x00 parses as a data value whose encoded form we + // then check below to see whether it's 0x00 0x00. + BerDataValue dataValue = readDataValue(in); + if (dataValue == null) { + throw new BerDataValueFormatException( + "Truncated indefinite-length contents: " + + (in.getReadByteCount() - readByteCountBefore) + " bytes read"); + } + if (in.getReadByteCount() <= 0) { + throw new BerDataValueFormatException("Indefinite-length contents too long"); + } + ByteBuffer encoded = dataValue.getEncoded(); + if ((encoded.remaining() == 2) && (encoded.get(0) == 0) && (encoded.get(1) == 0)) { + // 0x00 0x00 encountered + return in.getReadByteCount() - readByteCountBefore - 2; + } + } + } + + private static class RecordingInputStream extends InputStream { + private final InputStream mIn; + private final ByteArrayOutputStream mBuf; + + private RecordingInputStream(InputStream in) { + mIn = in; + mBuf = new ByteArrayOutputStream(); + } + + public byte[] getReadBytes() { + return mBuf.toByteArray(); + } + + public int getReadByteCount() { + return mBuf.size(); + } + + @Override + public int read() throws IOException { + int b = mIn.read(); + if (b != -1) { + mBuf.write(b); + } + return b; + } + + @Override + public int read(byte[] b) throws IOException { + int len = mIn.read(b); + if (len > 0) { + mBuf.write(b, 0, len); + } + return len; + } + + @Override + public int read(byte[] b, int off, int len) throws IOException { + len = mIn.read(b, off, len); + if (len > 0) { + mBuf.write(b, off, len); + } + return len; + } + + @Override + public long skip(long n) throws IOException { + if (n <= 0) { + return mIn.skip(n); + } + + byte[] buf = new byte[4096]; + int len = mIn.read(buf, 0, (int) Math.min(buf.length, n)); + if (len > 0) { + mBuf.write(buf, 0, len); + } + return (len < 0) ? 0 : len; + } + + @Override + public int available() throws IOException { + return super.available(); + } + + @Override + public void close() throws IOException { + super.close(); + } + + @Override + public synchronized void mark(int readlimit) {} + + @Override + public synchronized void reset() throws IOException { + throw new IOException("mark/reset not supported"); + } + + @Override + public boolean markSupported() { + return false; + } + } +} diff --git a/platform/android/java/editor/src/main/java/com/android/apksig/internal/jar/ManifestParser.java b/platform/android/java/editor/src/main/java/com/android/apksig/internal/jar/ManifestParser.java new file mode 100644 index 0000000000..ab0a5dad8a --- /dev/null +++ b/platform/android/java/editor/src/main/java/com/android/apksig/internal/jar/ManifestParser.java @@ -0,0 +1,363 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.apksig.internal.jar; + +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.jar.Attributes; + +/** + * JAR manifest and signature file parser. + * + * <p>These files consist of a main section followed by individual sections. Individual sections + * are named, their names referring to JAR entries. + * + * @see <a href="https://docs.oracle.com/javase/8/docs/technotes/guides/jar/jar.html#JAR_Manifest">JAR Manifest format</a> + */ +public class ManifestParser { + + private final byte[] mManifest; + private int mOffset; + private int mEndOffset; + + private byte[] mBufferedLine; + + /** + * Constructs a new {@code ManifestParser} with the provided input. + */ + public ManifestParser(byte[] data) { + this(data, 0, data.length); + } + + /** + * Constructs a new {@code ManifestParser} with the provided input. + */ + public ManifestParser(byte[] data, int offset, int length) { + mManifest = data; + mOffset = offset; + mEndOffset = offset + length; + } + + /** + * Returns the remaining sections of this file. + */ + public List<Section> readAllSections() { + List<Section> sections = new ArrayList<>(); + Section section; + while ((section = readSection()) != null) { + sections.add(section); + } + return sections; + } + + /** + * Returns the next section from this file or {@code null} if end of file has been reached. + */ + public Section readSection() { + // Locate the first non-empty line + int sectionStartOffset; + String attr; + do { + sectionStartOffset = mOffset; + attr = readAttribute(); + if (attr == null) { + return null; + } + } while (attr.length() == 0); + List<Attribute> attrs = new ArrayList<>(); + attrs.add(parseAttr(attr)); + + // Read attributes until end of section reached + while (true) { + attr = readAttribute(); + if ((attr == null) || (attr.length() == 0)) { + // End of section + break; + } + attrs.add(parseAttr(attr)); + } + + int sectionEndOffset = mOffset; + int sectionSizeBytes = sectionEndOffset - sectionStartOffset; + + return new Section(sectionStartOffset, sectionSizeBytes, attrs); + } + + private static Attribute parseAttr(String attr) { + // Name is separated from value by a semicolon followed by a single SPACE character. + // This permits trailing spaces in names and leading and trailing spaces in values. + // Some APK obfuscators take advantage of this fact. We thus need to preserve these unusual + // spaces to be able to parse such obfuscated APKs. + int delimiterIndex = attr.indexOf(": "); + if (delimiterIndex == -1) { + return new Attribute(attr, ""); + } else { + return new Attribute( + attr.substring(0, delimiterIndex), + attr.substring(delimiterIndex + ": ".length())); + } + } + + /** + * Returns the next attribute or empty {@code String} if end of section has been reached or + * {@code null} if end of input has been reached. + */ + private String readAttribute() { + byte[] bytes = readAttributeBytes(); + if (bytes == null) { + return null; + } else if (bytes.length == 0) { + return ""; + } else { + return new String(bytes, StandardCharsets.UTF_8); + } + } + + /** + * Returns the next attribute or empty array if end of section has been reached or {@code null} + * if end of input has been reached. + */ + private byte[] readAttributeBytes() { + // Check whether end of section was reached during previous invocation + if ((mBufferedLine != null) && (mBufferedLine.length == 0)) { + mBufferedLine = null; + return EMPTY_BYTE_ARRAY; + } + + // Read the next line + byte[] line = readLine(); + if (line == null) { + // End of input + if (mBufferedLine != null) { + byte[] result = mBufferedLine; + mBufferedLine = null; + return result; + } + return null; + } + + // Consume the read line + if (line.length == 0) { + // End of section + if (mBufferedLine != null) { + byte[] result = mBufferedLine; + mBufferedLine = EMPTY_BYTE_ARRAY; + return result; + } + return EMPTY_BYTE_ARRAY; + } + byte[] attrLine; + if (mBufferedLine == null) { + attrLine = line; + } else { + if ((line.length == 0) || (line[0] != ' ')) { + // The most common case: buffered line is a full attribute + byte[] result = mBufferedLine; + mBufferedLine = line; + return result; + } + attrLine = mBufferedLine; + mBufferedLine = null; + attrLine = concat(attrLine, line, 1, line.length - 1); + } + + // Everything's buffered in attrLine now. mBufferedLine is null + + // Read more lines + while (true) { + line = readLine(); + if (line == null) { + // End of input + return attrLine; + } else if (line.length == 0) { + // End of section + mBufferedLine = EMPTY_BYTE_ARRAY; // return "end of section" next time + return attrLine; + } + if (line[0] == ' ') { + // Continuation line + attrLine = concat(attrLine, line, 1, line.length - 1); + } else { + // Next attribute + mBufferedLine = line; + return attrLine; + } + } + } + + private static final byte[] EMPTY_BYTE_ARRAY = new byte[0]; + + private static byte[] concat(byte[] arr1, byte[] arr2, int offset2, int length2) { + byte[] result = new byte[arr1.length + length2]; + System.arraycopy(arr1, 0, result, 0, arr1.length); + System.arraycopy(arr2, offset2, result, arr1.length, length2); + return result; + } + + /** + * Returns the next line (without line delimiter characters) or {@code null} if end of input has + * been reached. + */ + private byte[] readLine() { + if (mOffset >= mEndOffset) { + return null; + } + int startOffset = mOffset; + int newlineStartOffset = -1; + int newlineEndOffset = -1; + for (int i = startOffset; i < mEndOffset; i++) { + byte b = mManifest[i]; + if (b == '\r') { + newlineStartOffset = i; + int nextIndex = i + 1; + if ((nextIndex < mEndOffset) && (mManifest[nextIndex] == '\n')) { + newlineEndOffset = nextIndex + 1; + break; + } + newlineEndOffset = nextIndex; + break; + } else if (b == '\n') { + newlineStartOffset = i; + newlineEndOffset = i + 1; + break; + } + } + if (newlineStartOffset == -1) { + newlineStartOffset = mEndOffset; + newlineEndOffset = mEndOffset; + } + mOffset = newlineEndOffset; + + if (newlineStartOffset == startOffset) { + return EMPTY_BYTE_ARRAY; + } + return Arrays.copyOfRange(mManifest, startOffset, newlineStartOffset); + } + + + /** + * Attribute. + */ + public static class Attribute { + private final String mName; + private final String mValue; + + /** + * Constructs a new {@code Attribute} with the provided name and value. + */ + public Attribute(String name, String value) { + mName = name; + mValue = value; + } + + /** + * Returns this attribute's name. + */ + public String getName() { + return mName; + } + + /** + * Returns this attribute's value. + */ + public String getValue() { + return mValue; + } + } + + /** + * Section. + */ + public static class Section { + private final int mStartOffset; + private final int mSizeBytes; + private final String mName; + private final List<Attribute> mAttributes; + + /** + * Constructs a new {@code Section}. + * + * @param startOffset start offset (in bytes) of the section in the input file + * @param sizeBytes size (in bytes) of the section in the input file + * @param attrs attributes contained in the section + */ + public Section(int startOffset, int sizeBytes, List<Attribute> attrs) { + mStartOffset = startOffset; + mSizeBytes = sizeBytes; + String sectionName = null; + if (!attrs.isEmpty()) { + Attribute firstAttr = attrs.get(0); + if ("Name".equalsIgnoreCase(firstAttr.getName())) { + sectionName = firstAttr.getValue(); + } + } + mName = sectionName; + mAttributes = Collections.unmodifiableList(new ArrayList<>(attrs)); + } + + public String getName() { + return mName; + } + + /** + * Returns the offset (in bytes) at which this section starts in the input. + */ + public int getStartOffset() { + return mStartOffset; + } + + /** + * Returns the size (in bytes) of this section in the input. + */ + public int getSizeBytes() { + return mSizeBytes; + } + + /** + * Returns this section's attributes, in the order in which they appear in the input. + */ + public List<Attribute> getAttributes() { + return mAttributes; + } + + /** + * Returns the value of the specified attribute in this section or {@code null} if this + * section does not contain a matching attribute. + */ + public String getAttributeValue(Attributes.Name name) { + return getAttributeValue(name.toString()); + } + + /** + * Returns the value of the specified attribute in this section or {@code null} if this + * section does not contain a matching attribute. + * + * @param name name of the attribute. Attribute names are case-insensitive. + */ + public String getAttributeValue(String name) { + for (Attribute attr : mAttributes) { + if (attr.getName().equalsIgnoreCase(name)) { + return attr.getValue(); + } + } + return null; + } + } +} diff --git a/platform/android/java/editor/src/main/java/com/android/apksig/internal/jar/ManifestWriter.java b/platform/android/java/editor/src/main/java/com/android/apksig/internal/jar/ManifestWriter.java new file mode 100644 index 0000000000..fa01beb7b7 --- /dev/null +++ b/platform/android/java/editor/src/main/java/com/android/apksig/internal/jar/ManifestWriter.java @@ -0,0 +1,127 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.apksig.internal.jar; + +import java.io.IOException; +import java.io.OutputStream; +import java.nio.charset.StandardCharsets; +import java.util.Map; +import java.util.Set; +import java.util.SortedMap; +import java.util.TreeMap; +import java.util.jar.Attributes; + +/** + * Producer of {@code META-INF/MANIFEST.MF} file. + * + * @see <a href="https://docs.oracle.com/javase/8/docs/technotes/guides/jar/jar.html#JAR_Manifest">JAR Manifest format</a> + */ +public abstract class ManifestWriter { + + private static final byte[] CRLF = new byte[] {'\r', '\n'}; + private static final int MAX_LINE_LENGTH = 70; + + private ManifestWriter() {} + + public static void writeMainSection(OutputStream out, Attributes attributes) + throws IOException { + + // Main section must start with the Manifest-Version attribute. + // See https://docs.oracle.com/javase/8/docs/technotes/guides/jar/jar.html#Signed_JAR_File. + String manifestVersion = attributes.getValue(Attributes.Name.MANIFEST_VERSION); + if (manifestVersion == null) { + throw new IllegalArgumentException( + "Mandatory " + Attributes.Name.MANIFEST_VERSION + " attribute missing"); + } + writeAttribute(out, Attributes.Name.MANIFEST_VERSION, manifestVersion); + + if (attributes.size() > 1) { + SortedMap<String, String> namedAttributes = getAttributesSortedByName(attributes); + namedAttributes.remove(Attributes.Name.MANIFEST_VERSION.toString()); + writeAttributes(out, namedAttributes); + } + writeSectionDelimiter(out); + } + + public static void writeIndividualSection(OutputStream out, String name, Attributes attributes) + throws IOException { + writeAttribute(out, "Name", name); + + if (!attributes.isEmpty()) { + writeAttributes(out, getAttributesSortedByName(attributes)); + } + writeSectionDelimiter(out); + } + + static void writeSectionDelimiter(OutputStream out) throws IOException { + out.write(CRLF); + } + + static void writeAttribute(OutputStream out, Attributes.Name name, String value) + throws IOException { + writeAttribute(out, name.toString(), value); + } + + private static void writeAttribute(OutputStream out, String name, String value) + throws IOException { + writeLine(out, name + ": " + value); + } + + private static void writeLine(OutputStream out, String line) throws IOException { + byte[] lineBytes = line.getBytes(StandardCharsets.UTF_8); + int offset = 0; + int remaining = lineBytes.length; + boolean firstLine = true; + while (remaining > 0) { + int chunkLength; + if (firstLine) { + // First line + chunkLength = Math.min(remaining, MAX_LINE_LENGTH); + } else { + // Continuation line + out.write(CRLF); + out.write(' '); + chunkLength = Math.min(remaining, MAX_LINE_LENGTH - 1); + } + out.write(lineBytes, offset, chunkLength); + offset += chunkLength; + remaining -= chunkLength; + firstLine = false; + } + out.write(CRLF); + } + + static SortedMap<String, String> getAttributesSortedByName(Attributes attributes) { + Set<Map.Entry<Object, Object>> attributesEntries = attributes.entrySet(); + SortedMap<String, String> namedAttributes = new TreeMap<String, String>(); + for (Map.Entry<Object, Object> attribute : attributesEntries) { + String attrName = attribute.getKey().toString(); + String attrValue = attribute.getValue().toString(); + namedAttributes.put(attrName, attrValue); + } + return namedAttributes; + } + + static void writeAttributes( + OutputStream out, SortedMap<String, String> attributesSortedByName) throws IOException { + for (Map.Entry<String, String> attribute : attributesSortedByName.entrySet()) { + String attrName = attribute.getKey(); + String attrValue = attribute.getValue(); + writeAttribute(out, attrName, attrValue); + } + } +} diff --git a/platform/android/java/editor/src/main/java/com/android/apksig/internal/jar/SignatureFileWriter.java b/platform/android/java/editor/src/main/java/com/android/apksig/internal/jar/SignatureFileWriter.java new file mode 100644 index 0000000000..fd8cbff8dc --- /dev/null +++ b/platform/android/java/editor/src/main/java/com/android/apksig/internal/jar/SignatureFileWriter.java @@ -0,0 +1,61 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.apksig.internal.jar; + +import java.io.IOException; +import java.io.OutputStream; +import java.util.SortedMap; +import java.util.jar.Attributes; + +/** + * Producer of JAR signature file ({@code *.SF}). + * + * @see <a href="https://docs.oracle.com/javase/8/docs/technotes/guides/jar/jar.html#JAR_Manifest">JAR Manifest format</a> + */ +public abstract class SignatureFileWriter { + private SignatureFileWriter() {} + + public static void writeMainSection(OutputStream out, Attributes attributes) + throws IOException { + + // Main section must start with the Signature-Version attribute. + // See https://docs.oracle.com/javase/8/docs/technotes/guides/jar/jar.html#Signed_JAR_File. + String signatureVersion = attributes.getValue(Attributes.Name.SIGNATURE_VERSION); + if (signatureVersion == null) { + throw new IllegalArgumentException( + "Mandatory " + Attributes.Name.SIGNATURE_VERSION + " attribute missing"); + } + ManifestWriter.writeAttribute(out, Attributes.Name.SIGNATURE_VERSION, signatureVersion); + + if (attributes.size() > 1) { + SortedMap<String, String> namedAttributes = + ManifestWriter.getAttributesSortedByName(attributes); + namedAttributes.remove(Attributes.Name.SIGNATURE_VERSION.toString()); + ManifestWriter.writeAttributes(out, namedAttributes); + } + writeSectionDelimiter(out); + } + + public static void writeIndividualSection(OutputStream out, String name, Attributes attributes) + throws IOException { + ManifestWriter.writeIndividualSection(out, name, attributes); + } + + public static void writeSectionDelimiter(OutputStream out) throws IOException { + ManifestWriter.writeSectionDelimiter(out); + } +} diff --git a/platform/android/java/editor/src/main/java/com/android/apksig/internal/oid/OidConstants.java b/platform/android/java/editor/src/main/java/com/android/apksig/internal/oid/OidConstants.java new file mode 100644 index 0000000000..d80cbaa6ee --- /dev/null +++ b/platform/android/java/editor/src/main/java/com/android/apksig/internal/oid/OidConstants.java @@ -0,0 +1,463 @@ +/* + * Copyright (C) 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.apksig.internal.oid; + +import com.android.apksig.internal.util.InclusiveIntRange; + +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +public class OidConstants { + public static final String OID_DIGEST_MD5 = "1.2.840.113549.2.5"; + public static final String OID_DIGEST_SHA1 = "1.3.14.3.2.26"; + public static final String OID_DIGEST_SHA224 = "2.16.840.1.101.3.4.2.4"; + public static final String OID_DIGEST_SHA256 = "2.16.840.1.101.3.4.2.1"; + public static final String OID_DIGEST_SHA384 = "2.16.840.1.101.3.4.2.2"; + public static final String OID_DIGEST_SHA512 = "2.16.840.1.101.3.4.2.3"; + + public static final String OID_SIG_RSA = "1.2.840.113549.1.1.1"; + public static final String OID_SIG_MD5_WITH_RSA = "1.2.840.113549.1.1.4"; + public static final String OID_SIG_SHA1_WITH_RSA = "1.2.840.113549.1.1.5"; + public static final String OID_SIG_SHA224_WITH_RSA = "1.2.840.113549.1.1.14"; + public static final String OID_SIG_SHA256_WITH_RSA = "1.2.840.113549.1.1.11"; + public static final String OID_SIG_SHA384_WITH_RSA = "1.2.840.113549.1.1.12"; + public static final String OID_SIG_SHA512_WITH_RSA = "1.2.840.113549.1.1.13"; + + public static final String OID_SIG_DSA = "1.2.840.10040.4.1"; + public static final String OID_SIG_SHA1_WITH_DSA = "1.2.840.10040.4.3"; + public static final String OID_SIG_SHA224_WITH_DSA = "2.16.840.1.101.3.4.3.1"; + public static final String OID_SIG_SHA256_WITH_DSA = "2.16.840.1.101.3.4.3.2"; + public static final String OID_SIG_SHA384_WITH_DSA = "2.16.840.1.101.3.4.3.3"; + public static final String OID_SIG_SHA512_WITH_DSA = "2.16.840.1.101.3.4.3.4"; + + public static final String OID_SIG_EC_PUBLIC_KEY = "1.2.840.10045.2.1"; + public static final String OID_SIG_SHA1_WITH_ECDSA = "1.2.840.10045.4.1"; + public static final String OID_SIG_SHA224_WITH_ECDSA = "1.2.840.10045.4.3.1"; + public static final String OID_SIG_SHA256_WITH_ECDSA = "1.2.840.10045.4.3.2"; + public static final String OID_SIG_SHA384_WITH_ECDSA = "1.2.840.10045.4.3.3"; + public static final String OID_SIG_SHA512_WITH_ECDSA = "1.2.840.10045.4.3.4"; + + public static final Map<String, List<InclusiveIntRange>> SUPPORTED_SIG_ALG_OIDS = + new HashMap<>(); + static { + addSupportedSigAlg( + OID_DIGEST_MD5, OID_SIG_RSA, + InclusiveIntRange.from(0)); + addSupportedSigAlg( + OID_DIGEST_MD5, OID_SIG_MD5_WITH_RSA, + InclusiveIntRange.fromTo(0, 8), InclusiveIntRange.from(21)); + addSupportedSigAlg( + OID_DIGEST_MD5, OID_SIG_SHA1_WITH_RSA, + InclusiveIntRange.fromTo(21, 23)); + addSupportedSigAlg( + OID_DIGEST_MD5, OID_SIG_SHA224_WITH_RSA, + InclusiveIntRange.fromTo(21, 23)); + addSupportedSigAlg( + OID_DIGEST_MD5, OID_SIG_SHA256_WITH_RSA, + InclusiveIntRange.fromTo(21, 23)); + addSupportedSigAlg( + OID_DIGEST_MD5, OID_SIG_SHA384_WITH_RSA, + InclusiveIntRange.fromTo(21, 23)); + addSupportedSigAlg( + OID_DIGEST_MD5, OID_SIG_SHA512_WITH_RSA, + InclusiveIntRange.fromTo(21, 23)); + + addSupportedSigAlg( + OID_DIGEST_SHA1, OID_SIG_RSA, + InclusiveIntRange.from(0)); + addSupportedSigAlg( + OID_DIGEST_SHA1, OID_SIG_MD5_WITH_RSA, + InclusiveIntRange.fromTo(21, 23)); + addSupportedSigAlg( + OID_DIGEST_SHA1, OID_SIG_SHA1_WITH_RSA, + InclusiveIntRange.from(0)); + addSupportedSigAlg( + OID_DIGEST_SHA1, OID_SIG_SHA224_WITH_RSA, + InclusiveIntRange.fromTo(21, 23)); + addSupportedSigAlg( + OID_DIGEST_SHA1, OID_SIG_SHA256_WITH_RSA, + InclusiveIntRange.fromTo(21, 23)); + addSupportedSigAlg( + OID_DIGEST_SHA1, OID_SIG_SHA384_WITH_RSA, + InclusiveIntRange.fromTo(21, 23)); + addSupportedSigAlg( + OID_DIGEST_SHA1, OID_SIG_SHA512_WITH_RSA, + InclusiveIntRange.fromTo(21, 23)); + + addSupportedSigAlg( + OID_DIGEST_SHA224, OID_SIG_RSA, + InclusiveIntRange.fromTo(0, 8), InclusiveIntRange.from(21)); + addSupportedSigAlg( + OID_DIGEST_SHA224, OID_SIG_MD5_WITH_RSA, + InclusiveIntRange.fromTo(21, 23)); + addSupportedSigAlg( + OID_DIGEST_SHA224, OID_SIG_SHA1_WITH_RSA, + InclusiveIntRange.fromTo(21, 23)); + addSupportedSigAlg( + OID_DIGEST_SHA224, OID_SIG_SHA224_WITH_RSA, + InclusiveIntRange.fromTo(0, 8), InclusiveIntRange.from(21)); + addSupportedSigAlg( + OID_DIGEST_SHA224, OID_SIG_SHA256_WITH_RSA, + InclusiveIntRange.fromTo(21, 21)); + addSupportedSigAlg( + OID_DIGEST_SHA224, OID_SIG_SHA384_WITH_RSA, + InclusiveIntRange.fromTo(21, 23)); + addSupportedSigAlg( + OID_DIGEST_SHA224, OID_SIG_SHA512_WITH_RSA, + InclusiveIntRange.fromTo(21, 23)); + + addSupportedSigAlg( + OID_DIGEST_SHA256, OID_SIG_RSA, + InclusiveIntRange.fromTo(0, 8), InclusiveIntRange.from(18)); + addSupportedSigAlg( + OID_DIGEST_SHA256, OID_SIG_MD5_WITH_RSA, + InclusiveIntRange.fromTo(21, 23)); + addSupportedSigAlg( + OID_DIGEST_SHA256, OID_SIG_SHA1_WITH_RSA, + InclusiveIntRange.fromTo(21, 21)); + addSupportedSigAlg( + OID_DIGEST_SHA256, OID_SIG_SHA224_WITH_RSA, + InclusiveIntRange.fromTo(21, 23)); + addSupportedSigAlg( + OID_DIGEST_SHA256, OID_SIG_SHA256_WITH_RSA, + InclusiveIntRange.fromTo(0, 8), InclusiveIntRange.from(18)); + addSupportedSigAlg( + OID_DIGEST_SHA256, OID_SIG_SHA384_WITH_RSA, + InclusiveIntRange.fromTo(21, 23)); + addSupportedSigAlg( + OID_DIGEST_SHA256, OID_SIG_SHA512_WITH_RSA, + InclusiveIntRange.fromTo(21, 23)); + + addSupportedSigAlg( + OID_DIGEST_SHA384, OID_SIG_RSA, + InclusiveIntRange.from(18)); + addSupportedSigAlg( + OID_DIGEST_SHA384, OID_SIG_MD5_WITH_RSA, + InclusiveIntRange.fromTo(21, 23)); + addSupportedSigAlg( + OID_DIGEST_SHA384, OID_SIG_SHA1_WITH_RSA, + InclusiveIntRange.fromTo(21, 23)); + addSupportedSigAlg( + OID_DIGEST_SHA384, OID_SIG_SHA224_WITH_RSA, + InclusiveIntRange.fromTo(21, 23)); + addSupportedSigAlg( + OID_DIGEST_SHA384, OID_SIG_SHA256_WITH_RSA, + InclusiveIntRange.fromTo(21, 23)); + addSupportedSigAlg( + OID_DIGEST_SHA384, OID_SIG_SHA384_WITH_RSA, + InclusiveIntRange.from(21)); + addSupportedSigAlg( + OID_DIGEST_SHA384, OID_SIG_SHA512_WITH_RSA, + InclusiveIntRange.fromTo(21, 23)); + + addSupportedSigAlg( + OID_DIGEST_SHA512, OID_SIG_RSA, + InclusiveIntRange.from(18)); + addSupportedSigAlg( + OID_DIGEST_SHA512, OID_SIG_MD5_WITH_RSA, + InclusiveIntRange.fromTo(21, 23)); + addSupportedSigAlg( + OID_DIGEST_SHA512, OID_SIG_SHA1_WITH_RSA, + InclusiveIntRange.fromTo(21, 23)); + addSupportedSigAlg( + OID_DIGEST_SHA512, OID_SIG_SHA224_WITH_RSA, + InclusiveIntRange.fromTo(21, 23)); + addSupportedSigAlg( + OID_DIGEST_SHA512, OID_SIG_SHA256_WITH_RSA, + InclusiveIntRange.fromTo(21, 23)); + addSupportedSigAlg( + OID_DIGEST_SHA512, OID_SIG_SHA384_WITH_RSA, + InclusiveIntRange.fromTo(21, 21)); + addSupportedSigAlg( + OID_DIGEST_SHA512, OID_SIG_SHA512_WITH_RSA, + InclusiveIntRange.from(21)); + + addSupportedSigAlg( + OID_DIGEST_MD5, OID_SIG_SHA1_WITH_DSA, + InclusiveIntRange.fromTo(21, 23)); + addSupportedSigAlg( + OID_DIGEST_MD5, OID_SIG_SHA224_WITH_DSA, + InclusiveIntRange.fromTo(21, 23)); + addSupportedSigAlg( + OID_DIGEST_MD5, OID_SIG_SHA256_WITH_DSA, + InclusiveIntRange.fromTo(21, 23)); + + addSupportedSigAlg( + OID_DIGEST_SHA1, OID_SIG_DSA, + InclusiveIntRange.from(0)); + addSupportedSigAlg( + OID_DIGEST_SHA1, OID_SIG_SHA1_WITH_DSA, + InclusiveIntRange.from(9)); + addSupportedSigAlg( + OID_DIGEST_SHA1, OID_SIG_SHA224_WITH_DSA, + InclusiveIntRange.fromTo(21, 23)); + addSupportedSigAlg( + OID_DIGEST_SHA1, OID_SIG_SHA256_WITH_DSA, + InclusiveIntRange.fromTo(21, 23)); + + addSupportedSigAlg( + OID_DIGEST_SHA224, OID_SIG_DSA, + InclusiveIntRange.from(22)); + addSupportedSigAlg( + OID_DIGEST_SHA224, OID_SIG_SHA1_WITH_DSA, + InclusiveIntRange.fromTo(21, 23)); + addSupportedSigAlg( + OID_DIGEST_SHA224, OID_SIG_SHA224_WITH_DSA, + InclusiveIntRange.from(21)); + addSupportedSigAlg( + OID_DIGEST_SHA224, OID_SIG_SHA256_WITH_DSA, + InclusiveIntRange.fromTo(21, 23)); + + addSupportedSigAlg( + OID_DIGEST_SHA256, OID_SIG_DSA, + InclusiveIntRange.from(22)); + addSupportedSigAlg( + OID_DIGEST_SHA256, OID_SIG_SHA1_WITH_DSA, + InclusiveIntRange.fromTo(21, 23)); + addSupportedSigAlg( + OID_DIGEST_SHA256, OID_SIG_SHA224_WITH_DSA, + InclusiveIntRange.fromTo(21, 23)); + addSupportedSigAlg( + OID_DIGEST_SHA256, OID_SIG_SHA256_WITH_DSA, + InclusiveIntRange.from(21)); + + addSupportedSigAlg( + OID_DIGEST_SHA384, OID_SIG_SHA1_WITH_DSA, + InclusiveIntRange.fromTo(21, 23)); + addSupportedSigAlg( + OID_DIGEST_SHA384, OID_SIG_SHA224_WITH_DSA, + InclusiveIntRange.fromTo(21, 23)); + addSupportedSigAlg( + OID_DIGEST_SHA384, OID_SIG_SHA256_WITH_DSA, + InclusiveIntRange.fromTo(21, 23)); + + addSupportedSigAlg( + OID_DIGEST_SHA512, OID_SIG_SHA1_WITH_DSA, + InclusiveIntRange.fromTo(21, 23)); + addSupportedSigAlg( + OID_DIGEST_SHA512, OID_SIG_SHA224_WITH_DSA, + InclusiveIntRange.fromTo(21, 23)); + addSupportedSigAlg( + OID_DIGEST_SHA512, OID_SIG_SHA256_WITH_DSA, + InclusiveIntRange.fromTo(21, 23)); + + addSupportedSigAlg( + OID_DIGEST_SHA1, OID_SIG_EC_PUBLIC_KEY, + InclusiveIntRange.from(18)); + addSupportedSigAlg( + OID_DIGEST_SHA224, OID_SIG_EC_PUBLIC_KEY, + InclusiveIntRange.from(21)); + addSupportedSigAlg( + OID_DIGEST_SHA256, OID_SIG_EC_PUBLIC_KEY, + InclusiveIntRange.from(18)); + addSupportedSigAlg( + OID_DIGEST_SHA384, OID_SIG_EC_PUBLIC_KEY, + InclusiveIntRange.from(18)); + addSupportedSigAlg( + OID_DIGEST_SHA512, OID_SIG_EC_PUBLIC_KEY, + InclusiveIntRange.from(18)); + + addSupportedSigAlg( + OID_DIGEST_MD5, OID_SIG_SHA1_WITH_ECDSA, + InclusiveIntRange.fromTo(21, 23)); + addSupportedSigAlg( + OID_DIGEST_MD5, OID_SIG_SHA224_WITH_ECDSA, + InclusiveIntRange.fromTo(21, 23)); + addSupportedSigAlg( + OID_DIGEST_MD5, OID_SIG_SHA256_WITH_ECDSA, + InclusiveIntRange.fromTo(21, 23)); + addSupportedSigAlg( + OID_DIGEST_MD5, OID_SIG_SHA384_WITH_ECDSA, + InclusiveIntRange.fromTo(21, 23)); + addSupportedSigAlg( + OID_DIGEST_MD5, OID_SIG_SHA512_WITH_ECDSA, + InclusiveIntRange.fromTo(21, 23)); + + addSupportedSigAlg( + OID_DIGEST_SHA1, OID_SIG_SHA1_WITH_ECDSA, + InclusiveIntRange.from(18)); + addSupportedSigAlg( + OID_DIGEST_SHA1, OID_SIG_SHA224_WITH_ECDSA, + InclusiveIntRange.fromTo(21, 23)); + addSupportedSigAlg( + OID_DIGEST_SHA1, OID_SIG_SHA256_WITH_ECDSA, + InclusiveIntRange.fromTo(21, 23)); + addSupportedSigAlg( + OID_DIGEST_SHA1, OID_SIG_SHA384_WITH_ECDSA, + InclusiveIntRange.fromTo(21, 23)); + addSupportedSigAlg( + OID_DIGEST_SHA1, OID_SIG_SHA512_WITH_ECDSA, + InclusiveIntRange.fromTo(21, 23)); + + addSupportedSigAlg( + OID_DIGEST_SHA224, OID_SIG_SHA1_WITH_ECDSA, + InclusiveIntRange.fromTo(21, 23)); + addSupportedSigAlg( + OID_DIGEST_SHA224, OID_SIG_SHA224_WITH_ECDSA, + InclusiveIntRange.from(21)); + addSupportedSigAlg( + OID_DIGEST_SHA224, OID_SIG_SHA256_WITH_ECDSA, + InclusiveIntRange.fromTo(21, 23)); + addSupportedSigAlg( + OID_DIGEST_SHA224, OID_SIG_SHA384_WITH_ECDSA, + InclusiveIntRange.fromTo(21, 23)); + addSupportedSigAlg( + OID_DIGEST_SHA224, OID_SIG_SHA512_WITH_ECDSA, + InclusiveIntRange.fromTo(21, 23)); + + addSupportedSigAlg( + OID_DIGEST_SHA256, OID_SIG_SHA1_WITH_ECDSA, + InclusiveIntRange.fromTo(21, 23)); + addSupportedSigAlg( + OID_DIGEST_SHA256, OID_SIG_SHA224_WITH_ECDSA, + InclusiveIntRange.fromTo(21, 23)); + addSupportedSigAlg( + OID_DIGEST_SHA256, OID_SIG_SHA256_WITH_ECDSA, + InclusiveIntRange.from(21)); + addSupportedSigAlg( + OID_DIGEST_SHA256, OID_SIG_SHA384_WITH_ECDSA, + InclusiveIntRange.fromTo(21, 23)); + addSupportedSigAlg( + OID_DIGEST_SHA256, OID_SIG_SHA512_WITH_ECDSA, + InclusiveIntRange.fromTo(21, 23)); + + addSupportedSigAlg( + OID_DIGEST_SHA384, OID_SIG_SHA1_WITH_ECDSA, + InclusiveIntRange.fromTo(21, 23)); + addSupportedSigAlg( + OID_DIGEST_SHA384, OID_SIG_SHA224_WITH_ECDSA, + InclusiveIntRange.fromTo(21, 23)); + addSupportedSigAlg( + OID_DIGEST_SHA384, OID_SIG_SHA256_WITH_ECDSA, + InclusiveIntRange.fromTo(21, 23)); + addSupportedSigAlg( + OID_DIGEST_SHA384, OID_SIG_SHA384_WITH_ECDSA, + InclusiveIntRange.from(21)); + addSupportedSigAlg( + OID_DIGEST_SHA384, OID_SIG_SHA512_WITH_ECDSA, + InclusiveIntRange.fromTo(21, 23)); + + addSupportedSigAlg( + OID_DIGEST_SHA512, OID_SIG_SHA1_WITH_ECDSA, + InclusiveIntRange.fromTo(21, 23)); + addSupportedSigAlg( + OID_DIGEST_SHA512, OID_SIG_SHA224_WITH_ECDSA, + InclusiveIntRange.fromTo(21, 23)); + addSupportedSigAlg( + OID_DIGEST_SHA512, OID_SIG_SHA256_WITH_ECDSA, + InclusiveIntRange.fromTo(21, 23)); + addSupportedSigAlg( + OID_DIGEST_SHA512, OID_SIG_SHA384_WITH_ECDSA, + InclusiveIntRange.fromTo(21, 23)); + addSupportedSigAlg( + OID_DIGEST_SHA512, OID_SIG_SHA512_WITH_ECDSA, + InclusiveIntRange.from(21)); + } + + public static void addSupportedSigAlg( + String digestAlgorithmOid, + String signatureAlgorithmOid, + InclusiveIntRange... supportedApiLevels) { + SUPPORTED_SIG_ALG_OIDS.put( + digestAlgorithmOid + "with" + signatureAlgorithmOid, + Arrays.asList(supportedApiLevels)); + } + + public static List<InclusiveIntRange> getSigAlgSupportedApiLevels( + String digestAlgorithmOid, + String signatureAlgorithmOid) { + List<InclusiveIntRange> result = + SUPPORTED_SIG_ALG_OIDS.get(digestAlgorithmOid + "with" + signatureAlgorithmOid); + return (result != null) ? result : Collections.emptyList(); + } + + public static class OidToUserFriendlyNameMapper { + private OidToUserFriendlyNameMapper() {} + + private static final Map<String, String> OID_TO_USER_FRIENDLY_NAME = new HashMap<>(); + static { + OID_TO_USER_FRIENDLY_NAME.put(OID_DIGEST_MD5, "MD5"); + OID_TO_USER_FRIENDLY_NAME.put(OID_DIGEST_SHA1, "SHA-1"); + OID_TO_USER_FRIENDLY_NAME.put(OID_DIGEST_SHA224, "SHA-224"); + OID_TO_USER_FRIENDLY_NAME.put(OID_DIGEST_SHA256, "SHA-256"); + OID_TO_USER_FRIENDLY_NAME.put(OID_DIGEST_SHA384, "SHA-384"); + OID_TO_USER_FRIENDLY_NAME.put(OID_DIGEST_SHA512, "SHA-512"); + + OID_TO_USER_FRIENDLY_NAME.put(OID_SIG_RSA, "RSA"); + OID_TO_USER_FRIENDLY_NAME.put(OID_SIG_MD5_WITH_RSA, "MD5 with RSA"); + OID_TO_USER_FRIENDLY_NAME.put(OID_SIG_SHA1_WITH_RSA, "SHA-1 with RSA"); + OID_TO_USER_FRIENDLY_NAME.put(OID_SIG_SHA224_WITH_RSA, "SHA-224 with RSA"); + OID_TO_USER_FRIENDLY_NAME.put(OID_SIG_SHA256_WITH_RSA, "SHA-256 with RSA"); + OID_TO_USER_FRIENDLY_NAME.put(OID_SIG_SHA384_WITH_RSA, "SHA-384 with RSA"); + OID_TO_USER_FRIENDLY_NAME.put(OID_SIG_SHA512_WITH_RSA, "SHA-512 with RSA"); + + + OID_TO_USER_FRIENDLY_NAME.put(OID_SIG_DSA, "DSA"); + OID_TO_USER_FRIENDLY_NAME.put(OID_SIG_SHA1_WITH_DSA, "SHA-1 with DSA"); + OID_TO_USER_FRIENDLY_NAME.put(OID_SIG_SHA224_WITH_DSA, "SHA-224 with DSA"); + OID_TO_USER_FRIENDLY_NAME.put(OID_SIG_SHA256_WITH_DSA, "SHA-256 with DSA"); + OID_TO_USER_FRIENDLY_NAME.put(OID_SIG_SHA384_WITH_DSA, "SHA-384 with DSA"); + OID_TO_USER_FRIENDLY_NAME.put(OID_SIG_SHA512_WITH_DSA, "SHA-512 with DSA"); + + OID_TO_USER_FRIENDLY_NAME.put(OID_SIG_EC_PUBLIC_KEY, "ECDSA"); + OID_TO_USER_FRIENDLY_NAME.put(OID_SIG_SHA1_WITH_ECDSA, "SHA-1 with ECDSA"); + OID_TO_USER_FRIENDLY_NAME.put(OID_SIG_SHA224_WITH_ECDSA, "SHA-224 with ECDSA"); + OID_TO_USER_FRIENDLY_NAME.put(OID_SIG_SHA256_WITH_ECDSA, "SHA-256 with ECDSA"); + OID_TO_USER_FRIENDLY_NAME.put(OID_SIG_SHA384_WITH_ECDSA, "SHA-384 with ECDSA"); + OID_TO_USER_FRIENDLY_NAME.put(OID_SIG_SHA512_WITH_ECDSA, "SHA-512 with ECDSA"); + } + + public static String getUserFriendlyNameForOid(String oid) { + return OID_TO_USER_FRIENDLY_NAME.get(oid); + } + } + + public static final Map<String, String> OID_TO_JCA_DIGEST_ALG = new HashMap<>(); + static { + OID_TO_JCA_DIGEST_ALG.put(OID_DIGEST_MD5, "MD5"); + OID_TO_JCA_DIGEST_ALG.put(OID_DIGEST_SHA1, "SHA-1"); + OID_TO_JCA_DIGEST_ALG.put(OID_DIGEST_SHA224, "SHA-224"); + OID_TO_JCA_DIGEST_ALG.put(OID_DIGEST_SHA256, "SHA-256"); + OID_TO_JCA_DIGEST_ALG.put(OID_DIGEST_SHA384, "SHA-384"); + OID_TO_JCA_DIGEST_ALG.put(OID_DIGEST_SHA512, "SHA-512"); + } + + public static final Map<String, String> OID_TO_JCA_SIGNATURE_ALG = new HashMap<>(); + static { + OID_TO_JCA_SIGNATURE_ALG.put(OID_SIG_MD5_WITH_RSA, "MD5withRSA"); + OID_TO_JCA_SIGNATURE_ALG.put(OID_SIG_SHA1_WITH_RSA, "SHA1withRSA"); + OID_TO_JCA_SIGNATURE_ALG.put(OID_SIG_SHA224_WITH_RSA, "SHA224withRSA"); + OID_TO_JCA_SIGNATURE_ALG.put(OID_SIG_SHA256_WITH_RSA, "SHA256withRSA"); + OID_TO_JCA_SIGNATURE_ALG.put(OID_SIG_SHA384_WITH_RSA, "SHA384withRSA"); + OID_TO_JCA_SIGNATURE_ALG.put(OID_SIG_SHA512_WITH_RSA, "SHA512withRSA"); + + OID_TO_JCA_SIGNATURE_ALG.put(OID_SIG_SHA1_WITH_DSA, "SHA1withDSA"); + OID_TO_JCA_SIGNATURE_ALG.put(OID_SIG_SHA224_WITH_DSA, "SHA224withDSA"); + OID_TO_JCA_SIGNATURE_ALG.put(OID_SIG_SHA256_WITH_DSA, "SHA256withDSA"); + + OID_TO_JCA_SIGNATURE_ALG.put(OID_SIG_SHA1_WITH_ECDSA, "SHA1withECDSA"); + OID_TO_JCA_SIGNATURE_ALG.put(OID_SIG_SHA224_WITH_ECDSA, "SHA224withECDSA"); + OID_TO_JCA_SIGNATURE_ALG.put(OID_SIG_SHA256_WITH_ECDSA, "SHA256withECDSA"); + OID_TO_JCA_SIGNATURE_ALG.put(OID_SIG_SHA384_WITH_ECDSA, "SHA384withECDSA"); + OID_TO_JCA_SIGNATURE_ALG.put(OID_SIG_SHA512_WITH_ECDSA, "SHA512withECDSA"); + } + + private OidConstants() {} +} diff --git a/platform/android/java/editor/src/main/java/com/android/apksig/internal/pkcs7/AlgorithmIdentifier.java b/platform/android/java/editor/src/main/java/com/android/apksig/internal/pkcs7/AlgorithmIdentifier.java new file mode 100644 index 0000000000..9712767293 --- /dev/null +++ b/platform/android/java/editor/src/main/java/com/android/apksig/internal/pkcs7/AlgorithmIdentifier.java @@ -0,0 +1,173 @@ +/* + * Copyright (C) 2017 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.apksig.internal.pkcs7; + +import static com.android.apksig.Constants.OID_RSA_ENCRYPTION; +import static com.android.apksig.internal.asn1.Asn1DerEncoder.ASN1_DER_NULL; +import static com.android.apksig.internal.oid.OidConstants.OID_DIGEST_SHA1; +import static com.android.apksig.internal.oid.OidConstants.OID_DIGEST_SHA256; +import static com.android.apksig.internal.oid.OidConstants.OID_SIG_DSA; +import static com.android.apksig.internal.oid.OidConstants.OID_SIG_EC_PUBLIC_KEY; +import static com.android.apksig.internal.oid.OidConstants.OID_SIG_RSA; +import static com.android.apksig.internal.oid.OidConstants.OID_SIG_SHA256_WITH_DSA; +import static com.android.apksig.internal.oid.OidConstants.OID_TO_JCA_DIGEST_ALG; +import static com.android.apksig.internal.oid.OidConstants.OID_TO_JCA_SIGNATURE_ALG; + +import com.android.apksig.internal.apk.v1.DigestAlgorithm; +import com.android.apksig.internal.asn1.Asn1Class; +import com.android.apksig.internal.asn1.Asn1Field; +import com.android.apksig.internal.asn1.Asn1OpaqueObject; +import com.android.apksig.internal.asn1.Asn1Type; +import com.android.apksig.internal.util.Pair; + +import java.security.InvalidKeyException; +import java.security.PublicKey; +import java.security.Signature; +import java.security.SignatureException; + +/** + * PKCS #7 {@code AlgorithmIdentifier} as specified in RFC 5652. + */ +@Asn1Class(type = Asn1Type.SEQUENCE) +public class AlgorithmIdentifier { + + @Asn1Field(index = 0, type = Asn1Type.OBJECT_IDENTIFIER) + public String algorithm; + + @Asn1Field(index = 1, type = Asn1Type.ANY, optional = true) + public Asn1OpaqueObject parameters; + + public AlgorithmIdentifier() {} + + public AlgorithmIdentifier(String algorithmOid, Asn1OpaqueObject parameters) { + this.algorithm = algorithmOid; + this.parameters = parameters; + } + + /** + * Returns the PKCS #7 {@code DigestAlgorithm} to use when signing using the specified digest + * algorithm. + */ + public static AlgorithmIdentifier getSignerInfoDigestAlgorithmOid( + DigestAlgorithm digestAlgorithm) { + switch (digestAlgorithm) { + case SHA1: + return new AlgorithmIdentifier(OID_DIGEST_SHA1, ASN1_DER_NULL); + case SHA256: + return new AlgorithmIdentifier(OID_DIGEST_SHA256, ASN1_DER_NULL); + } + throw new IllegalArgumentException("Unsupported digest algorithm: " + digestAlgorithm); + } + + /** + * Returns the JCA {@link Signature} algorithm and PKCS #7 {@code SignatureAlgorithm} to use + * when signing with the specified key and digest algorithm. + */ + public static Pair<String, AlgorithmIdentifier> getSignerInfoSignatureAlgorithm( + PublicKey publicKey, DigestAlgorithm digestAlgorithm, boolean deterministicDsaSigning) + throws InvalidKeyException { + String keyAlgorithm = publicKey.getAlgorithm(); + String jcaDigestPrefixForSigAlg; + switch (digestAlgorithm) { + case SHA1: + jcaDigestPrefixForSigAlg = "SHA1"; + break; + case SHA256: + jcaDigestPrefixForSigAlg = "SHA256"; + break; + default: + throw new IllegalArgumentException( + "Unexpected digest algorithm: " + digestAlgorithm); + } + if ("RSA".equalsIgnoreCase(keyAlgorithm) || OID_RSA_ENCRYPTION.equals(keyAlgorithm)) { + return Pair.of( + jcaDigestPrefixForSigAlg + "withRSA", + new AlgorithmIdentifier(OID_SIG_RSA, ASN1_DER_NULL)); + } else if ("DSA".equalsIgnoreCase(keyAlgorithm)) { + AlgorithmIdentifier sigAlgId; + switch (digestAlgorithm) { + case SHA1: + sigAlgId = + new AlgorithmIdentifier(OID_SIG_DSA, ASN1_DER_NULL); + break; + case SHA256: + // DSA signatures with SHA-256 in SignedData are accepted by Android API Level + // 21 and higher. However, there are two ways to specify their SignedData + // SignatureAlgorithm: dsaWithSha256 (2.16.840.1.101.3.4.3.2) and + // dsa (1.2.840.10040.4.1). The latter works only on API Level 22+. Thus, we use + // the former. + sigAlgId = + new AlgorithmIdentifier(OID_SIG_SHA256_WITH_DSA, ASN1_DER_NULL); + break; + default: + throw new IllegalArgumentException( + "Unexpected digest algorithm: " + digestAlgorithm); + } + String signingAlgorithmName = + jcaDigestPrefixForSigAlg + (deterministicDsaSigning ? "withDetDSA" : "withDSA"); + return Pair.of(signingAlgorithmName, sigAlgId); + } else if ("EC".equalsIgnoreCase(keyAlgorithm)) { + return Pair.of( + jcaDigestPrefixForSigAlg + "withECDSA", + new AlgorithmIdentifier(OID_SIG_EC_PUBLIC_KEY, ASN1_DER_NULL)); + } else { + throw new InvalidKeyException("Unsupported key algorithm: " + keyAlgorithm); + } + } + + public static String getJcaSignatureAlgorithm( + String digestAlgorithmOid, + String signatureAlgorithmOid) throws SignatureException { + // First check whether the signature algorithm OID alone is sufficient + String result = OID_TO_JCA_SIGNATURE_ALG.get(signatureAlgorithmOid); + if (result != null) { + return result; + } + + // Signature algorithm OID alone is insufficient. Need to combine digest algorithm OID + // with signature algorithm OID. + String suffix; + if (OID_SIG_RSA.equals(signatureAlgorithmOid)) { + suffix = "RSA"; + } else if (OID_SIG_DSA.equals(signatureAlgorithmOid)) { + suffix = "DSA"; + } else if (OID_SIG_EC_PUBLIC_KEY.equals(signatureAlgorithmOid)) { + suffix = "ECDSA"; + } else { + throw new SignatureException( + "Unsupported JCA Signature algorithm" + + " . Digest algorithm: " + digestAlgorithmOid + + ", signature algorithm: " + signatureAlgorithmOid); + } + String jcaDigestAlg = getJcaDigestAlgorithm(digestAlgorithmOid); + // Canonical name for SHA-1 with ... is SHA1with, rather than SHA1. Same for all other + // SHA algorithms. + if (jcaDigestAlg.startsWith("SHA-")) { + jcaDigestAlg = "SHA" + jcaDigestAlg.substring("SHA-".length()); + } + return jcaDigestAlg + "with" + suffix; + } + + public static String getJcaDigestAlgorithm(String oid) + throws SignatureException { + String result = OID_TO_JCA_DIGEST_ALG.get(oid); + if (result == null) { + throw new SignatureException("Unsupported digest algorithm: " + oid); + } + return result; + } +} diff --git a/platform/android/java/editor/src/main/java/com/android/apksig/internal/pkcs7/Attribute.java b/platform/android/java/editor/src/main/java/com/android/apksig/internal/pkcs7/Attribute.java new file mode 100644 index 0000000000..a6c91efac6 --- /dev/null +++ b/platform/android/java/editor/src/main/java/com/android/apksig/internal/pkcs7/Attribute.java @@ -0,0 +1,36 @@ +/* + * Copyright (C) 2017 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.apksig.internal.pkcs7; + +import com.android.apksig.internal.asn1.Asn1Class; +import com.android.apksig.internal.asn1.Asn1Field; +import com.android.apksig.internal.asn1.Asn1OpaqueObject; +import com.android.apksig.internal.asn1.Asn1Type; +import java.util.List; + +/** + * PKCS #7 {@code Attribute} as specified in RFC 5652. + */ +@Asn1Class(type = Asn1Type.SEQUENCE) +public class Attribute { + + @Asn1Field(index = 0, type = Asn1Type.OBJECT_IDENTIFIER) + public String attrType; + + @Asn1Field(index = 1, type = Asn1Type.SET_OF) + public List<Asn1OpaqueObject> attrValues; +} diff --git a/platform/android/java/editor/src/main/java/com/android/apksig/internal/pkcs7/ContentInfo.java b/platform/android/java/editor/src/main/java/com/android/apksig/internal/pkcs7/ContentInfo.java new file mode 100644 index 0000000000..8ab722c2db --- /dev/null +++ b/platform/android/java/editor/src/main/java/com/android/apksig/internal/pkcs7/ContentInfo.java @@ -0,0 +1,36 @@ +/* + * Copyright (C) 2017 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.apksig.internal.pkcs7; + +import com.android.apksig.internal.asn1.Asn1Class; +import com.android.apksig.internal.asn1.Asn1Field; +import com.android.apksig.internal.asn1.Asn1OpaqueObject; +import com.android.apksig.internal.asn1.Asn1Type; +import com.android.apksig.internal.asn1.Asn1Tagging; + +/** + * PKCS #7 {@code ContentInfo} as specified in RFC 5652. + */ +@Asn1Class(type = Asn1Type.SEQUENCE) +public class ContentInfo { + + @Asn1Field(index = 1, type = Asn1Type.OBJECT_IDENTIFIER) + public String contentType; + + @Asn1Field(index = 2, type = Asn1Type.ANY, tagging = Asn1Tagging.EXPLICIT, tagNumber = 0) + public Asn1OpaqueObject content; +} diff --git a/platform/android/java/editor/src/main/java/com/android/apksig/internal/pkcs7/EncapsulatedContentInfo.java b/platform/android/java/editor/src/main/java/com/android/apksig/internal/pkcs7/EncapsulatedContentInfo.java new file mode 100644 index 0000000000..79f41af89d --- /dev/null +++ b/platform/android/java/editor/src/main/java/com/android/apksig/internal/pkcs7/EncapsulatedContentInfo.java @@ -0,0 +1,46 @@ +/* + * Copyright (C) 2017 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.apksig.internal.pkcs7; + +import com.android.apksig.internal.asn1.Asn1Class; +import com.android.apksig.internal.asn1.Asn1Field; +import com.android.apksig.internal.asn1.Asn1Type; +import com.android.apksig.internal.asn1.Asn1Tagging; +import java.nio.ByteBuffer; + +/** + * PKCS #7 {@code EncapsulatedContentInfo} as specified in RFC 5652. + */ +@Asn1Class(type = Asn1Type.SEQUENCE) +public class EncapsulatedContentInfo { + + @Asn1Field(index = 0, type = Asn1Type.OBJECT_IDENTIFIER) + public String contentType; + + @Asn1Field( + index = 1, + type = Asn1Type.OCTET_STRING, + tagging = Asn1Tagging.EXPLICIT, tagNumber = 0, + optional = true) + public ByteBuffer content; + + public EncapsulatedContentInfo() {} + + public EncapsulatedContentInfo(String contentTypeOid) { + contentType = contentTypeOid; + } +} diff --git a/platform/android/java/editor/src/main/java/com/android/apksig/internal/pkcs7/IssuerAndSerialNumber.java b/platform/android/java/editor/src/main/java/com/android/apksig/internal/pkcs7/IssuerAndSerialNumber.java new file mode 100644 index 0000000000..284b11764b --- /dev/null +++ b/platform/android/java/editor/src/main/java/com/android/apksig/internal/pkcs7/IssuerAndSerialNumber.java @@ -0,0 +1,43 @@ +/* + * Copyright (C) 2017 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.apksig.internal.pkcs7; + +import com.android.apksig.internal.asn1.Asn1Class; +import com.android.apksig.internal.asn1.Asn1Field; +import com.android.apksig.internal.asn1.Asn1OpaqueObject; +import com.android.apksig.internal.asn1.Asn1Type; +import java.math.BigInteger; + +/** + * PKCS #7 {@code IssuerAndSerialNumber} as specified in RFC 5652. + */ +@Asn1Class(type = Asn1Type.SEQUENCE) +public class IssuerAndSerialNumber { + + @Asn1Field(index = 0, type = Asn1Type.ANY) + public Asn1OpaqueObject issuer; + + @Asn1Field(index = 1, type = Asn1Type.INTEGER) + public BigInteger certificateSerialNumber; + + public IssuerAndSerialNumber() {} + + public IssuerAndSerialNumber(Asn1OpaqueObject issuer, BigInteger certificateSerialNumber) { + this.issuer = issuer; + this.certificateSerialNumber = certificateSerialNumber; + } +} diff --git a/platform/android/java/editor/src/main/java/com/android/apksig/internal/pkcs7/Pkcs7Constants.java b/platform/android/java/editor/src/main/java/com/android/apksig/internal/pkcs7/Pkcs7Constants.java new file mode 100644 index 0000000000..1a115d5156 --- /dev/null +++ b/platform/android/java/editor/src/main/java/com/android/apksig/internal/pkcs7/Pkcs7Constants.java @@ -0,0 +1,29 @@ +/* + * Copyright (C) 2017 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.apksig.internal.pkcs7; + +/** + * Assorted PKCS #7 constants from RFC 5652. + */ +public abstract class Pkcs7Constants { + private Pkcs7Constants() {} + + public static final String OID_DATA = "1.2.840.113549.1.7.1"; + public static final String OID_SIGNED_DATA = "1.2.840.113549.1.7.2"; + public static final String OID_CONTENT_TYPE = "1.2.840.113549.1.9.3"; + public static final String OID_MESSAGE_DIGEST = "1.2.840.113549.1.9.4"; +} diff --git a/platform/android/java/editor/src/main/java/com/android/apksig/internal/pkcs7/Pkcs7DecodingException.java b/platform/android/java/editor/src/main/java/com/android/apksig/internal/pkcs7/Pkcs7DecodingException.java new file mode 100644 index 0000000000..4004ee7f78 --- /dev/null +++ b/platform/android/java/editor/src/main/java/com/android/apksig/internal/pkcs7/Pkcs7DecodingException.java @@ -0,0 +1,32 @@ +/* + * Copyright (C) 2017 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.apksig.internal.pkcs7; + +/** + * Indicates that an error was encountered while decoding a PKCS #7 structure. + */ +public class Pkcs7DecodingException extends Exception { + private static final long serialVersionUID = 1L; + + public Pkcs7DecodingException(String message) { + super(message); + } + + public Pkcs7DecodingException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/platform/android/java/editor/src/main/java/com/android/apksig/internal/pkcs7/SignedData.java b/platform/android/java/editor/src/main/java/com/android/apksig/internal/pkcs7/SignedData.java new file mode 100644 index 0000000000..56b6e502dc --- /dev/null +++ b/platform/android/java/editor/src/main/java/com/android/apksig/internal/pkcs7/SignedData.java @@ -0,0 +1,58 @@ +/* + * Copyright (C) 2017 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.apksig.internal.pkcs7; + +import com.android.apksig.internal.asn1.Asn1Class; +import com.android.apksig.internal.asn1.Asn1Field; +import com.android.apksig.internal.asn1.Asn1OpaqueObject; +import com.android.apksig.internal.asn1.Asn1Type; +import com.android.apksig.internal.asn1.Asn1Tagging; +import java.nio.ByteBuffer; +import java.util.List; + +/** + * PKCS #7 {@code SignedData} as specified in RFC 5652. + */ +@Asn1Class(type = Asn1Type.SEQUENCE) +public class SignedData { + + @Asn1Field(index = 0, type = Asn1Type.INTEGER) + public int version; + + @Asn1Field(index = 1, type = Asn1Type.SET_OF) + public List<AlgorithmIdentifier> digestAlgorithms; + + @Asn1Field(index = 2, type = Asn1Type.SEQUENCE) + public EncapsulatedContentInfo encapContentInfo; + + @Asn1Field( + index = 3, + type = Asn1Type.SET_OF, + tagging = Asn1Tagging.IMPLICIT, tagNumber = 0, + optional = true) + public List<Asn1OpaqueObject> certificates; + + @Asn1Field( + index = 4, + type = Asn1Type.SET_OF, + tagging = Asn1Tagging.IMPLICIT, tagNumber = 1, + optional = true) + public List<ByteBuffer> crls; + + @Asn1Field(index = 5, type = Asn1Type.SET_OF) + public List<SignerInfo> signerInfos; +} diff --git a/platform/android/java/editor/src/main/java/com/android/apksig/internal/pkcs7/SignerIdentifier.java b/platform/android/java/editor/src/main/java/com/android/apksig/internal/pkcs7/SignerIdentifier.java new file mode 100644 index 0000000000..a3d70f16bb --- /dev/null +++ b/platform/android/java/editor/src/main/java/com/android/apksig/internal/pkcs7/SignerIdentifier.java @@ -0,0 +1,42 @@ +/* + * Copyright (C) 2017 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.apksig.internal.pkcs7; + +import com.android.apksig.internal.asn1.Asn1Class; +import com.android.apksig.internal.asn1.Asn1Field; +import com.android.apksig.internal.asn1.Asn1Type; +import com.android.apksig.internal.asn1.Asn1Tagging; +import java.nio.ByteBuffer; + +/** + * PKCS #7 {@code SignerIdentifier} as specified in RFC 5652. + */ +@Asn1Class(type = Asn1Type.CHOICE) +public class SignerIdentifier { + + @Asn1Field(type = Asn1Type.SEQUENCE) + public IssuerAndSerialNumber issuerAndSerialNumber; + + @Asn1Field(type = Asn1Type.OCTET_STRING, tagging = Asn1Tagging.IMPLICIT, tagNumber = 0) + public ByteBuffer subjectKeyIdentifier; + + public SignerIdentifier() {} + + public SignerIdentifier(IssuerAndSerialNumber issuerAndSerialNumber) { + this.issuerAndSerialNumber = issuerAndSerialNumber; + } +} diff --git a/platform/android/java/editor/src/main/java/com/android/apksig/internal/pkcs7/SignerInfo.java b/platform/android/java/editor/src/main/java/com/android/apksig/internal/pkcs7/SignerInfo.java new file mode 100644 index 0000000000..b885eb8002 --- /dev/null +++ b/platform/android/java/editor/src/main/java/com/android/apksig/internal/pkcs7/SignerInfo.java @@ -0,0 +1,61 @@ +/* + * Copyright (C) 2017 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.apksig.internal.pkcs7; + +import com.android.apksig.internal.asn1.Asn1Class; +import com.android.apksig.internal.asn1.Asn1Field; +import com.android.apksig.internal.asn1.Asn1OpaqueObject; +import com.android.apksig.internal.asn1.Asn1Type; +import com.android.apksig.internal.asn1.Asn1Tagging; +import java.nio.ByteBuffer; +import java.util.List; + +/** + * PKCS #7 {@code SignerInfo} as specified in RFC 5652. + */ +@Asn1Class(type = Asn1Type.SEQUENCE) +public class SignerInfo { + + @Asn1Field(index = 0, type = Asn1Type.INTEGER) + public int version; + + @Asn1Field(index = 1, type = Asn1Type.CHOICE) + public SignerIdentifier sid; + + @Asn1Field(index = 2, type = Asn1Type.SEQUENCE) + public AlgorithmIdentifier digestAlgorithm; + + @Asn1Field( + index = 3, + type = Asn1Type.SET_OF, + tagging = Asn1Tagging.IMPLICIT, tagNumber = 0, + optional = true) + public Asn1OpaqueObject signedAttrs; + + @Asn1Field(index = 4, type = Asn1Type.SEQUENCE) + public AlgorithmIdentifier signatureAlgorithm; + + @Asn1Field(index = 5, type = Asn1Type.OCTET_STRING) + public ByteBuffer signature; + + @Asn1Field( + index = 6, + type = Asn1Type.SET_OF, + tagging = Asn1Tagging.IMPLICIT, tagNumber = 1, + optional = true) + public List<Attribute> unsignedAttrs; +} diff --git a/platform/android/java/editor/src/main/java/com/android/apksig/internal/util/AndroidSdkVersion.java b/platform/android/java/editor/src/main/java/com/android/apksig/internal/util/AndroidSdkVersion.java new file mode 100644 index 0000000000..90aee30321 --- /dev/null +++ b/platform/android/java/editor/src/main/java/com/android/apksig/internal/util/AndroidSdkVersion.java @@ -0,0 +1,74 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.apksig.internal.util; + +/** + * Android SDK version / API Level constants. + */ +public abstract class AndroidSdkVersion { + + /** Hidden constructor to prevent instantiation. */ + private AndroidSdkVersion() {} + + /** Android 1.0 */ + public static final int INITIAL_RELEASE = 1; + + /** Android 2.3. */ + public static final int GINGERBREAD = 9; + + /** Android 3.0 */ + public static final int HONEYCOMB = 11; + + /** Android 4.3. The revenge of the beans. */ + public static final int JELLY_BEAN_MR2 = 18; + + /** Android 4.4. KitKat, another tasty treat. */ + public static final int KITKAT = 19; + + /** Android 5.0. A flat one with beautiful shadows. But still tasty. */ + public static final int LOLLIPOP = 21; + + /** Android 6.0. M is for Marshmallow! */ + public static final int M = 23; + + /** Android 7.0. N is for Nougat. */ + public static final int N = 24; + + /** Android O. */ + public static final int O = 26; + + /** Android P. */ + public static final int P = 28; + + /** Android Q. */ + public static final int Q = 29; + + /** Android R. */ + public static final int R = 30; + + /** Android S. */ + public static final int S = 31; + + /** Android Sv2. */ + public static final int Sv2 = 32; + + /** Android Tiramisu. */ + public static final int T = 33; + + /** Android Upside Down Cake. */ + public static final int U = 34; +} diff --git a/platform/android/java/editor/src/main/java/com/android/apksig/internal/util/ByteArrayDataSink.java b/platform/android/java/editor/src/main/java/com/android/apksig/internal/util/ByteArrayDataSink.java new file mode 100644 index 0000000000..e5741a5b53 --- /dev/null +++ b/platform/android/java/editor/src/main/java/com/android/apksig/internal/util/ByteArrayDataSink.java @@ -0,0 +1,240 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.apksig.internal.util; + +import com.android.apksig.util.DataSink; +import com.android.apksig.util.DataSource; +import com.android.apksig.util.ReadableDataSink; +import java.io.IOException; +import java.nio.ByteBuffer; +import java.util.Arrays; + +/** + * Growable byte array which can be appended to via {@link DataSink} interface and read from via + * {@link DataSource} interface. + */ +public class ByteArrayDataSink implements ReadableDataSink { + + private static final int MAX_READ_CHUNK_SIZE = 65536; + + private byte[] mArray; + private int mSize; + + public ByteArrayDataSink() { + this(65536); + } + + public ByteArrayDataSink(int initialCapacity) { + if (initialCapacity < 0) { + throw new IllegalArgumentException("initial capacity: " + initialCapacity); + } + mArray = new byte[initialCapacity]; + } + + @Override + public void consume(byte[] buf, int offset, int length) throws IOException { + if (offset < 0) { + // Must perform this check because System.arraycopy below doesn't perform it when + // length == 0 + throw new IndexOutOfBoundsException("offset: " + offset); + } + if (offset > buf.length) { + // Must perform this check because System.arraycopy below doesn't perform it when + // length == 0 + throw new IndexOutOfBoundsException( + "offset: " + offset + ", buf.length: " + buf.length); + } + if (length == 0) { + return; + } + + ensureAvailable(length); + System.arraycopy(buf, offset, mArray, mSize, length); + mSize += length; + } + + @Override + public void consume(ByteBuffer buf) throws IOException { + if (!buf.hasRemaining()) { + return; + } + + if (buf.hasArray()) { + consume(buf.array(), buf.arrayOffset() + buf.position(), buf.remaining()); + buf.position(buf.limit()); + return; + } + + ensureAvailable(buf.remaining()); + byte[] tmp = new byte[Math.min(buf.remaining(), MAX_READ_CHUNK_SIZE)]; + while (buf.hasRemaining()) { + int chunkSize = Math.min(buf.remaining(), tmp.length); + buf.get(tmp, 0, chunkSize); + System.arraycopy(tmp, 0, mArray, mSize, chunkSize); + mSize += chunkSize; + } + } + + private void ensureAvailable(int minAvailable) throws IOException { + if (minAvailable <= 0) { + return; + } + + long minCapacity = ((long) mSize) + minAvailable; + if (minCapacity <= mArray.length) { + return; + } + if (minCapacity > Integer.MAX_VALUE) { + throw new IOException( + "Required capacity too large: " + minCapacity + ", max: " + Integer.MAX_VALUE); + } + int doubleCurrentSize = (int) Math.min(mArray.length * 2L, Integer.MAX_VALUE); + int newSize = (int) Math.max(minCapacity, doubleCurrentSize); + mArray = Arrays.copyOf(mArray, newSize); + } + + @Override + public long size() { + return mSize; + } + + @Override + public ByteBuffer getByteBuffer(long offset, int size) { + checkChunkValid(offset, size); + + // checkChunkValid ensures that it's OK to cast offset to int. + return ByteBuffer.wrap(mArray, (int) offset, size).slice(); + } + + @Override + public void feed(long offset, long size, DataSink sink) throws IOException { + checkChunkValid(offset, size); + + // checkChunkValid ensures that it's OK to cast offset and size to int. + sink.consume(mArray, (int) offset, (int) size); + } + + @Override + public void copyTo(long offset, int size, ByteBuffer dest) throws IOException { + checkChunkValid(offset, size); + + // checkChunkValid ensures that it's OK to cast offset to int. + dest.put(mArray, (int) offset, size); + } + + private void checkChunkValid(long offset, long size) { + if (offset < 0) { + throw new IndexOutOfBoundsException("offset: " + offset); + } + if (size < 0) { + throw new IndexOutOfBoundsException("size: " + size); + } + if (offset > mSize) { + throw new IndexOutOfBoundsException( + "offset (" + offset + ") > source size (" + mSize + ")"); + } + long endOffset = offset + size; + if (endOffset < offset) { + throw new IndexOutOfBoundsException( + "offset (" + offset + ") + size (" + size + ") overflow"); + } + if (endOffset > mSize) { + throw new IndexOutOfBoundsException( + "offset (" + offset + ") + size (" + size + ") > source size (" + mSize + ")"); + } + } + + @Override + public DataSource slice(long offset, long size) { + checkChunkValid(offset, size); + // checkChunkValid ensures that it's OK to cast offset and size to int. + return new SliceDataSource((int) offset, (int) size); + } + + /** + * Slice of the growable byte array. The slice's offset and size in the array are fixed. + */ + private class SliceDataSource implements DataSource { + private final int mSliceOffset; + private final int mSliceSize; + + private SliceDataSource(int offset, int size) { + mSliceOffset = offset; + mSliceSize = size; + } + + @Override + public long size() { + return mSliceSize; + } + + @Override + public void feed(long offset, long size, DataSink sink) throws IOException { + checkChunkValid(offset, size); + // checkChunkValid combined with the way instances of this class are constructed ensures + // that mSliceOffset + offset does not overflow and that it's fine to cast size to int. + sink.consume(mArray, (int) (mSliceOffset + offset), (int) size); + } + + @Override + public ByteBuffer getByteBuffer(long offset, int size) throws IOException { + checkChunkValid(offset, size); + // checkChunkValid combined with the way instances of this class are constructed ensures + // that mSliceOffset + offset does not overflow. + return ByteBuffer.wrap(mArray, (int) (mSliceOffset + offset), size).slice(); + } + + @Override + public void copyTo(long offset, int size, ByteBuffer dest) throws IOException { + checkChunkValid(offset, size); + // checkChunkValid combined with the way instances of this class are constructed ensures + // that mSliceOffset + offset does not overflow. + dest.put(mArray, (int) (mSliceOffset + offset), size); + } + + @Override + public DataSource slice(long offset, long size) { + checkChunkValid(offset, size); + // checkChunkValid combined with the way instances of this class are constructed ensures + // that mSliceOffset + offset does not overflow and that it's fine to cast size to int. + return new SliceDataSource((int) (mSliceOffset + offset), (int) size); + } + + private void checkChunkValid(long offset, long size) { + if (offset < 0) { + throw new IndexOutOfBoundsException("offset: " + offset); + } + if (size < 0) { + throw new IndexOutOfBoundsException("size: " + size); + } + if (offset > mSliceSize) { + throw new IndexOutOfBoundsException( + "offset (" + offset + ") > source size (" + mSliceSize + ")"); + } + long endOffset = offset + size; + if (endOffset < offset) { + throw new IndexOutOfBoundsException( + "offset (" + offset + ") + size (" + size + ") overflow"); + } + if (endOffset > mSliceSize) { + throw new IndexOutOfBoundsException( + "offset (" + offset + ") + size (" + size + ") > source size (" + mSliceSize + + ")"); + } + } + } +} diff --git a/platform/android/java/editor/src/main/java/com/android/apksig/internal/util/ByteBufferDataSource.java b/platform/android/java/editor/src/main/java/com/android/apksig/internal/util/ByteBufferDataSource.java new file mode 100644 index 0000000000..656c20e111 --- /dev/null +++ b/platform/android/java/editor/src/main/java/com/android/apksig/internal/util/ByteBufferDataSource.java @@ -0,0 +1,125 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.apksig.internal.util; + +import com.android.apksig.util.DataSink; +import com.android.apksig.util.DataSource; +import java.io.IOException; +import java.nio.ByteBuffer; + +/** + * {@link DataSource} backed by a {@link ByteBuffer}. + */ +public class ByteBufferDataSource implements DataSource { + + private final ByteBuffer mBuffer; + private final int mSize; + + /** + * Constructs a new {@code ByteBufferDigestSource} based on the data contained in the provided + * buffer between the buffer's position and limit. + */ + public ByteBufferDataSource(ByteBuffer buffer) { + this(buffer, true); + } + + /** + * Constructs a new {@code ByteBufferDigestSource} based on the data contained in the provided + * buffer between the buffer's position and limit. + */ + private ByteBufferDataSource(ByteBuffer buffer, boolean sliceRequired) { + mBuffer = (sliceRequired) ? buffer.slice() : buffer; + mSize = buffer.remaining(); + } + + @Override + public long size() { + return mSize; + } + + @Override + public ByteBuffer getByteBuffer(long offset, int size) { + checkChunkValid(offset, size); + + // checkChunkValid ensures that it's OK to cast offset to int. + int chunkPosition = (int) offset; + int chunkLimit = chunkPosition + size; + // Creating a slice of ByteBuffer modifies the state of the source ByteBuffer (position + // and limit fields, to be more specific). We thus use synchronization around these + // state-changing operations to make instances of this class thread-safe. + synchronized (mBuffer) { + // ByteBuffer.limit(int) and .position(int) check that that the position >= limit + // invariant is not broken. Thus, the only way to safely change position and limit + // without caring about their current values is to first set position to 0 or set the + // limit to capacity. + mBuffer.position(0); + + mBuffer.limit(chunkLimit); + mBuffer.position(chunkPosition); + return mBuffer.slice(); + } + } + + @Override + public void copyTo(long offset, int size, ByteBuffer dest) { + dest.put(getByteBuffer(offset, size)); + } + + @Override + public void feed(long offset, long size, DataSink sink) throws IOException { + if ((size < 0) || (size > mSize)) { + throw new IndexOutOfBoundsException("size: " + size + ", source size: " + mSize); + } + sink.consume(getByteBuffer(offset, (int) size)); + } + + @Override + public ByteBufferDataSource slice(long offset, long size) { + if ((offset == 0) && (size == mSize)) { + return this; + } + if ((size < 0) || (size > mSize)) { + throw new IndexOutOfBoundsException("size: " + size + ", source size: " + mSize); + } + return new ByteBufferDataSource( + getByteBuffer(offset, (int) size), + false // no need to slice -- it's already a slice + ); + } + + private void checkChunkValid(long offset, long size) { + if (offset < 0) { + throw new IndexOutOfBoundsException("offset: " + offset); + } + if (size < 0) { + throw new IndexOutOfBoundsException("size: " + size); + } + if (offset > mSize) { + throw new IndexOutOfBoundsException( + "offset (" + offset + ") > source size (" + mSize + ")"); + } + long endOffset = offset + size; + if (endOffset < offset) { + throw new IndexOutOfBoundsException( + "offset (" + offset + ") + size (" + size + ") overflow"); + } + if (endOffset > mSize) { + throw new IndexOutOfBoundsException( + "offset (" + offset + ") + size (" + size + ") > source size (" + mSize +")"); + } + } +} diff --git a/platform/android/java/editor/src/main/java/com/android/apksig/internal/util/ByteBufferSink.java b/platform/android/java/editor/src/main/java/com/android/apksig/internal/util/ByteBufferSink.java new file mode 100644 index 0000000000..d7cbe03511 --- /dev/null +++ b/platform/android/java/editor/src/main/java/com/android/apksig/internal/util/ByteBufferSink.java @@ -0,0 +1,59 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.apksig.internal.util; + +import com.android.apksig.util.DataSink; +import java.io.IOException; +import java.nio.BufferOverflowException; +import java.nio.ByteBuffer; + +/** + * Data sink which stores all received data into the associated {@link ByteBuffer}. + */ +public class ByteBufferSink implements DataSink { + + private final ByteBuffer mBuffer; + + public ByteBufferSink(ByteBuffer buffer) { + mBuffer = buffer; + } + + public ByteBuffer getBuffer() { + return mBuffer; + } + + @Override + public void consume(byte[] buf, int offset, int length) throws IOException { + try { + mBuffer.put(buf, offset, length); + } catch (BufferOverflowException e) { + throw new IOException( + "Insufficient space in output buffer for " + length + " bytes", e); + } + } + + @Override + public void consume(ByteBuffer buf) throws IOException { + int length = buf.remaining(); + try { + mBuffer.put(buf); + } catch (BufferOverflowException e) { + throw new IOException( + "Insufficient space in output buffer for " + length + " bytes", e); + } + } +} diff --git a/platform/android/java/editor/src/main/java/com/android/apksig/internal/util/ByteBufferUtils.java b/platform/android/java/editor/src/main/java/com/android/apksig/internal/util/ByteBufferUtils.java new file mode 100644 index 0000000000..a7b4b5c804 --- /dev/null +++ b/platform/android/java/editor/src/main/java/com/android/apksig/internal/util/ByteBufferUtils.java @@ -0,0 +1,33 @@ +/* + * Copyright (C) 2017 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.apksig.internal.util; + +import java.nio.ByteBuffer; + +public final class ByteBufferUtils { + private ByteBufferUtils() {} + + /** + * Returns the remaining data of the provided buffer as a new byte array and advances the + * position of the buffer to the buffer's limit. + */ + public static byte[] toByteArray(ByteBuffer buf) { + byte[] result = new byte[buf.remaining()]; + buf.get(result); + return result; + } +} diff --git a/platform/android/java/editor/src/main/java/com/android/apksig/internal/util/ByteStreams.java b/platform/android/java/editor/src/main/java/com/android/apksig/internal/util/ByteStreams.java new file mode 100644 index 0000000000..bca3b0827b --- /dev/null +++ b/platform/android/java/editor/src/main/java/com/android/apksig/internal/util/ByteStreams.java @@ -0,0 +1,41 @@ +/* + * Copyright (C) 2017 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.apksig.internal.util; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; + +/** + * Utilities for byte arrays and I/O streams. + */ +public final class ByteStreams { + private ByteStreams() {} + + /** + * Returns the data remaining in the provided input stream as a byte array + */ + public static byte[] toByteArray(InputStream in) throws IOException { + ByteArrayOutputStream result = new ByteArrayOutputStream(); + byte[] buf = new byte[16384]; + int chunkSize; + while ((chunkSize = in.read(buf)) != -1) { + result.write(buf, 0, chunkSize); + } + return result.toByteArray(); + } +} diff --git a/platform/android/java/editor/src/main/java/com/android/apksig/internal/util/ChainedDataSource.java b/platform/android/java/editor/src/main/java/com/android/apksig/internal/util/ChainedDataSource.java new file mode 100644 index 0000000000..a0baf1aeff --- /dev/null +++ b/platform/android/java/editor/src/main/java/com/android/apksig/internal/util/ChainedDataSource.java @@ -0,0 +1,145 @@ +/* + * Copyright (C) 2017 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.apksig.internal.util; + +import com.android.apksig.util.DataSink; +import com.android.apksig.util.DataSource; +import java.io.IOException; +import java.nio.ByteBuffer; +import java.util.ArrayList; +import java.util.Arrays; + +/** Pseudo {@link DataSource} that chains the given {@link DataSource} as a continuous one. */ +public class ChainedDataSource implements DataSource { + + private final DataSource[] mSources; + private final long mTotalSize; + + public ChainedDataSource(DataSource... sources) { + mSources = sources; + mTotalSize = Arrays.stream(sources).mapToLong(src -> src.size()).sum(); + } + + @Override + public long size() { + return mTotalSize; + } + + @Override + public void feed(long offset, long size, DataSink sink) throws IOException { + if (offset + size > mTotalSize) { + throw new IndexOutOfBoundsException("Requested more than available"); + } + + for (DataSource src : mSources) { + // Offset is beyond the current source. Skip. + if (offset >= src.size()) { + offset -= src.size(); + continue; + } + + // If the remaining is enough, finish it. + long remaining = src.size() - offset; + if (remaining >= size) { + src.feed(offset, size, sink); + break; + } + + // If the remaining is not enough, consume all. + src.feed(offset, remaining, sink); + size -= remaining; + offset = 0; + } + } + + @Override + public ByteBuffer getByteBuffer(long offset, int size) throws IOException { + if (offset + size > mTotalSize) { + throw new IndexOutOfBoundsException("Requested more than available"); + } + + // Skip to the first DataSource we need. + Pair<Integer, Long> firstSource = locateDataSource(offset); + int i = firstSource.getFirst(); + offset = firstSource.getSecond(); + + // Return the current source's ByteBuffer if it fits. + if (offset + size <= mSources[i].size()) { + return mSources[i].getByteBuffer(offset, size); + } + + // Otherwise, read into a new buffer. + ByteBuffer buffer = ByteBuffer.allocate(size); + for (; i < mSources.length && buffer.hasRemaining(); i++) { + long sizeToCopy = Math.min(mSources[i].size() - offset, buffer.remaining()); + mSources[i].copyTo(offset, Math.toIntExact(sizeToCopy), buffer); + offset = 0; // may not be zero for the first source, but reset after that. + } + buffer.rewind(); + return buffer; + } + + @Override + public void copyTo(long offset, int size, ByteBuffer dest) throws IOException { + feed(offset, size, new ByteBufferSink(dest)); + } + + @Override + public DataSource slice(long offset, long size) { + // Find the first slice. + Pair<Integer, Long> firstSource = locateDataSource(offset); + int beginIndex = firstSource.getFirst(); + long beginLocalOffset = firstSource.getSecond(); + DataSource beginSource = mSources[beginIndex]; + + if (beginLocalOffset + size <= beginSource.size()) { + return beginSource.slice(beginLocalOffset, size); + } + + // Add the first slice to chaining, followed by the middle full slices, then the last. + ArrayList<DataSource> sources = new ArrayList<>(); + sources.add(beginSource.slice( + beginLocalOffset, beginSource.size() - beginLocalOffset)); + + Pair<Integer, Long> lastSource = locateDataSource(offset + size - 1); + int endIndex = lastSource.getFirst(); + long endLocalOffset = lastSource.getSecond(); + + for (int i = beginIndex + 1; i < endIndex; i++) { + sources.add(mSources[i]); + } + + sources.add(mSources[endIndex].slice(0, endLocalOffset + 1)); + return new ChainedDataSource(sources.toArray(new DataSource[0])); + } + + /** + * Find the index of DataSource that offset is at. + * @return Pair of DataSource index and the local offset in the DataSource. + */ + private Pair<Integer, Long> locateDataSource(long offset) { + long localOffset = offset; + for (int i = 0; i < mSources.length; i++) { + if (localOffset < mSources[i].size()) { + return Pair.of(i, localOffset); + } + localOffset -= mSources[i].size(); + } + throw new IndexOutOfBoundsException("Access is out of bound, offset: " + offset + + ", totalSize: " + mTotalSize); + } +} diff --git a/platform/android/java/editor/src/main/java/com/android/apksig/internal/util/DelegatingX509Certificate.java b/platform/android/java/editor/src/main/java/com/android/apksig/internal/util/DelegatingX509Certificate.java new file mode 100644 index 0000000000..2a890f6868 --- /dev/null +++ b/platform/android/java/editor/src/main/java/com/android/apksig/internal/util/DelegatingX509Certificate.java @@ -0,0 +1,219 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.apksig.internal.util; + +import java.math.BigInteger; +import java.security.InvalidKeyException; +import java.security.NoSuchAlgorithmException; +import java.security.NoSuchProviderException; +import java.security.Principal; +import java.security.Provider; +import java.security.PublicKey; +import java.security.SignatureException; +import java.security.cert.CertificateEncodingException; +import java.security.cert.CertificateException; +import java.security.cert.CertificateExpiredException; +import java.security.cert.CertificateNotYetValidException; +import java.security.cert.CertificateParsingException; +import java.security.cert.X509Certificate; +import java.util.Collection; +import java.util.Date; +import java.util.List; +import java.util.Set; + +import javax.security.auth.x500.X500Principal; + +/** + * {@link X509Certificate} which delegates all method invocations to the provided delegate + * {@code X509Certificate}. + */ +public class DelegatingX509Certificate extends X509Certificate { + private static final long serialVersionUID = 1L; + + private final X509Certificate mDelegate; + + public DelegatingX509Certificate(X509Certificate delegate) { + this.mDelegate = delegate; + } + + @Override + public Set<String> getCriticalExtensionOIDs() { + return mDelegate.getCriticalExtensionOIDs(); + } + + @Override + public byte[] getExtensionValue(String oid) { + return mDelegate.getExtensionValue(oid); + } + + @Override + public Set<String> getNonCriticalExtensionOIDs() { + return mDelegate.getNonCriticalExtensionOIDs(); + } + + @Override + public boolean hasUnsupportedCriticalExtension() { + return mDelegate.hasUnsupportedCriticalExtension(); + } + + @Override + public void checkValidity() + throws CertificateExpiredException, CertificateNotYetValidException { + mDelegate.checkValidity(); + } + + @Override + public void checkValidity(Date date) + throws CertificateExpiredException, CertificateNotYetValidException { + mDelegate.checkValidity(date); + } + + @Override + public int getVersion() { + return mDelegate.getVersion(); + } + + @Override + public BigInteger getSerialNumber() { + return mDelegate.getSerialNumber(); + } + + @Override + public Principal getIssuerDN() { + return mDelegate.getIssuerDN(); + } + + @Override + public Principal getSubjectDN() { + return mDelegate.getSubjectDN(); + } + + @Override + public Date getNotBefore() { + return mDelegate.getNotBefore(); + } + + @Override + public Date getNotAfter() { + return mDelegate.getNotAfter(); + } + + @Override + public byte[] getTBSCertificate() throws CertificateEncodingException { + return mDelegate.getTBSCertificate(); + } + + @Override + public byte[] getSignature() { + return mDelegate.getSignature(); + } + + @Override + public String getSigAlgName() { + return mDelegate.getSigAlgName(); + } + + @Override + public String getSigAlgOID() { + return mDelegate.getSigAlgOID(); + } + + @Override + public byte[] getSigAlgParams() { + return mDelegate.getSigAlgParams(); + } + + @Override + public boolean[] getIssuerUniqueID() { + return mDelegate.getIssuerUniqueID(); + } + + @Override + public boolean[] getSubjectUniqueID() { + return mDelegate.getSubjectUniqueID(); + } + + @Override + public boolean[] getKeyUsage() { + return mDelegate.getKeyUsage(); + } + + @Override + public int getBasicConstraints() { + return mDelegate.getBasicConstraints(); + } + + @Override + public byte[] getEncoded() throws CertificateEncodingException { + return mDelegate.getEncoded(); + } + + @Override + public void verify(PublicKey key) throws CertificateException, NoSuchAlgorithmException, + InvalidKeyException, NoSuchProviderException, SignatureException { + mDelegate.verify(key); + } + + @Override + public void verify(PublicKey key, String sigProvider) + throws CertificateException, NoSuchAlgorithmException, InvalidKeyException, + NoSuchProviderException, SignatureException { + mDelegate.verify(key, sigProvider); + } + + @Override + public String toString() { + return mDelegate.toString(); + } + + @Override + public PublicKey getPublicKey() { + return mDelegate.getPublicKey(); + } + + @Override + public X500Principal getIssuerX500Principal() { + return mDelegate.getIssuerX500Principal(); + } + + @Override + public X500Principal getSubjectX500Principal() { + return mDelegate.getSubjectX500Principal(); + } + + @Override + public List<String> getExtendedKeyUsage() throws CertificateParsingException { + return mDelegate.getExtendedKeyUsage(); + } + + @Override + public Collection<List<?>> getSubjectAlternativeNames() throws CertificateParsingException { + return mDelegate.getSubjectAlternativeNames(); + } + + @Override + public Collection<List<?>> getIssuerAlternativeNames() throws CertificateParsingException { + return mDelegate.getIssuerAlternativeNames(); + } + + @Override + @SuppressWarnings("AndroidJdkLibsChecker") + public void verify(PublicKey key, Provider sigProvider) throws CertificateException, + NoSuchAlgorithmException, InvalidKeyException, SignatureException { + mDelegate.verify(key, sigProvider); + } +} diff --git a/platform/android/java/editor/src/main/java/com/android/apksig/internal/util/FileChannelDataSource.java b/platform/android/java/editor/src/main/java/com/android/apksig/internal/util/FileChannelDataSource.java new file mode 100644 index 0000000000..e4a421a72c --- /dev/null +++ b/platform/android/java/editor/src/main/java/com/android/apksig/internal/util/FileChannelDataSource.java @@ -0,0 +1,191 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.apksig.internal.util; + +import com.android.apksig.util.DataSink; +import com.android.apksig.util.DataSource; +import java.io.IOException; +import java.io.RandomAccessFile; +import java.nio.BufferOverflowException; +import java.nio.ByteBuffer; +import java.nio.channels.FileChannel; + +/** + * {@link DataSource} backed by a {@link FileChannel} for {@link RandomAccessFile} access. + */ +public class FileChannelDataSource implements DataSource { + + private static final int MAX_READ_CHUNK_SIZE = 1024 * 1024; + + private final FileChannel mChannel; + private final long mOffset; + private final long mSize; + + /** + * Constructs a new {@code FileChannelDataSource} based on the data contained in the + * whole file. Changes to the contents of the file, including the size of the file, + * will be visible in this data source. + */ + public FileChannelDataSource(FileChannel channel) { + mChannel = channel; + mOffset = 0; + mSize = -1; + } + + /** + * Constructs a new {@code FileChannelDataSource} based on the data contained in the + * specified region of the provided file. Changes to the contents of the file will be visible in + * this data source. + * + * @throws IndexOutOfBoundsException if {@code offset} or {@code size} is negative. + */ + public FileChannelDataSource(FileChannel channel, long offset, long size) { + if (offset < 0) { + throw new IndexOutOfBoundsException("offset: " + size); + } + if (size < 0) { + throw new IndexOutOfBoundsException("size: " + size); + } + mChannel = channel; + mOffset = offset; + mSize = size; + } + + @Override + public long size() { + if (mSize == -1) { + try { + return mChannel.size(); + } catch (IOException e) { + return 0; + } + } else { + return mSize; + } + } + + @Override + public FileChannelDataSource slice(long offset, long size) { + long sourceSize = size(); + checkChunkValid(offset, size, sourceSize); + if ((offset == 0) && (size == sourceSize)) { + return this; + } + + return new FileChannelDataSource(mChannel, mOffset + offset, size); + } + + @Override + public void feed(long offset, long size, DataSink sink) throws IOException { + long sourceSize = size(); + checkChunkValid(offset, size, sourceSize); + if (size == 0) { + return; + } + + long chunkOffsetInFile = mOffset + offset; + long remaining = size; + ByteBuffer buf = ByteBuffer.allocateDirect((int) Math.min(remaining, MAX_READ_CHUNK_SIZE)); + + while (remaining > 0) { + int chunkSize = (int) Math.min(remaining, buf.capacity()); + int chunkRemaining = chunkSize; + buf.limit(chunkSize); + synchronized (mChannel) { + mChannel.position(chunkOffsetInFile); + while (chunkRemaining > 0) { + int read = mChannel.read(buf); + if (read < 0) { + throw new IOException("Unexpected EOF encountered"); + } + chunkRemaining -= read; + } + } + buf.flip(); + sink.consume(buf); + buf.clear(); + chunkOffsetInFile += chunkSize; + remaining -= chunkSize; + } + } + + @Override + public void copyTo(long offset, int size, ByteBuffer dest) throws IOException { + long sourceSize = size(); + checkChunkValid(offset, size, sourceSize); + if (size == 0) { + return; + } + if (size > dest.remaining()) { + throw new BufferOverflowException(); + } + + long offsetInFile = mOffset + offset; + int remaining = size; + int prevLimit = dest.limit(); + try { + // FileChannel.read(ByteBuffer) reads up to dest.remaining(). Thus, we need to adjust + // the buffer's limit to avoid reading more than size bytes. + dest.limit(dest.position() + size); + while (remaining > 0) { + int chunkSize; + synchronized (mChannel) { + mChannel.position(offsetInFile); + chunkSize = mChannel.read(dest); + } + offsetInFile += chunkSize; + remaining -= chunkSize; + } + } finally { + dest.limit(prevLimit); + } + } + + @Override + public ByteBuffer getByteBuffer(long offset, int size) throws IOException { + if (size < 0) { + throw new IndexOutOfBoundsException("size: " + size); + } + ByteBuffer result = ByteBuffer.allocate(size); + copyTo(offset, size, result); + result.flip(); + return result; + } + + private static void checkChunkValid(long offset, long size, long sourceSize) { + if (offset < 0) { + throw new IndexOutOfBoundsException("offset: " + offset); + } + if (size < 0) { + throw new IndexOutOfBoundsException("size: " + size); + } + if (offset > sourceSize) { + throw new IndexOutOfBoundsException( + "offset (" + offset + ") > source size (" + sourceSize + ")"); + } + long endOffset = offset + size; + if (endOffset < offset) { + throw new IndexOutOfBoundsException( + "offset (" + offset + ") + size (" + size + ") overflow"); + } + if (endOffset > sourceSize) { + throw new IndexOutOfBoundsException( + "offset (" + offset + ") + size (" + size + + ") > source size (" + sourceSize +")"); + } + } +} diff --git a/platform/android/java/editor/src/main/java/com/android/apksig/internal/util/GuaranteedEncodedFormX509Certificate.java b/platform/android/java/editor/src/main/java/com/android/apksig/internal/util/GuaranteedEncodedFormX509Certificate.java new file mode 100644 index 0000000000..958cd12aa3 --- /dev/null +++ b/platform/android/java/editor/src/main/java/com/android/apksig/internal/util/GuaranteedEncodedFormX509Certificate.java @@ -0,0 +1,68 @@ +/* + * Copyright (C) 2017 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.apksig.internal.util; + +import java.security.cert.CertificateEncodingException; +import java.security.cert.X509Certificate; +import java.util.Arrays; + +/** + * {@link X509Certificate} whose {@link #getEncoded()} returns the data provided at construction + * time. + */ +public class GuaranteedEncodedFormX509Certificate extends DelegatingX509Certificate { + private static final long serialVersionUID = 1L; + + private final byte[] mEncodedForm; + private int mHash = -1; + + public GuaranteedEncodedFormX509Certificate(X509Certificate wrapped, byte[] encodedForm) { + super(wrapped); + this.mEncodedForm = (encodedForm != null) ? encodedForm.clone() : null; + } + + @Override + public byte[] getEncoded() throws CertificateEncodingException { + return (mEncodedForm != null) ? mEncodedForm.clone() : null; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof X509Certificate)) return false; + + try { + byte[] a = this.getEncoded(); + byte[] b = ((X509Certificate) o).getEncoded(); + return Arrays.equals(a, b); + } catch (CertificateEncodingException e) { + return false; + } + } + + @Override + public int hashCode() { + if (mHash == -1) { + try { + mHash = Arrays.hashCode(this.getEncoded()); + } catch (CertificateEncodingException e) { + mHash = 0; + } + } + return mHash; + } +} diff --git a/platform/android/java/editor/src/main/java/com/android/apksig/internal/util/InclusiveIntRange.java b/platform/android/java/editor/src/main/java/com/android/apksig/internal/util/InclusiveIntRange.java new file mode 100644 index 0000000000..d7866a9edd --- /dev/null +++ b/platform/android/java/editor/src/main/java/com/android/apksig/internal/util/InclusiveIntRange.java @@ -0,0 +1,89 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.apksig.internal.util; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +/** + * Inclusive interval of integers. + */ +public class InclusiveIntRange { + private final int min; + private final int max; + + private InclusiveIntRange(int min, int max) { + this.min = min; + this.max = max; + } + + public int getMin() { + return min; + } + + public int getMax() { + return max; + } + + public static InclusiveIntRange fromTo(int min, int max) { + return new InclusiveIntRange(min, max); + } + + public static InclusiveIntRange from(int min) { + return new InclusiveIntRange(min, Integer.MAX_VALUE); + } + + public List<InclusiveIntRange> getValuesNotIn( + List<InclusiveIntRange> sortedNonOverlappingRanges) { + if (sortedNonOverlappingRanges.isEmpty()) { + return Collections.singletonList(this); + } + + int testValue = min; + List<InclusiveIntRange> result = null; + for (InclusiveIntRange range : sortedNonOverlappingRanges) { + int rangeMax = range.max; + if (testValue > rangeMax) { + continue; + } + int rangeMin = range.min; + if (testValue < range.min) { + if (result == null) { + result = new ArrayList<>(); + } + result.add(fromTo(testValue, rangeMin - 1)); + } + if (rangeMax >= max) { + return (result != null) ? result : Collections.emptyList(); + } + testValue = rangeMax + 1; + } + if (testValue <= max) { + if (result == null) { + result = new ArrayList<>(1); + } + result.add(fromTo(testValue, max)); + } + return (result != null) ? result : Collections.emptyList(); + } + + @Override + public String toString() { + return "[" + min + ", " + ((max < Integer.MAX_VALUE) ? (max + "]") : "\u221e)"); + } +} diff --git a/platform/android/java/editor/src/main/java/com/android/apksig/internal/util/MessageDigestSink.java b/platform/android/java/editor/src/main/java/com/android/apksig/internal/util/MessageDigestSink.java new file mode 100644 index 0000000000..733dd563ce --- /dev/null +++ b/platform/android/java/editor/src/main/java/com/android/apksig/internal/util/MessageDigestSink.java @@ -0,0 +1,51 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.apksig.internal.util; + +import com.android.apksig.util.DataSink; +import java.nio.ByteBuffer; +import java.security.MessageDigest; + +/** + * Data sink which feeds all received data into the associated {@link MessageDigest} instances. Each + * {@code MessageDigest} instance receives the same data. + */ +public class MessageDigestSink implements DataSink { + + private final MessageDigest[] mMessageDigests; + + public MessageDigestSink(MessageDigest[] digests) { + mMessageDigests = digests; + } + + @Override + public void consume(byte[] buf, int offset, int length) { + for (MessageDigest md : mMessageDigests) { + md.update(buf, offset, length); + } + } + + @Override + public void consume(ByteBuffer buf) { + int originalPosition = buf.position(); + for (MessageDigest md : mMessageDigests) { + // Reset the position back to the original because the previous iteration's + // MessageDigest.update set the buffer's position to the buffer's limit. + buf.position(originalPosition); + md.update(buf); + } + } +} diff --git a/platform/android/java/editor/src/main/java/com/android/apksig/internal/util/OutputStreamDataSink.java b/platform/android/java/editor/src/main/java/com/android/apksig/internal/util/OutputStreamDataSink.java new file mode 100644 index 0000000000..f1b5ac6c5e --- /dev/null +++ b/platform/android/java/editor/src/main/java/com/android/apksig/internal/util/OutputStreamDataSink.java @@ -0,0 +1,77 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.apksig.internal.util; + +import com.android.apksig.util.DataSink; +import java.io.IOException; +import java.io.OutputStream; +import java.nio.ByteBuffer; + +/** + * {@link DataSink} which outputs received data into the associated {@link OutputStream}. + */ +public class OutputStreamDataSink implements DataSink { + + private static final int MAX_READ_CHUNK_SIZE = 65536; + + private final OutputStream mOut; + + /** + * Constructs a new {@code OutputStreamDataSink} which outputs received data into the provided + * {@link OutputStream}. + */ + public OutputStreamDataSink(OutputStream out) { + if (out == null) { + throw new NullPointerException("out == null"); + } + mOut = out; + } + + /** + * Returns {@link OutputStream} into which this data sink outputs received data. + */ + public OutputStream getOutputStream() { + return mOut; + } + + @Override + public void consume(byte[] buf, int offset, int length) throws IOException { + mOut.write(buf, offset, length); + } + + @Override + public void consume(ByteBuffer buf) throws IOException { + if (!buf.hasRemaining()) { + return; + } + + if (buf.hasArray()) { + mOut.write( + buf.array(), + buf.arrayOffset() + buf.position(), + buf.remaining()); + buf.position(buf.limit()); + } else { + byte[] tmp = new byte[Math.min(buf.remaining(), MAX_READ_CHUNK_SIZE)]; + while (buf.hasRemaining()) { + int chunkSize = Math.min(buf.remaining(), tmp.length); + buf.get(tmp, 0, chunkSize); + mOut.write(tmp, 0, chunkSize); + } + } + } +} diff --git a/platform/android/java/editor/src/main/java/com/android/apksig/internal/util/Pair.java b/platform/android/java/editor/src/main/java/com/android/apksig/internal/util/Pair.java new file mode 100644 index 0000000000..7f9ee520f1 --- /dev/null +++ b/platform/android/java/editor/src/main/java/com/android/apksig/internal/util/Pair.java @@ -0,0 +1,81 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.apksig.internal.util; + +/** + * Pair of two elements. + */ +public final class Pair<A, B> { + private final A mFirst; + private final B mSecond; + + private Pair(A first, B second) { + mFirst = first; + mSecond = second; + } + + public static <A, B> Pair<A, B> of(A first, B second) { + return new Pair<A, B>(first, second); + } + + public A getFirst() { + return mFirst; + } + + public B getSecond() { + return mSecond; + } + + @Override + public int hashCode() { + final int prime = 31; + int result = 1; + result = prime * result + ((mFirst == null) ? 0 : mFirst.hashCode()); + result = prime * result + ((mSecond == null) ? 0 : mSecond.hashCode()); + return result; + } + + @Override + public boolean equals(Object obj) { + if (this == obj) { + return true; + } + if (obj == null) { + return false; + } + if (getClass() != obj.getClass()) { + return false; + } + @SuppressWarnings("rawtypes") + Pair other = (Pair) obj; + if (mFirst == null) { + if (other.mFirst != null) { + return false; + } + } else if (!mFirst.equals(other.mFirst)) { + return false; + } + if (mSecond == null) { + if (other.mSecond != null) { + return false; + } + } else if (!mSecond.equals(other.mSecond)) { + return false; + } + return true; + } +} diff --git a/platform/android/java/editor/src/main/java/com/android/apksig/internal/util/RandomAccessFileDataSink.java b/platform/android/java/editor/src/main/java/com/android/apksig/internal/util/RandomAccessFileDataSink.java new file mode 100644 index 0000000000..bbd2d14a8a --- /dev/null +++ b/platform/android/java/editor/src/main/java/com/android/apksig/internal/util/RandomAccessFileDataSink.java @@ -0,0 +1,104 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.apksig.internal.util; + +import com.android.apksig.util.DataSink; +import java.io.IOException; +import java.io.RandomAccessFile; +import java.nio.ByteBuffer; +import java.nio.channels.FileChannel; + +/** + * {@link DataSink} which outputs received data into the associated file, sequentially. + */ +public class RandomAccessFileDataSink implements DataSink { + + private final RandomAccessFile mFile; + private final FileChannel mFileChannel; + private long mPosition; + + /** + * Constructs a new {@code RandomAccessFileDataSink} which stores output starting from the + * beginning of the provided file. + */ + public RandomAccessFileDataSink(RandomAccessFile file) { + this(file, 0); + } + + /** + * Constructs a new {@code RandomAccessFileDataSink} which stores output starting from the + * specified position of the provided file. + */ + public RandomAccessFileDataSink(RandomAccessFile file, long startPosition) { + if (file == null) { + throw new NullPointerException("file == null"); + } + if (startPosition < 0) { + throw new IllegalArgumentException("startPosition: " + startPosition); + } + mFile = file; + mFileChannel = file.getChannel(); + mPosition = startPosition; + } + + /** + * Returns the underlying {@link RandomAccessFile}. + */ + public RandomAccessFile getFile() { + return mFile; + } + + @Override + public void consume(byte[] buf, int offset, int length) throws IOException { + if (offset < 0) { + // Must perform this check here because RandomAccessFile.write doesn't throw when offset + // is negative but length is 0 + throw new IndexOutOfBoundsException("offset: " + offset); + } + if (offset > buf.length) { + // Must perform this check here because RandomAccessFile.write doesn't throw when offset + // is too large but length is 0 + throw new IndexOutOfBoundsException( + "offset: " + offset + ", buf.length: " + buf.length); + } + if (length == 0) { + return; + } + + synchronized (mFile) { + mFile.seek(mPosition); + mFile.write(buf, offset, length); + mPosition += length; + } + } + + @Override + public void consume(ByteBuffer buf) throws IOException { + int length = buf.remaining(); + if (length == 0) { + return; + } + + synchronized (mFile) { + mFile.seek(mPosition); + while (buf.hasRemaining()) { + mFileChannel.write(buf); + } + mPosition += length; + } + } +} diff --git a/platform/android/java/editor/src/main/java/com/android/apksig/internal/util/TeeDataSink.java b/platform/android/java/editor/src/main/java/com/android/apksig/internal/util/TeeDataSink.java new file mode 100644 index 0000000000..2e46f18b05 --- /dev/null +++ b/platform/android/java/editor/src/main/java/com/android/apksig/internal/util/TeeDataSink.java @@ -0,0 +1,51 @@ +/* + * Copyright (C) 2017 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.apksig.internal.util; + +import com.android.apksig.util.DataSink; +import java.io.IOException; +import java.nio.ByteBuffer; + +/** + * {@link DataSink} which copies provided input into each of the sinks provided to it. + */ +public class TeeDataSink implements DataSink { + + private final DataSink[] mSinks; + + public TeeDataSink(DataSink[] sinks) { + mSinks = sinks; + } + + @Override + public void consume(byte[] buf, int offset, int length) throws IOException { + for (DataSink sink : mSinks) { + sink.consume(buf, offset, length); + } + } + + @Override + public void consume(ByteBuffer buf) throws IOException { + int originalPosition = buf.position(); + for (int i = 0; i < mSinks.length; i++) { + if (i > 0) { + buf.position(originalPosition); + } + mSinks[i].consume(buf); + } + } +} diff --git a/platform/android/java/editor/src/main/java/com/android/apksig/internal/util/VerityTreeBuilder.java b/platform/android/java/editor/src/main/java/com/android/apksig/internal/util/VerityTreeBuilder.java new file mode 100644 index 0000000000..81026ba5ff --- /dev/null +++ b/platform/android/java/editor/src/main/java/com/android/apksig/internal/util/VerityTreeBuilder.java @@ -0,0 +1,325 @@ +/* + * Copyright (C) 2017 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.apksig.internal.util; + +import static java.util.concurrent.TimeUnit.MILLISECONDS; + +import com.android.apksig.internal.zip.ZipUtils; +import com.android.apksig.util.DataSink; +import com.android.apksig.util.DataSource; +import com.android.apksig.util.DataSources; + +import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; + +import java.util.ArrayList; +import java.util.concurrent.ArrayBlockingQueue; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Phaser; +import java.util.concurrent.ThreadPoolExecutor; +import java.util.concurrent.TimeUnit; + +/** + * VerityTreeBuilder is used to generate the root hash of verity tree built from the input file. + * The root hash can be used on device for on-access verification. The tree itself is reproducible + * on device, and is not shipped with the APK. + */ +public class VerityTreeBuilder implements AutoCloseable { + + /** + * Maximum size (in bytes) of each node of the tree. + */ + private final static int CHUNK_SIZE = 4096; + /** + * Maximum parallelism while calculating digests. + */ + private final static int DIGEST_PARALLELISM = Math.min(32, + Runtime.getRuntime().availableProcessors()); + /** + * Queue size. + */ + private final static int MAX_OUTSTANDING_CHUNKS = 4; + /** + * Typical prefetch size. + */ + private final static int MAX_PREFETCH_CHUNKS = 1024; + /** + * Minimum chunks to be processed by a single worker task. + */ + private final static int MIN_CHUNKS_PER_WORKER = 8; + + /** + * Digest algorithm (JCA Digest algorithm name) used in the tree. + */ + private final static String JCA_ALGORITHM = "SHA-256"; + + /** + * Optional salt to apply before each digestion. + */ + private final byte[] mSalt; + + private final MessageDigest mMd; + + private final ExecutorService mExecutor = + new ThreadPoolExecutor(DIGEST_PARALLELISM, DIGEST_PARALLELISM, + 0L, MILLISECONDS, + new ArrayBlockingQueue<>(MAX_OUTSTANDING_CHUNKS), + new ThreadPoolExecutor.CallerRunsPolicy()); + + public VerityTreeBuilder(byte[] salt) throws NoSuchAlgorithmException { + mSalt = salt; + mMd = getNewMessageDigest(); + } + + @Override + public void close() { + mExecutor.shutdownNow(); + } + + /** + * Returns the root hash of the APK verity tree built from ZIP blocks. + * + * Specifically, APK verity tree is built from the APK, but as if the APK Signing Block (which + * must be page aligned) and the "Central Directory offset" field in End of Central Directory + * are skipped. + */ + public byte[] generateVerityTreeRootHash(DataSource beforeApkSigningBlock, + DataSource centralDir, DataSource eocd) throws IOException { + if (beforeApkSigningBlock.size() % CHUNK_SIZE != 0) { + throw new IllegalStateException("APK Signing Block size not a multiple of " + CHUNK_SIZE + + ": " + beforeApkSigningBlock.size()); + } + + // Ensure that, when digesting, ZIP End of Central Directory record's Central Directory + // offset field is treated as pointing to the offset at which the APK Signing Block will + // start. + long centralDirOffsetForDigesting = beforeApkSigningBlock.size(); + ByteBuffer eocdBuf = ByteBuffer.allocate((int) eocd.size()); + eocdBuf.order(ByteOrder.LITTLE_ENDIAN); + eocd.copyTo(0, (int) eocd.size(), eocdBuf); + eocdBuf.flip(); + ZipUtils.setZipEocdCentralDirectoryOffset(eocdBuf, centralDirOffsetForDigesting); + + return generateVerityTreeRootHash(new ChainedDataSource(beforeApkSigningBlock, centralDir, + DataSources.asDataSource(eocdBuf))); + } + + /** + * Returns the root hash of the verity tree built from the data source. + */ + public byte[] generateVerityTreeRootHash(DataSource fileSource) throws IOException { + ByteBuffer verityBuffer = generateVerityTree(fileSource); + return getRootHashFromTree(verityBuffer); + } + + /** + * Returns the byte buffer that contains the whole verity tree. + * + * The tree is built bottom up. The bottom level has 256-bit digest for each 4 KB block in the + * input file. If the total size is larger than 4 KB, take this level as input and repeat the + * same procedure, until the level is within 4 KB. If salt is given, it will apply to each + * digestion before the actual data. + * + * The returned root hash is calculated from the last level of 4 KB chunk, similarly with salt. + * + * The tree is currently stored only in memory and is never written out. Nevertheless, it is + * the actual verity tree format on disk, and is supposed to be re-generated on device. + */ + public ByteBuffer generateVerityTree(DataSource fileSource) throws IOException { + int digestSize = mMd.getDigestLength(); + + // Calculate the summed area table of level size. In other word, this is the offset + // table of each level, plus the next non-existing level. + int[] levelOffset = calculateLevelOffset(fileSource.size(), digestSize); + + ByteBuffer verityBuffer = ByteBuffer.allocate(levelOffset[levelOffset.length - 1]); + + // Generate the hash tree bottom-up. + for (int i = levelOffset.length - 2; i >= 0; i--) { + DataSink middleBufferSink = new ByteBufferSink( + slice(verityBuffer, levelOffset[i], levelOffset[i + 1])); + DataSource src; + if (i == levelOffset.length - 2) { + src = fileSource; + digestDataByChunks(src, middleBufferSink); + } else { + src = DataSources.asDataSource(slice(verityBuffer.asReadOnlyBuffer(), + levelOffset[i + 1], levelOffset[i + 2])); + digestDataByChunks(src, middleBufferSink); + } + + // If the output is not full chunk, pad with 0s. + long totalOutput = divideRoundup(src.size(), CHUNK_SIZE) * digestSize; + int incomplete = (int) (totalOutput % CHUNK_SIZE); + if (incomplete > 0) { + byte[] padding = new byte[CHUNK_SIZE - incomplete]; + middleBufferSink.consume(padding, 0, padding.length); + } + } + return verityBuffer; + } + + /** + * Returns the digested root hash from the top level (only page) of a verity tree. + */ + public byte[] getRootHashFromTree(ByteBuffer verityBuffer) throws IOException { + ByteBuffer firstPage = slice(verityBuffer.asReadOnlyBuffer(), 0, CHUNK_SIZE); + return saltedDigest(firstPage); + } + + /** + * Returns an array of summed area table of level size in the verity tree. In other words, the + * returned array is offset of each level in the verity tree file format, plus an additional + * offset of the next non-existing level (i.e. end of the last level + 1). Thus the array size + * is level + 1. + */ + private static int[] calculateLevelOffset(long dataSize, int digestSize) { + // Compute total size of each level, bottom to top. + ArrayList<Long> levelSize = new ArrayList<>(); + while (true) { + long chunkCount = divideRoundup(dataSize, CHUNK_SIZE); + long size = CHUNK_SIZE * divideRoundup(chunkCount * digestSize, CHUNK_SIZE); + levelSize.add(size); + if (chunkCount * digestSize <= CHUNK_SIZE) { + break; + } + dataSize = chunkCount * digestSize; + } + + // Reverse and convert to summed area table. + int[] levelOffset = new int[levelSize.size() + 1]; + levelOffset[0] = 0; + for (int i = 0; i < levelSize.size(); i++) { + // We don't support verity tree if it is larger then Integer.MAX_VALUE. + levelOffset[i + 1] = levelOffset[i] + Math.toIntExact( + levelSize.get(levelSize.size() - i - 1)); + } + return levelOffset; + } + + /** + * Digest data source by chunks then feeds them to the sink one by one. If the last unit is + * less than the chunk size and padding is desired, feed with extra padding 0 to fill up the + * chunk before digesting. + */ + private void digestDataByChunks(DataSource dataSource, DataSink dataSink) throws IOException { + final long size = dataSource.size(); + final int chunks = (int) divideRoundup(size, CHUNK_SIZE); + + /** Single IO operation size, in chunks. */ + final int ioSizeChunks = MAX_PREFETCH_CHUNKS; + + final byte[][] hashes = new byte[chunks][]; + + Phaser tasks = new Phaser(1); + + // Reading the input file as fast as we can. + final long maxReadSize = ioSizeChunks * CHUNK_SIZE; + + long readOffset = 0; + int startChunkIndex = 0; + while (readOffset < size) { + final long readLimit = Math.min(readOffset + maxReadSize, size); + final int readSize = (int) (readLimit - readOffset); + final int bufferSizeChunks = (int) divideRoundup(readSize, CHUNK_SIZE); + + // Overllocating to zero-pad last chunk. + // With 4MiB block size, 32 threads and 4 queue size we might allocate up to 144MiB. + final ByteBuffer buffer = ByteBuffer.allocate(bufferSizeChunks * CHUNK_SIZE); + dataSource.copyTo(readOffset, readSize, buffer); + buffer.rewind(); + + final int readChunkIndex = startChunkIndex; + Runnable task = () -> { + final MessageDigest md = cloneMessageDigest(); + for (int offset = 0, finish = buffer.capacity(), chunkIndex = readChunkIndex; + offset < finish; offset += CHUNK_SIZE, ++chunkIndex) { + ByteBuffer chunk = slice(buffer, offset, offset + CHUNK_SIZE); + hashes[chunkIndex] = saltedDigest(md, chunk); + } + tasks.arriveAndDeregister(); + }; + tasks.register(); + mExecutor.execute(task); + + startChunkIndex += bufferSizeChunks; + readOffset += readSize; + } + + // Waiting for the tasks to complete. + tasks.arriveAndAwaitAdvance(); + + // Streaming hashes back. + for (byte[] hash : hashes) { + dataSink.consume(hash, 0, hash.length); + } + } + + /** Returns the digest of data with salt prepended. */ + private byte[] saltedDigest(ByteBuffer data) { + return saltedDigest(mMd, data); + } + + private byte[] saltedDigest(MessageDigest md, ByteBuffer data) { + md.reset(); + if (mSalt != null) { + md.update(mSalt); + } + md.update(data); + return md.digest(); + } + + /** Divides a number and round up to the closest integer. */ + private static long divideRoundup(long dividend, long divisor) { + return (dividend + divisor - 1) / divisor; + } + + /** Returns a slice of the buffer with shared the content. */ + private static ByteBuffer slice(ByteBuffer buffer, int begin, int end) { + ByteBuffer b = buffer.duplicate(); + b.position(0); // to ensure position <= limit invariant. + b.limit(end); + b.position(begin); + return b.slice(); + } + + /** + * Obtains a new instance of the message digest algorithm. + */ + private static MessageDigest getNewMessageDigest() throws NoSuchAlgorithmException { + return MessageDigest.getInstance(JCA_ALGORITHM); + } + + /** + * Clones the existing message digest, or creates a new instance if clone is unavailable. + */ + private MessageDigest cloneMessageDigest() { + try { + return (MessageDigest) mMd.clone(); + } catch (CloneNotSupportedException ignored) { + try { + return getNewMessageDigest(); + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException( + "Failed to obtain an instance of a previously available message digest", e); + } + } + } +} diff --git a/platform/android/java/editor/src/main/java/com/android/apksig/internal/util/X509CertificateUtils.java b/platform/android/java/editor/src/main/java/com/android/apksig/internal/util/X509CertificateUtils.java new file mode 100644 index 0000000000..ca6271df2a --- /dev/null +++ b/platform/android/java/editor/src/main/java/com/android/apksig/internal/util/X509CertificateUtils.java @@ -0,0 +1,282 @@ +/* + * Copyright (C) 2018 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.apksig.internal.util; + +import com.android.apksig.internal.asn1.Asn1BerParser; +import com.android.apksig.internal.asn1.Asn1DecodingException; +import com.android.apksig.internal.asn1.Asn1DerEncoder; +import com.android.apksig.internal.asn1.Asn1EncodingException; +import com.android.apksig.internal.x509.Certificate; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.nio.ByteBuffer; +import java.security.cert.CertificateException; +import java.security.cert.CertificateFactory; +import java.security.cert.X509Certificate; +import java.util.ArrayList; +import java.util.Base64; +import java.util.Collection; + +/** + * Provides methods to generate {@code X509Certificate}s from their encoded form. These methods + * can be used to generate certificates that would be rejected by the Java {@code + * CertificateFactory}. + */ +public class X509CertificateUtils { + + private static volatile CertificateFactory sCertFactory = null; + + // The PEM certificate header and footer as specified in RFC 7468: + // There is exactly one space character (SP) separating the "BEGIN" or + // "END" from the label. There are exactly five hyphen-minus (also + // known as dash) characters ("-") on both ends of the encapsulation + // boundaries, no more, no less. + public static final byte[] BEGIN_CERT_HEADER = "-----BEGIN CERTIFICATE-----".getBytes(); + public static final byte[] END_CERT_FOOTER = "-----END CERTIFICATE-----".getBytes(); + + private static void buildCertFactory() { + if (sCertFactory != null) { + return; + } + + buildCertFactoryHelper(); + } + + private static synchronized void buildCertFactoryHelper() { + if (sCertFactory != null) { + return; + } + try { + sCertFactory = CertificateFactory.getInstance("X.509"); + } catch (CertificateException e) { + throw new RuntimeException("Failed to create X.509 CertificateFactory", e); + } + } + + /** + * Generates an {@code X509Certificate} from the {@code InputStream}. + * + * @throws CertificateException if the {@code InputStream} cannot be decoded to a valid + * certificate. + */ + public static X509Certificate generateCertificate(InputStream in) throws CertificateException { + byte[] encodedForm; + try { + encodedForm = ByteStreams.toByteArray(in); + } catch (IOException e) { + throw new CertificateException("Failed to parse certificate", e); + } + return generateCertificate(encodedForm); + } + + /** + * Generates an {@code X509Certificate} from the encoded form. + * + * @throws CertificateException if the encodedForm cannot be decoded to a valid certificate. + */ + public static X509Certificate generateCertificate(byte[] encodedForm) + throws CertificateException { + buildCertFactory(); + return generateCertificate(encodedForm, sCertFactory); + } + + /** + * Generates an {@code X509Certificate} from the encoded form using the provided + * {@code CertificateFactory}. + * + * @throws CertificateException if the encodedForm cannot be decoded to a valid certificate. + */ + public static X509Certificate generateCertificate(byte[] encodedForm, + CertificateFactory certFactory) throws CertificateException { + X509Certificate certificate; + try { + certificate = (X509Certificate) certFactory.generateCertificate( + new ByteArrayInputStream(encodedForm)); + return certificate; + } catch (CertificateException e) { + // This could be expected if the certificate is encoded using a BER encoding that does + // not use the minimum number of bytes to represent the length of the contents; attempt + // to decode the certificate using the BER parser and re-encode using the DER encoder + // below. + } + try { + // Some apps were previously signed with a BER encoded certificate that now results + // in exceptions from the CertificateFactory generateCertificate(s) methods. Since + // the original BER encoding of the certificate is used as the signature for these + // apps that original encoding must be maintained when signing updated versions of + // these apps and any new apps that may require capabilities guarded by the + // signature. To maintain the same signature the BER parser can be used to parse + // the certificate, then it can be re-encoded to its DER equivalent which is + // accepted by the generateCertificate method. The positions in the ByteBuffer can + // then be used with the GuaranteedEncodedFormX509Certificate object to ensure the + // getEncoded method returns the original signature of the app. + ByteBuffer encodedCertBuffer = getNextDEREncodedCertificateBlock( + ByteBuffer.wrap(encodedForm)); + int startingPos = encodedCertBuffer.position(); + Certificate reencodedCert = Asn1BerParser.parse(encodedCertBuffer, Certificate.class); + byte[] reencodedForm = Asn1DerEncoder.encode(reencodedCert); + certificate = (X509Certificate) certFactory.generateCertificate( + new ByteArrayInputStream(reencodedForm)); + // If the reencodedForm is successfully accepted by the CertificateFactory then copy the + // original encoding from the ByteBuffer and use that encoding in the Guaranteed object. + byte[] originalEncoding = new byte[encodedCertBuffer.position() - startingPos]; + encodedCertBuffer.position(startingPos); + encodedCertBuffer.get(originalEncoding); + GuaranteedEncodedFormX509Certificate guaranteedEncodedCert = + new GuaranteedEncodedFormX509Certificate(certificate, originalEncoding); + return guaranteedEncodedCert; + } catch (Asn1DecodingException | Asn1EncodingException | CertificateException e) { + throw new CertificateException("Failed to parse certificate", e); + } + } + + /** + * Generates a {@code Collection} of {@code Certificate} objects from the encoded {@code + * InputStream}. + * + * @throws CertificateException if the InputStream cannot be decoded to zero or more valid + * {@code Certificate} objects. + */ + public static Collection<? extends java.security.cert.Certificate> generateCertificates( + InputStream in) throws CertificateException { + buildCertFactory(); + return generateCertificates(in, sCertFactory); + } + + /** + * Generates a {@code Collection} of {@code Certificate} objects from the encoded {@code + * InputStream} using the provided {@code CertificateFactory}. + * + * @throws CertificateException if the InputStream cannot be decoded to zero or more valid + * {@code Certificates} objects. + */ + public static Collection<? extends java.security.cert.Certificate> generateCertificates( + InputStream in, CertificateFactory certFactory) throws CertificateException { + // Since the InputStream is not guaranteed to support mark / reset operations first read it + // into a byte array to allow using the BER parser / DER encoder if it cannot be read by + // the CertificateFactory. + byte[] encodedCerts; + try { + encodedCerts = ByteStreams.toByteArray(in); + } catch (IOException e) { + throw new CertificateException("Failed to read the input stream", e); + } + try { + return certFactory.generateCertificates(new ByteArrayInputStream(encodedCerts)); + } catch (CertificateException e) { + // This could be expected if the certificates are encoded using a BER encoding that does + // not use the minimum number of bytes to represent the length of the contents; attempt + // to decode the certificates using the BER parser and re-encode using the DER encoder + // below. + } + try { + Collection<X509Certificate> certificates = new ArrayList<>(1); + ByteBuffer encodedCertsBuffer = ByteBuffer.wrap(encodedCerts); + while (encodedCertsBuffer.hasRemaining()) { + ByteBuffer certBuffer = getNextDEREncodedCertificateBlock(encodedCertsBuffer); + int startingPos = certBuffer.position(); + Certificate reencodedCert = Asn1BerParser.parse(certBuffer, Certificate.class); + byte[] reencodedForm = Asn1DerEncoder.encode(reencodedCert); + X509Certificate certificate = (X509Certificate) certFactory.generateCertificate( + new ByteArrayInputStream(reencodedForm)); + byte[] originalEncoding = new byte[certBuffer.position() - startingPos]; + certBuffer.position(startingPos); + certBuffer.get(originalEncoding); + GuaranteedEncodedFormX509Certificate guaranteedEncodedCert = + new GuaranteedEncodedFormX509Certificate(certificate, originalEncoding); + certificates.add(guaranteedEncodedCert); + } + return certificates; + } catch (Asn1DecodingException | Asn1EncodingException e) { + throw new CertificateException("Failed to parse certificates", e); + } + } + + /** + * Parses the provided ByteBuffer to obtain the next certificate in DER encoding. If the buffer + * does not begin with the PEM certificate header then it is returned with the assumption that + * it is already DER encoded. If the buffer does begin with the PEM certificate header then the + * certificate data is read from the buffer until the PEM certificate footer is reached; this + * data is then base64 decoded and returned in a new ByteBuffer. + * + * If the buffer is in PEM format then the position of the buffer is moved to the end of the + * current certificate; if the buffer is already DER encoded then the position of the buffer is + * not modified. + * + * @throws CertificateException if the buffer contains the PEM certificate header but does not + * contain the expected footer. + */ + private static ByteBuffer getNextDEREncodedCertificateBlock(ByteBuffer certificateBuffer) + throws CertificateException { + if (certificateBuffer == null) { + throw new NullPointerException("The certificateBuffer cannot be null"); + } + // if the buffer does not contain enough data for the PEM cert header then just return the + // provided buffer. + if (certificateBuffer.remaining() < BEGIN_CERT_HEADER.length) { + return certificateBuffer; + } + certificateBuffer.mark(); + for (int i = 0; i < BEGIN_CERT_HEADER.length; i++) { + if (certificateBuffer.get() != BEGIN_CERT_HEADER[i]) { + certificateBuffer.reset(); + return certificateBuffer; + } + } + StringBuilder pemEncoding = new StringBuilder(); + while (certificateBuffer.hasRemaining()) { + char encodedChar = (char) certificateBuffer.get(); + // if the current character is a '-' then the beginning of the footer has been reached + if (encodedChar == '-') { + break; + } else if (Character.isWhitespace(encodedChar)) { + continue; + } else { + pemEncoding.append(encodedChar); + } + } + // start from the second index in the certificate footer since the first '-' should have + // been consumed above. + for (int i = 1; i < END_CERT_FOOTER.length; i++) { + if (!certificateBuffer.hasRemaining()) { + throw new CertificateException( + "The provided input contains the PEM certificate header but does not " + + "contain sufficient data for the footer"); + } + if (certificateBuffer.get() != END_CERT_FOOTER[i]) { + throw new CertificateException( + "The provided input contains the PEM certificate header without a " + + "valid certificate footer"); + } + } + byte[] derEncoding = Base64.getDecoder().decode(pemEncoding.toString()); + // consume any trailing whitespace in the byte buffer + int nextEncodedChar = certificateBuffer.position(); + while (certificateBuffer.hasRemaining()) { + char trailingChar = (char) certificateBuffer.get(); + if (Character.isWhitespace(trailingChar)) { + nextEncodedChar++; + } else { + break; + } + } + certificateBuffer.position(nextEncodedChar); + return ByteBuffer.wrap(derEncoding); + } +} diff --git a/platform/android/java/editor/src/main/java/com/android/apksig/internal/x509/AttributeTypeAndValue.java b/platform/android/java/editor/src/main/java/com/android/apksig/internal/x509/AttributeTypeAndValue.java new file mode 100644 index 0000000000..077db232ba --- /dev/null +++ b/platform/android/java/editor/src/main/java/com/android/apksig/internal/x509/AttributeTypeAndValue.java @@ -0,0 +1,35 @@ +/* + * Copyright (C) 2018 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.apksig.internal.x509; + +import com.android.apksig.internal.asn1.Asn1Class; +import com.android.apksig.internal.asn1.Asn1Field; +import com.android.apksig.internal.asn1.Asn1OpaqueObject; +import com.android.apksig.internal.asn1.Asn1Type; + +/** + * {@code AttributeTypeAndValue} as specified in RFC 5280. + */ +@Asn1Class(type = Asn1Type.SEQUENCE) +public class AttributeTypeAndValue { + + @Asn1Field(index = 0, type = Asn1Type.OBJECT_IDENTIFIER) + public String attrType; + + @Asn1Field(index = 1, type = Asn1Type.ANY) + public Asn1OpaqueObject attrValue; +}
\ No newline at end of file diff --git a/platform/android/java/editor/src/main/java/com/android/apksig/internal/x509/Certificate.java b/platform/android/java/editor/src/main/java/com/android/apksig/internal/x509/Certificate.java new file mode 100644 index 0000000000..70ff6a163c --- /dev/null +++ b/platform/android/java/editor/src/main/java/com/android/apksig/internal/x509/Certificate.java @@ -0,0 +1,105 @@ +/* + * Copyright (C) 2018 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.apksig.internal.x509; + +import com.android.apksig.internal.asn1.Asn1Class; +import com.android.apksig.internal.asn1.Asn1Field; +import com.android.apksig.internal.asn1.Asn1OpaqueObject; +import com.android.apksig.internal.asn1.Asn1Type; +import com.android.apksig.internal.pkcs7.AlgorithmIdentifier; +import com.android.apksig.internal.pkcs7.IssuerAndSerialNumber; +import com.android.apksig.internal.pkcs7.SignerIdentifier; +import com.android.apksig.internal.util.ByteBufferUtils; +import com.android.apksig.internal.util.GuaranteedEncodedFormX509Certificate; +import com.android.apksig.internal.util.X509CertificateUtils; + +import java.math.BigInteger; +import java.nio.ByteBuffer; +import java.security.cert.CertificateException; +import java.security.cert.X509Certificate; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.List; + +import javax.security.auth.x500.X500Principal; + +/** + * X509 {@code Certificate} as specified in RFC 5280. + */ +@Asn1Class(type = Asn1Type.SEQUENCE) +public class Certificate { + @Asn1Field(index = 0, type = Asn1Type.SEQUENCE) + public TBSCertificate certificate; + + @Asn1Field(index = 1, type = Asn1Type.SEQUENCE) + public AlgorithmIdentifier signatureAlgorithm; + + @Asn1Field(index = 2, type = Asn1Type.BIT_STRING) + public ByteBuffer signature; + + public static X509Certificate findCertificate( + Collection<X509Certificate> certs, SignerIdentifier id) { + for (X509Certificate cert : certs) { + if (isMatchingCerticicate(cert, id)) { + return cert; + } + } + return null; + } + + private static boolean isMatchingCerticicate(X509Certificate cert, SignerIdentifier id) { + if (id.issuerAndSerialNumber == null) { + // Android doesn't support any other means of identifying the signing certificate + return false; + } + IssuerAndSerialNumber issuerAndSerialNumber = id.issuerAndSerialNumber; + byte[] encodedIssuer = + ByteBufferUtils.toByteArray(issuerAndSerialNumber.issuer.getEncoded()); + X500Principal idIssuer = new X500Principal(encodedIssuer); + BigInteger idSerialNumber = issuerAndSerialNumber.certificateSerialNumber; + return idSerialNumber.equals(cert.getSerialNumber()) + && idIssuer.equals(cert.getIssuerX500Principal()); + } + + public static List<X509Certificate> parseCertificates( + List<Asn1OpaqueObject> encodedCertificates) throws CertificateException { + if (encodedCertificates.isEmpty()) { + return Collections.emptyList(); + } + + List<X509Certificate> result = new ArrayList<>(encodedCertificates.size()); + for (int i = 0; i < encodedCertificates.size(); i++) { + Asn1OpaqueObject encodedCertificate = encodedCertificates.get(i); + X509Certificate certificate; + byte[] encodedForm = ByteBufferUtils.toByteArray(encodedCertificate.getEncoded()); + try { + certificate = X509CertificateUtils.generateCertificate(encodedForm); + } catch (CertificateException e) { + throw new CertificateException("Failed to parse certificate #" + (i + 1), e); + } + // Wrap the cert so that the result's getEncoded returns exactly the original + // encoded form. Without this, getEncoded may return a different form from what was + // stored in the signature. This is because some X509Certificate(Factory) + // implementations re-encode certificates and/or some implementations of + // X509Certificate.getEncoded() re-encode certificates. + certificate = new GuaranteedEncodedFormX509Certificate(certificate, encodedForm); + result.add(certificate); + } + return result; + } +} diff --git a/platform/android/java/editor/src/main/java/com/android/apksig/internal/x509/Extension.java b/platform/android/java/editor/src/main/java/com/android/apksig/internal/x509/Extension.java new file mode 100644 index 0000000000..bf37c1e824 --- /dev/null +++ b/platform/android/java/editor/src/main/java/com/android/apksig/internal/x509/Extension.java @@ -0,0 +1,38 @@ +/* + * Copyright (C) 2018 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.apksig.internal.x509; + +import com.android.apksig.internal.asn1.Asn1Class; +import com.android.apksig.internal.asn1.Asn1Field; +import com.android.apksig.internal.asn1.Asn1Type; + +import java.nio.ByteBuffer; + +/** + * X509 {@code Extension} as specified in RFC 5280. + */ +@Asn1Class(type = Asn1Type.SEQUENCE) +public class Extension { + @Asn1Field(index = 0, type = Asn1Type.OBJECT_IDENTIFIER) + public String extensionID; + + @Asn1Field(index = 1, type = Asn1Type.BOOLEAN, optional = true) + public boolean isCritial = false; + + @Asn1Field(index = 2, type = Asn1Type.OCTET_STRING) + public ByteBuffer extensionValue; +} diff --git a/platform/android/java/editor/src/main/java/com/android/apksig/internal/x509/Name.java b/platform/android/java/editor/src/main/java/com/android/apksig/internal/x509/Name.java new file mode 100644 index 0000000000..08400d6814 --- /dev/null +++ b/platform/android/java/editor/src/main/java/com/android/apksig/internal/x509/Name.java @@ -0,0 +1,34 @@ +/* + * Copyright (C) 2018 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.apksig.internal.x509; + +import com.android.apksig.internal.asn1.Asn1Class; +import com.android.apksig.internal.asn1.Asn1Field; +import com.android.apksig.internal.asn1.Asn1Type; + +import java.util.List; + +/** + * X501 {@code Name} as specified in RFC 5280. + */ +@Asn1Class(type = Asn1Type.CHOICE) +public class Name { + + // This field is the RDNSequence specified in RFC 5280. + @Asn1Field(index = 0, type = Asn1Type.SEQUENCE_OF) + public List<RelativeDistinguishedName> relativeDistinguishedNames; +} diff --git a/platform/android/java/editor/src/main/java/com/android/apksig/internal/x509/RSAPublicKey.java b/platform/android/java/editor/src/main/java/com/android/apksig/internal/x509/RSAPublicKey.java new file mode 100644 index 0000000000..521e067c26 --- /dev/null +++ b/platform/android/java/editor/src/main/java/com/android/apksig/internal/x509/RSAPublicKey.java @@ -0,0 +1,35 @@ +/* + * Copyright (C) 2019 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.apksig.internal.x509; + +import com.android.apksig.internal.asn1.Asn1Class; +import com.android.apksig.internal.asn1.Asn1Field; +import com.android.apksig.internal.asn1.Asn1Type; + +import java.math.BigInteger; + +/** + * {@code RSAPublicKey} as specified in RFC 3279. + */ +@Asn1Class(type = Asn1Type.SEQUENCE) +public class RSAPublicKey { + @Asn1Field(index = 0, type = Asn1Type.INTEGER) + public BigInteger modulus; + + @Asn1Field(index = 1, type = Asn1Type.INTEGER) + public BigInteger publicExponent; +} diff --git a/platform/android/java/editor/src/main/java/com/android/apksig/internal/x509/RelativeDistinguishedName.java b/platform/android/java/editor/src/main/java/com/android/apksig/internal/x509/RelativeDistinguishedName.java new file mode 100644 index 0000000000..bb89e8d33a --- /dev/null +++ b/platform/android/java/editor/src/main/java/com/android/apksig/internal/x509/RelativeDistinguishedName.java @@ -0,0 +1,33 @@ +/* + * Copyright (C) 2018 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.apksig.internal.x509; + +import com.android.apksig.internal.asn1.Asn1Class; +import com.android.apksig.internal.asn1.Asn1Field; +import com.android.apksig.internal.asn1.Asn1Type; + +import java.util.List; + +/** + * {@code RelativeDistinguishedName} as specified in RFC 5280. + */ +@Asn1Class(type = Asn1Type.UNENCODED_CONTAINER) +public class RelativeDistinguishedName { + + @Asn1Field(index = 0, type = Asn1Type.SET_OF) + public List<AttributeTypeAndValue> attributes; +} diff --git a/platform/android/java/editor/src/main/java/com/android/apksig/internal/x509/SubjectPublicKeyInfo.java b/platform/android/java/editor/src/main/java/com/android/apksig/internal/x509/SubjectPublicKeyInfo.java new file mode 100644 index 0000000000..821523761b --- /dev/null +++ b/platform/android/java/editor/src/main/java/com/android/apksig/internal/x509/SubjectPublicKeyInfo.java @@ -0,0 +1,36 @@ +/* + * Copyright (C) 2018 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.apksig.internal.x509; + +import com.android.apksig.internal.asn1.Asn1Class; +import com.android.apksig.internal.asn1.Asn1Field; +import com.android.apksig.internal.asn1.Asn1Type; +import com.android.apksig.internal.pkcs7.AlgorithmIdentifier; + +import java.nio.ByteBuffer; + +/** + * {@code SubjectPublicKeyInfo} as specified in RFC 5280. + */ +@Asn1Class(type = Asn1Type.SEQUENCE) +public class SubjectPublicKeyInfo { + @Asn1Field(index = 0, type = Asn1Type.SEQUENCE) + public AlgorithmIdentifier algorithmIdentifier; + + @Asn1Field(index = 1, type = Asn1Type.BIT_STRING) + public ByteBuffer subjectPublicKey; +} diff --git a/platform/android/java/editor/src/main/java/com/android/apksig/internal/x509/TBSCertificate.java b/platform/android/java/editor/src/main/java/com/android/apksig/internal/x509/TBSCertificate.java new file mode 100644 index 0000000000..922f52c205 --- /dev/null +++ b/platform/android/java/editor/src/main/java/com/android/apksig/internal/x509/TBSCertificate.java @@ -0,0 +1,79 @@ +/* + * Copyright (C) 2018 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.apksig.internal.x509; + +import com.android.apksig.internal.asn1.Asn1Class; +import com.android.apksig.internal.asn1.Asn1Field; +import com.android.apksig.internal.asn1.Asn1Type; +import com.android.apksig.internal.asn1.Asn1Tagging; +import com.android.apksig.internal.pkcs7.AlgorithmIdentifier; + +import java.math.BigInteger; +import java.nio.ByteBuffer; +import java.util.List; + +/** + * To Be Signed Certificate as specified in RFC 5280. + */ +@Asn1Class(type = Asn1Type.SEQUENCE) +public class TBSCertificate { + + @Asn1Field( + index = 0, + type = Asn1Type.INTEGER, + tagging = Asn1Tagging.EXPLICIT, tagNumber = 0) + public int version; + + @Asn1Field(index = 1, type = Asn1Type.INTEGER) + public BigInteger serialNumber; + + @Asn1Field(index = 2, type = Asn1Type.SEQUENCE) + public AlgorithmIdentifier signatureAlgorithm; + + @Asn1Field(index = 3, type = Asn1Type.CHOICE) + public Name issuer; + + @Asn1Field(index = 4, type = Asn1Type.SEQUENCE) + public Validity validity; + + @Asn1Field(index = 5, type = Asn1Type.CHOICE) + public Name subject; + + @Asn1Field(index = 6, type = Asn1Type.SEQUENCE) + public SubjectPublicKeyInfo subjectPublicKeyInfo; + + @Asn1Field(index = 7, + type = Asn1Type.BIT_STRING, + tagging = Asn1Tagging.IMPLICIT, + optional = true, + tagNumber = 1) + public ByteBuffer issuerUniqueID; + + @Asn1Field(index = 8, + type = Asn1Type.BIT_STRING, + tagging = Asn1Tagging.IMPLICIT, + optional = true, + tagNumber = 2) + public ByteBuffer subjectUniqueID; + + @Asn1Field(index = 9, + type = Asn1Type.SEQUENCE_OF, + tagging = Asn1Tagging.EXPLICIT, + optional = true, + tagNumber = 3) + public List<Extension> extensions; +} diff --git a/platform/android/java/editor/src/main/java/com/android/apksig/internal/x509/Time.java b/platform/android/java/editor/src/main/java/com/android/apksig/internal/x509/Time.java new file mode 100644 index 0000000000..def2ee8947 --- /dev/null +++ b/platform/android/java/editor/src/main/java/com/android/apksig/internal/x509/Time.java @@ -0,0 +1,34 @@ +/* + * Copyright (C) 2018 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.apksig.internal.x509; + +import com.android.apksig.internal.asn1.Asn1Class; +import com.android.apksig.internal.asn1.Asn1Field; +import com.android.apksig.internal.asn1.Asn1Type; + +/** + * {@code Time} as specified in RFC 5280. + */ +@Asn1Class(type = Asn1Type.CHOICE) +public class Time { + + @Asn1Field(type = Asn1Type.UTC_TIME) + public String utcTime; + + @Asn1Field(type = Asn1Type.GENERALIZED_TIME) + public String generalizedTime; +} diff --git a/platform/android/java/editor/src/main/java/com/android/apksig/internal/x509/Validity.java b/platform/android/java/editor/src/main/java/com/android/apksig/internal/x509/Validity.java new file mode 100644 index 0000000000..df9acb3f36 --- /dev/null +++ b/platform/android/java/editor/src/main/java/com/android/apksig/internal/x509/Validity.java @@ -0,0 +1,34 @@ +/* + * Copyright (C) 2018 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.apksig.internal.x509; + +import com.android.apksig.internal.asn1.Asn1Class; +import com.android.apksig.internal.asn1.Asn1Field; +import com.android.apksig.internal.asn1.Asn1Type; + +/** + * {@code Validity} as specified in RFC 5280. + */ +@Asn1Class(type = Asn1Type.SEQUENCE) +public class Validity { + + @Asn1Field(index = 0, type = Asn1Type.CHOICE) + public Time notBefore; + + @Asn1Field(index = 1, type = Asn1Type.CHOICE) + public Time notAfter; +} diff --git a/platform/android/java/editor/src/main/java/com/android/apksig/internal/zip/CentralDirectoryRecord.java b/platform/android/java/editor/src/main/java/com/android/apksig/internal/zip/CentralDirectoryRecord.java new file mode 100644 index 0000000000..d2f444ddcd --- /dev/null +++ b/platform/android/java/editor/src/main/java/com/android/apksig/internal/zip/CentralDirectoryRecord.java @@ -0,0 +1,304 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.apksig.internal.zip; + +import com.android.apksig.zip.ZipFormatException; +import java.nio.BufferUnderflowException; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.nio.charset.StandardCharsets; +import java.util.Comparator; + +/** + * ZIP Central Directory (CD) Record. + */ +public class CentralDirectoryRecord { + + /** + * Comparator which compares records by the offset of the corresponding Local File Header in the + * archive. + */ + public static final Comparator<CentralDirectoryRecord> BY_LOCAL_FILE_HEADER_OFFSET_COMPARATOR = + new ByLocalFileHeaderOffsetComparator(); + + private static final int RECORD_SIGNATURE = 0x02014b50; + private static final int HEADER_SIZE_BYTES = 46; + + private static final int GP_FLAGS_OFFSET = 8; + private static final int LOCAL_FILE_HEADER_OFFSET_OFFSET = 42; + private static final int NAME_OFFSET = HEADER_SIZE_BYTES; + + private final ByteBuffer mData; + private final short mGpFlags; + private final short mCompressionMethod; + private final int mLastModificationTime; + private final int mLastModificationDate; + private final long mCrc32; + private final long mCompressedSize; + private final long mUncompressedSize; + private final long mLocalFileHeaderOffset; + private final String mName; + private final int mNameSizeBytes; + + private CentralDirectoryRecord( + ByteBuffer data, + short gpFlags, + short compressionMethod, + int lastModificationTime, + int lastModificationDate, + long crc32, + long compressedSize, + long uncompressedSize, + long localFileHeaderOffset, + String name, + int nameSizeBytes) { + mData = data; + mGpFlags = gpFlags; + mCompressionMethod = compressionMethod; + mLastModificationDate = lastModificationDate; + mLastModificationTime = lastModificationTime; + mCrc32 = crc32; + mCompressedSize = compressedSize; + mUncompressedSize = uncompressedSize; + mLocalFileHeaderOffset = localFileHeaderOffset; + mName = name; + mNameSizeBytes = nameSizeBytes; + } + + public int getSize() { + return mData.remaining(); + } + + public String getName() { + return mName; + } + + public int getNameSizeBytes() { + return mNameSizeBytes; + } + + public short getGpFlags() { + return mGpFlags; + } + + public short getCompressionMethod() { + return mCompressionMethod; + } + + public int getLastModificationTime() { + return mLastModificationTime; + } + + public int getLastModificationDate() { + return mLastModificationDate; + } + + public long getCrc32() { + return mCrc32; + } + + public long getCompressedSize() { + return mCompressedSize; + } + + public long getUncompressedSize() { + return mUncompressedSize; + } + + public long getLocalFileHeaderOffset() { + return mLocalFileHeaderOffset; + } + + /** + * Returns the Central Directory Record starting at the current position of the provided buffer + * and advances the buffer's position immediately past the end of the record. + */ + public static CentralDirectoryRecord getRecord(ByteBuffer buf) throws ZipFormatException { + ZipUtils.assertByteOrderLittleEndian(buf); + if (buf.remaining() < HEADER_SIZE_BYTES) { + throw new ZipFormatException( + "Input too short. Need at least: " + HEADER_SIZE_BYTES + + " bytes, available: " + buf.remaining() + " bytes", + new BufferUnderflowException()); + } + int originalPosition = buf.position(); + int recordSignature = buf.getInt(); + if (recordSignature != RECORD_SIGNATURE) { + throw new ZipFormatException( + "Not a Central Directory record. Signature: 0x" + + Long.toHexString(recordSignature & 0xffffffffL)); + } + buf.position(originalPosition + GP_FLAGS_OFFSET); + short gpFlags = buf.getShort(); + short compressionMethod = buf.getShort(); + int lastModificationTime = ZipUtils.getUnsignedInt16(buf); + int lastModificationDate = ZipUtils.getUnsignedInt16(buf); + long crc32 = ZipUtils.getUnsignedInt32(buf); + long compressedSize = ZipUtils.getUnsignedInt32(buf); + long uncompressedSize = ZipUtils.getUnsignedInt32(buf); + int nameSize = ZipUtils.getUnsignedInt16(buf); + int extraSize = ZipUtils.getUnsignedInt16(buf); + int commentSize = ZipUtils.getUnsignedInt16(buf); + buf.position(originalPosition + LOCAL_FILE_HEADER_OFFSET_OFFSET); + long localFileHeaderOffset = ZipUtils.getUnsignedInt32(buf); + buf.position(originalPosition); + int recordSize = HEADER_SIZE_BYTES + nameSize + extraSize + commentSize; + if (recordSize > buf.remaining()) { + throw new ZipFormatException( + "Input too short. Need: " + recordSize + " bytes, available: " + + buf.remaining() + " bytes", + new BufferUnderflowException()); + } + String name = getName(buf, originalPosition + NAME_OFFSET, nameSize); + buf.position(originalPosition); + int originalLimit = buf.limit(); + int recordEndInBuf = originalPosition + recordSize; + ByteBuffer recordBuf; + try { + buf.limit(recordEndInBuf); + recordBuf = buf.slice(); + } finally { + buf.limit(originalLimit); + } + // Consume this record + buf.position(recordEndInBuf); + return new CentralDirectoryRecord( + recordBuf, + gpFlags, + compressionMethod, + lastModificationTime, + lastModificationDate, + crc32, + compressedSize, + uncompressedSize, + localFileHeaderOffset, + name, + nameSize); + } + + public void copyTo(ByteBuffer output) { + output.put(mData.slice()); + } + + public CentralDirectoryRecord createWithModifiedLocalFileHeaderOffset( + long localFileHeaderOffset) { + ByteBuffer result = ByteBuffer.allocate(mData.remaining()); + result.put(mData.slice()); + result.flip(); + result.order(ByteOrder.LITTLE_ENDIAN); + ZipUtils.setUnsignedInt32(result, LOCAL_FILE_HEADER_OFFSET_OFFSET, localFileHeaderOffset); + return new CentralDirectoryRecord( + result, + mGpFlags, + mCompressionMethod, + mLastModificationTime, + mLastModificationDate, + mCrc32, + mCompressedSize, + mUncompressedSize, + localFileHeaderOffset, + mName, + mNameSizeBytes); + } + + public static CentralDirectoryRecord createWithDeflateCompressedData( + String name, + int lastModifiedTime, + int lastModifiedDate, + long crc32, + long compressedSize, + long uncompressedSize, + long localFileHeaderOffset) { + byte[] nameBytes = name.getBytes(StandardCharsets.UTF_8); + short gpFlags = ZipUtils.GP_FLAG_EFS; // UTF-8 character encoding used for entry name + short compressionMethod = ZipUtils.COMPRESSION_METHOD_DEFLATED; + int recordSize = HEADER_SIZE_BYTES + nameBytes.length; + ByteBuffer result = ByteBuffer.allocate(recordSize); + result.order(ByteOrder.LITTLE_ENDIAN); + result.putInt(RECORD_SIGNATURE); + ZipUtils.putUnsignedInt16(result, 0x14); // Version made by + ZipUtils.putUnsignedInt16(result, 0x14); // Minimum version needed to extract + result.putShort(gpFlags); + result.putShort(compressionMethod); + ZipUtils.putUnsignedInt16(result, lastModifiedTime); + ZipUtils.putUnsignedInt16(result, lastModifiedDate); + ZipUtils.putUnsignedInt32(result, crc32); + ZipUtils.putUnsignedInt32(result, compressedSize); + ZipUtils.putUnsignedInt32(result, uncompressedSize); + ZipUtils.putUnsignedInt16(result, nameBytes.length); + ZipUtils.putUnsignedInt16(result, 0); // Extra field length + ZipUtils.putUnsignedInt16(result, 0); // File comment length + ZipUtils.putUnsignedInt16(result, 0); // Disk number + ZipUtils.putUnsignedInt16(result, 0); // Internal file attributes + ZipUtils.putUnsignedInt32(result, 0); // External file attributes + ZipUtils.putUnsignedInt32(result, localFileHeaderOffset); + result.put(nameBytes); + + if (result.hasRemaining()) { + throw new RuntimeException("pos: " + result.position() + ", limit: " + result.limit()); + } + result.flip(); + return new CentralDirectoryRecord( + result, + gpFlags, + compressionMethod, + lastModifiedTime, + lastModifiedDate, + crc32, + compressedSize, + uncompressedSize, + localFileHeaderOffset, + name, + nameBytes.length); + } + + static String getName(ByteBuffer record, int position, int nameLengthBytes) { + byte[] nameBytes; + int nameBytesOffset; + if (record.hasArray()) { + nameBytes = record.array(); + nameBytesOffset = record.arrayOffset() + position; + } else { + nameBytes = new byte[nameLengthBytes]; + nameBytesOffset = 0; + int originalPosition = record.position(); + try { + record.position(position); + record.get(nameBytes); + } finally { + record.position(originalPosition); + } + } + return new String(nameBytes, nameBytesOffset, nameLengthBytes, StandardCharsets.UTF_8); + } + + private static class ByLocalFileHeaderOffsetComparator + implements Comparator<CentralDirectoryRecord> { + @Override + public int compare(CentralDirectoryRecord r1, CentralDirectoryRecord r2) { + long offset1 = r1.getLocalFileHeaderOffset(); + long offset2 = r2.getLocalFileHeaderOffset(); + if (offset1 > offset2) { + return 1; + } else if (offset1 < offset2) { + return -1; + } else { + return 0; + } + } + } +} diff --git a/platform/android/java/editor/src/main/java/com/android/apksig/internal/zip/EocdRecord.java b/platform/android/java/editor/src/main/java/com/android/apksig/internal/zip/EocdRecord.java new file mode 100644 index 0000000000..d2000b42dd --- /dev/null +++ b/platform/android/java/editor/src/main/java/com/android/apksig/internal/zip/EocdRecord.java @@ -0,0 +1,57 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.apksig.internal.zip; + +import java.nio.ByteBuffer; +import java.nio.ByteOrder; + +/** + * ZIP End of Central Directory record. + */ +public class EocdRecord { + private static final int CD_RECORD_COUNT_ON_DISK_OFFSET = 8; + private static final int CD_RECORD_COUNT_TOTAL_OFFSET = 10; + private static final int CD_SIZE_OFFSET = 12; + private static final int CD_OFFSET_OFFSET = 16; + + public static ByteBuffer createWithModifiedCentralDirectoryInfo( + ByteBuffer original, + int centralDirectoryRecordCount, + long centralDirectorySizeBytes, + long centralDirectoryOffset) { + ByteBuffer result = ByteBuffer.allocate(original.remaining()); + result.order(ByteOrder.LITTLE_ENDIAN); + result.put(original.slice()); + result.flip(); + ZipUtils.setUnsignedInt16( + result, CD_RECORD_COUNT_ON_DISK_OFFSET, centralDirectoryRecordCount); + ZipUtils.setUnsignedInt16( + result, CD_RECORD_COUNT_TOTAL_OFFSET, centralDirectoryRecordCount); + ZipUtils.setUnsignedInt32(result, CD_SIZE_OFFSET, centralDirectorySizeBytes); + ZipUtils.setUnsignedInt32(result, CD_OFFSET_OFFSET, centralDirectoryOffset); + return result; + } + + public static ByteBuffer createWithPaddedComment(ByteBuffer original, int padding) { + ByteBuffer result = ByteBuffer.allocate((int) original.remaining() + padding); + result.order(ByteOrder.LITTLE_ENDIAN); + result.put(original.slice()); + result.rewind(); + ZipUtils.updateZipEocdCommentLen(result); + return result; + } +} diff --git a/platform/android/java/editor/src/main/java/com/android/apksig/internal/zip/LocalFileRecord.java b/platform/android/java/editor/src/main/java/com/android/apksig/internal/zip/LocalFileRecord.java new file mode 100644 index 0000000000..50ce386aa7 --- /dev/null +++ b/platform/android/java/editor/src/main/java/com/android/apksig/internal/zip/LocalFileRecord.java @@ -0,0 +1,543 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.apksig.internal.zip; + +import com.android.apksig.internal.util.ByteBufferSink; +import com.android.apksig.util.DataSink; +import com.android.apksig.util.DataSource; +import com.android.apksig.zip.ZipFormatException; +import java.io.Closeable; +import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.nio.charset.StandardCharsets; +import java.util.zip.DataFormatException; +import java.util.zip.Inflater; + +/** + * ZIP Local File record. + * + * <p>The record consists of the Local File Header, file data, and (if present) Data Descriptor. + */ +public class LocalFileRecord { + private static final int RECORD_SIGNATURE = 0x04034b50; + private static final int HEADER_SIZE_BYTES = 30; + + private static final int GP_FLAGS_OFFSET = 6; + private static final int CRC32_OFFSET = 14; + private static final int COMPRESSED_SIZE_OFFSET = 18; + private static final int UNCOMPRESSED_SIZE_OFFSET = 22; + private static final int NAME_LENGTH_OFFSET = 26; + private static final int EXTRA_LENGTH_OFFSET = 28; + private static final int NAME_OFFSET = HEADER_SIZE_BYTES; + + private static final int DATA_DESCRIPTOR_SIZE_BYTES_WITHOUT_SIGNATURE = 12; + private static final int DATA_DESCRIPTOR_SIGNATURE = 0x08074b50; + + private final String mName; + private final int mNameSizeBytes; + private final ByteBuffer mExtra; + + private final long mStartOffsetInArchive; + private final long mSize; + + private final int mDataStartOffset; + private final long mDataSize; + private final boolean mDataCompressed; + private final long mUncompressedDataSize; + + private LocalFileRecord( + String name, + int nameSizeBytes, + ByteBuffer extra, + long startOffsetInArchive, + long size, + int dataStartOffset, + long dataSize, + boolean dataCompressed, + long uncompressedDataSize) { + mName = name; + mNameSizeBytes = nameSizeBytes; + mExtra = extra; + mStartOffsetInArchive = startOffsetInArchive; + mSize = size; + mDataStartOffset = dataStartOffset; + mDataSize = dataSize; + mDataCompressed = dataCompressed; + mUncompressedDataSize = uncompressedDataSize; + } + + public String getName() { + return mName; + } + + public ByteBuffer getExtra() { + return (mExtra.capacity() > 0) ? mExtra.slice() : mExtra; + } + + public int getExtraFieldStartOffsetInsideRecord() { + return HEADER_SIZE_BYTES + mNameSizeBytes; + } + + public long getStartOffsetInArchive() { + return mStartOffsetInArchive; + } + + public int getDataStartOffsetInRecord() { + return mDataStartOffset; + } + + /** + * Returns the size (in bytes) of this record. + */ + public long getSize() { + return mSize; + } + + /** + * Returns {@code true} if this record's file data is stored in compressed form. + */ + public boolean isDataCompressed() { + return mDataCompressed; + } + + /** + * Returns the Local File record starting at the current position of the provided buffer + * and advances the buffer's position immediately past the end of the record. The record + * consists of the Local File Header, data, and (if present) Data Descriptor. + */ + public static LocalFileRecord getRecord( + DataSource apk, + CentralDirectoryRecord cdRecord, + long cdStartOffset) throws ZipFormatException, IOException { + return getRecord( + apk, + cdRecord, + cdStartOffset, + true, // obtain extra field contents + true // include Data Descriptor (if present) + ); + } + + /** + * Returns the Local File record starting at the current position of the provided buffer + * and advances the buffer's position immediately past the end of the record. The record + * consists of the Local File Header, data, and (if present) Data Descriptor. + */ + private static LocalFileRecord getRecord( + DataSource apk, + CentralDirectoryRecord cdRecord, + long cdStartOffset, + boolean extraFieldContentsNeeded, + boolean dataDescriptorIncluded) throws ZipFormatException, IOException { + // IMPLEMENTATION NOTE: This method attempts to mimic the behavior of Android platform + // exhibited when reading an APK for the purposes of verifying its signatures. + + String entryName = cdRecord.getName(); + int cdRecordEntryNameSizeBytes = cdRecord.getNameSizeBytes(); + int headerSizeWithName = HEADER_SIZE_BYTES + cdRecordEntryNameSizeBytes; + long headerStartOffset = cdRecord.getLocalFileHeaderOffset(); + long headerEndOffset = headerStartOffset + headerSizeWithName; + if (headerEndOffset > cdStartOffset) { + throw new ZipFormatException( + "Local File Header of " + entryName + " extends beyond start of Central" + + " Directory. LFH end: " + headerEndOffset + + ", CD start: " + cdStartOffset); + } + ByteBuffer header; + try { + header = apk.getByteBuffer(headerStartOffset, headerSizeWithName); + } catch (IOException e) { + throw new IOException("Failed to read Local File Header of " + entryName, e); + } + header.order(ByteOrder.LITTLE_ENDIAN); + + int recordSignature = header.getInt(); + if (recordSignature != RECORD_SIGNATURE) { + throw new ZipFormatException( + "Not a Local File Header record for entry " + entryName + ". Signature: 0x" + + Long.toHexString(recordSignature & 0xffffffffL)); + } + short gpFlags = header.getShort(GP_FLAGS_OFFSET); + boolean dataDescriptorUsed = (gpFlags & ZipUtils.GP_FLAG_DATA_DESCRIPTOR_USED) != 0; + boolean cdDataDescriptorUsed = + (cdRecord.getGpFlags() & ZipUtils.GP_FLAG_DATA_DESCRIPTOR_USED) != 0; + if (dataDescriptorUsed != cdDataDescriptorUsed) { + throw new ZipFormatException( + "Data Descriptor presence mismatch between Local File Header and Central" + + " Directory for entry " + entryName + + ". LFH: " + dataDescriptorUsed + ", CD: " + cdDataDescriptorUsed); + } + long uncompressedDataCrc32FromCdRecord = cdRecord.getCrc32(); + long compressedDataSizeFromCdRecord = cdRecord.getCompressedSize(); + long uncompressedDataSizeFromCdRecord = cdRecord.getUncompressedSize(); + if (!dataDescriptorUsed) { + long crc32 = ZipUtils.getUnsignedInt32(header, CRC32_OFFSET); + if (crc32 != uncompressedDataCrc32FromCdRecord) { + throw new ZipFormatException( + "CRC-32 mismatch between Local File Header and Central Directory for entry " + + entryName + ". LFH: " + crc32 + + ", CD: " + uncompressedDataCrc32FromCdRecord); + } + long compressedSize = ZipUtils.getUnsignedInt32(header, COMPRESSED_SIZE_OFFSET); + if (compressedSize != compressedDataSizeFromCdRecord) { + throw new ZipFormatException( + "Compressed size mismatch between Local File Header and Central Directory" + + " for entry " + entryName + ". LFH: " + compressedSize + + ", CD: " + compressedDataSizeFromCdRecord); + } + long uncompressedSize = ZipUtils.getUnsignedInt32(header, UNCOMPRESSED_SIZE_OFFSET); + if (uncompressedSize != uncompressedDataSizeFromCdRecord) { + throw new ZipFormatException( + "Uncompressed size mismatch between Local File Header and Central Directory" + + " for entry " + entryName + ". LFH: " + uncompressedSize + + ", CD: " + uncompressedDataSizeFromCdRecord); + } + } + int nameLength = ZipUtils.getUnsignedInt16(header, NAME_LENGTH_OFFSET); + if (nameLength > cdRecordEntryNameSizeBytes) { + throw new ZipFormatException( + "Name mismatch between Local File Header and Central Directory for entry" + + entryName + ". LFH: " + nameLength + + " bytes, CD: " + cdRecordEntryNameSizeBytes + " bytes"); + } + String name = CentralDirectoryRecord.getName(header, NAME_OFFSET, nameLength); + if (!entryName.equals(name)) { + throw new ZipFormatException( + "Name mismatch between Local File Header and Central Directory. LFH: \"" + + name + "\", CD: \"" + entryName + "\""); + } + int extraLength = ZipUtils.getUnsignedInt16(header, EXTRA_LENGTH_OFFSET); + long dataStartOffset = headerStartOffset + HEADER_SIZE_BYTES + nameLength + extraLength; + long dataSize; + boolean compressed = + (cdRecord.getCompressionMethod() != ZipUtils.COMPRESSION_METHOD_STORED); + if (compressed) { + dataSize = compressedDataSizeFromCdRecord; + } else { + dataSize = uncompressedDataSizeFromCdRecord; + } + long dataEndOffset = dataStartOffset + dataSize; + if (dataEndOffset > cdStartOffset) { + throw new ZipFormatException( + "Local File Header data of " + entryName + " overlaps with Central Directory" + + ". LFH data start: " + dataStartOffset + + ", LFH data end: " + dataEndOffset + ", CD start: " + cdStartOffset); + } + + ByteBuffer extra = EMPTY_BYTE_BUFFER; + if ((extraFieldContentsNeeded) && (extraLength > 0)) { + extra = apk.getByteBuffer( + headerStartOffset + HEADER_SIZE_BYTES + nameLength, extraLength); + } + + long recordEndOffset = dataEndOffset; + // Include the Data Descriptor (if requested and present) into the record. + if ((dataDescriptorIncluded) && ((gpFlags & ZipUtils.GP_FLAG_DATA_DESCRIPTOR_USED) != 0)) { + // The record's data is supposed to be followed by the Data Descriptor. Unfortunately, + // the descriptor's size is not known in advance because the spec lets the signature + // field (the first four bytes) be omitted. Thus, there's no 100% reliable way to tell + // how long the Data Descriptor record is. Most parsers (including Android) check + // whether the first four bytes look like Data Descriptor record signature and, if so, + // assume that it is indeed the record's signature. However, this is the wrong + // conclusion if the record's CRC-32 (next field after the signature) has the same value + // as the signature. In any case, we're doing what Android is doing. + long dataDescriptorEndOffset = + dataEndOffset + DATA_DESCRIPTOR_SIZE_BYTES_WITHOUT_SIGNATURE; + if (dataDescriptorEndOffset > cdStartOffset) { + throw new ZipFormatException( + "Data Descriptor of " + entryName + " overlaps with Central Directory" + + ". Data Descriptor end: " + dataEndOffset + + ", CD start: " + cdStartOffset); + } + ByteBuffer dataDescriptorPotentialSig = apk.getByteBuffer(dataEndOffset, 4); + dataDescriptorPotentialSig.order(ByteOrder.LITTLE_ENDIAN); + if (dataDescriptorPotentialSig.getInt() == DATA_DESCRIPTOR_SIGNATURE) { + dataDescriptorEndOffset += 4; + if (dataDescriptorEndOffset > cdStartOffset) { + throw new ZipFormatException( + "Data Descriptor of " + entryName + " overlaps with Central Directory" + + ". Data Descriptor end: " + dataEndOffset + + ", CD start: " + cdStartOffset); + } + } + recordEndOffset = dataDescriptorEndOffset; + } + + long recordSize = recordEndOffset - headerStartOffset; + int dataStartOffsetInRecord = HEADER_SIZE_BYTES + nameLength + extraLength; + + return new LocalFileRecord( + entryName, + cdRecordEntryNameSizeBytes, + extra, + headerStartOffset, + recordSize, + dataStartOffsetInRecord, + dataSize, + compressed, + uncompressedDataSizeFromCdRecord); + } + + /** + * Outputs this record and returns returns the number of bytes output. + */ + public long outputRecord(DataSource sourceApk, DataSink output) throws IOException { + long size = getSize(); + sourceApk.feed(getStartOffsetInArchive(), size, output); + return size; + } + + /** + * Outputs this record, replacing its extra field with the provided one, and returns returns the + * number of bytes output. + */ + public long outputRecordWithModifiedExtra( + DataSource sourceApk, + ByteBuffer extra, + DataSink output) throws IOException { + long recordStartOffsetInSource = getStartOffsetInArchive(); + int extraStartOffsetInRecord = getExtraFieldStartOffsetInsideRecord(); + int extraSizeBytes = extra.remaining(); + int headerSize = extraStartOffsetInRecord + extraSizeBytes; + ByteBuffer header = ByteBuffer.allocate(headerSize); + header.order(ByteOrder.LITTLE_ENDIAN); + sourceApk.copyTo(recordStartOffsetInSource, extraStartOffsetInRecord, header); + header.put(extra.slice()); + header.flip(); + ZipUtils.setUnsignedInt16(header, EXTRA_LENGTH_OFFSET, extraSizeBytes); + + long outputByteCount = header.remaining(); + output.consume(header); + long remainingRecordSize = getSize() - mDataStartOffset; + sourceApk.feed(recordStartOffsetInSource + mDataStartOffset, remainingRecordSize, output); + outputByteCount += remainingRecordSize; + return outputByteCount; + } + + /** + * Outputs the specified Local File Header record with its data and returns the number of bytes + * output. + */ + public static long outputRecordWithDeflateCompressedData( + String name, + int lastModifiedTime, + int lastModifiedDate, + byte[] compressedData, + long crc32, + long uncompressedSize, + DataSink output) throws IOException { + byte[] nameBytes = name.getBytes(StandardCharsets.UTF_8); + int recordSize = HEADER_SIZE_BYTES + nameBytes.length; + ByteBuffer result = ByteBuffer.allocate(recordSize); + result.order(ByteOrder.LITTLE_ENDIAN); + result.putInt(RECORD_SIGNATURE); + ZipUtils.putUnsignedInt16(result, 0x14); // Minimum version needed to extract + result.putShort(ZipUtils.GP_FLAG_EFS); // General purpose flag: UTF-8 encoded name + result.putShort(ZipUtils.COMPRESSION_METHOD_DEFLATED); + ZipUtils.putUnsignedInt16(result, lastModifiedTime); + ZipUtils.putUnsignedInt16(result, lastModifiedDate); + ZipUtils.putUnsignedInt32(result, crc32); + ZipUtils.putUnsignedInt32(result, compressedData.length); + ZipUtils.putUnsignedInt32(result, uncompressedSize); + ZipUtils.putUnsignedInt16(result, nameBytes.length); + ZipUtils.putUnsignedInt16(result, 0); // Extra field length + result.put(nameBytes); + if (result.hasRemaining()) { + throw new RuntimeException("pos: " + result.position() + ", limit: " + result.limit()); + } + result.flip(); + + long outputByteCount = result.remaining(); + output.consume(result); + outputByteCount += compressedData.length; + output.consume(compressedData, 0, compressedData.length); + return outputByteCount; + } + + private static final ByteBuffer EMPTY_BYTE_BUFFER = ByteBuffer.allocate(0); + + /** + * Sends uncompressed data of this record into the the provided data sink. + */ + public void outputUncompressedData( + DataSource lfhSection, + DataSink sink) throws IOException, ZipFormatException { + long dataStartOffsetInArchive = mStartOffsetInArchive + mDataStartOffset; + try { + if (mDataCompressed) { + try (InflateSinkAdapter inflateAdapter = new InflateSinkAdapter(sink)) { + lfhSection.feed(dataStartOffsetInArchive, mDataSize, inflateAdapter); + long actualUncompressedSize = inflateAdapter.getOutputByteCount(); + if (actualUncompressedSize != mUncompressedDataSize) { + throw new ZipFormatException( + "Unexpected size of uncompressed data of " + mName + + ". Expected: " + mUncompressedDataSize + " bytes" + + ", actual: " + actualUncompressedSize + " bytes"); + } + } catch (IOException e) { + if (e.getCause() instanceof DataFormatException) { + throw new ZipFormatException("Data of entry " + mName + " malformed", e); + } + throw e; + } + } else { + lfhSection.feed(dataStartOffsetInArchive, mDataSize, sink); + // No need to check whether output size is as expected because DataSource.feed is + // guaranteed to output exactly the number of bytes requested. + } + } catch (IOException e) { + throw new IOException( + "Failed to read data of " + ((mDataCompressed) ? "compressed" : "uncompressed") + + " entry " + mName, + e); + } + // Interestingly, Android doesn't check that uncompressed data's CRC-32 is as expected. We + // thus don't check either. + } + + /** + * Sends uncompressed data pointed to by the provided ZIP Central Directory (CD) record into the + * provided data sink. + */ + public static void outputUncompressedData( + DataSource source, + CentralDirectoryRecord cdRecord, + long cdStartOffsetInArchive, + DataSink sink) throws ZipFormatException, IOException { + // IMPLEMENTATION NOTE: This method attempts to mimic the behavior of Android platform + // exhibited when reading an APK for the purposes of verifying its signatures. + // When verifying an APK, Android doesn't care reading the extra field or the Data + // Descriptor. + LocalFileRecord lfhRecord = + getRecord( + source, + cdRecord, + cdStartOffsetInArchive, + false, // don't care about the extra field + false // don't read the Data Descriptor + ); + lfhRecord.outputUncompressedData(source, sink); + } + + /** + * Returns the uncompressed data pointed to by the provided ZIP Central Directory (CD) record. + */ + public static byte[] getUncompressedData( + DataSource source, + CentralDirectoryRecord cdRecord, + long cdStartOffsetInArchive) throws ZipFormatException, IOException { + if (cdRecord.getUncompressedSize() > Integer.MAX_VALUE) { + throw new IOException( + cdRecord.getName() + " too large: " + cdRecord.getUncompressedSize()); + } + byte[] result = null; + try { + result = new byte[(int) cdRecord.getUncompressedSize()]; + } catch (OutOfMemoryError e) { + throw new IOException( + cdRecord.getName() + " too large: " + cdRecord.getUncompressedSize(), e); + } + ByteBuffer resultBuf = ByteBuffer.wrap(result); + ByteBufferSink resultSink = new ByteBufferSink(resultBuf); + outputUncompressedData( + source, + cdRecord, + cdStartOffsetInArchive, + resultSink); + return result; + } + + /** + * {@link DataSink} which inflates received data and outputs the deflated data into the provided + * delegate sink. + */ + private static class InflateSinkAdapter implements DataSink, Closeable { + private final DataSink mDelegate; + + private Inflater mInflater = new Inflater(true); + private byte[] mOutputBuffer; + private byte[] mInputBuffer; + private long mOutputByteCount; + private boolean mClosed; + + private InflateSinkAdapter(DataSink delegate) { + mDelegate = delegate; + } + + @Override + public void consume(byte[] buf, int offset, int length) throws IOException { + checkNotClosed(); + mInflater.setInput(buf, offset, length); + if (mOutputBuffer == null) { + mOutputBuffer = new byte[65536]; + } + while (!mInflater.finished()) { + int outputChunkSize; + try { + outputChunkSize = mInflater.inflate(mOutputBuffer); + } catch (DataFormatException e) { + throw new IOException("Failed to inflate data", e); + } + if (outputChunkSize == 0) { + return; + } + mDelegate.consume(mOutputBuffer, 0, outputChunkSize); + mOutputByteCount += outputChunkSize; + } + } + + @Override + public void consume(ByteBuffer buf) throws IOException { + checkNotClosed(); + if (buf.hasArray()) { + consume(buf.array(), buf.arrayOffset() + buf.position(), buf.remaining()); + buf.position(buf.limit()); + } else { + if (mInputBuffer == null) { + mInputBuffer = new byte[65536]; + } + while (buf.hasRemaining()) { + int chunkSize = Math.min(buf.remaining(), mInputBuffer.length); + buf.get(mInputBuffer, 0, chunkSize); + consume(mInputBuffer, 0, chunkSize); + } + } + } + + public long getOutputByteCount() { + return mOutputByteCount; + } + + @Override + public void close() throws IOException { + mClosed = true; + mInputBuffer = null; + mOutputBuffer = null; + if (mInflater != null) { + mInflater.end(); + mInflater = null; + } + } + + private void checkNotClosed() { + if (mClosed) { + throw new IllegalStateException("Closed"); + } + } + } +} diff --git a/platform/android/java/editor/src/main/java/com/android/apksig/internal/zip/ZipUtils.java b/platform/android/java/editor/src/main/java/com/android/apksig/internal/zip/ZipUtils.java new file mode 100644 index 0000000000..1c2e82cdab --- /dev/null +++ b/platform/android/java/editor/src/main/java/com/android/apksig/internal/zip/ZipUtils.java @@ -0,0 +1,385 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.apksig.internal.zip; + +import com.android.apksig.apk.ApkFormatException; +import com.android.apksig.internal.util.Pair; +import com.android.apksig.util.DataSource; +import com.android.apksig.zip.ZipFormatException; +import com.android.apksig.zip.ZipSections; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.util.ArrayList; +import java.util.List; +import java.util.zip.CRC32; +import java.util.zip.Deflater; + +/** + * Assorted ZIP format helpers. + * + * <p>NOTE: Most helper methods operating on {@code ByteBuffer} instances expect that the byte + * order of these buffers is little-endian. + */ +public abstract class ZipUtils { + private ZipUtils() {} + + public static final short COMPRESSION_METHOD_STORED = 0; + public static final short COMPRESSION_METHOD_DEFLATED = 8; + + public static final short GP_FLAG_DATA_DESCRIPTOR_USED = 0x08; + public static final short GP_FLAG_EFS = 0x0800; + + private static final int ZIP_EOCD_REC_MIN_SIZE = 22; + private static final int ZIP_EOCD_REC_SIG = 0x06054b50; + private static final int ZIP_EOCD_CENTRAL_DIR_TOTAL_RECORD_COUNT_OFFSET = 10; + private static final int ZIP_EOCD_CENTRAL_DIR_SIZE_FIELD_OFFSET = 12; + private static final int ZIP_EOCD_CENTRAL_DIR_OFFSET_FIELD_OFFSET = 16; + private static final int ZIP_EOCD_COMMENT_LENGTH_FIELD_OFFSET = 20; + + private static final int UINT16_MAX_VALUE = 0xffff; + + /** + * Sets the offset of the start of the ZIP Central Directory in the archive. + * + * <p>NOTE: Byte order of {@code zipEndOfCentralDirectory} must be little-endian. + */ + public static void setZipEocdCentralDirectoryOffset( + ByteBuffer zipEndOfCentralDirectory, long offset) { + assertByteOrderLittleEndian(zipEndOfCentralDirectory); + setUnsignedInt32( + zipEndOfCentralDirectory, + zipEndOfCentralDirectory.position() + ZIP_EOCD_CENTRAL_DIR_OFFSET_FIELD_OFFSET, + offset); + } + + /** + * Sets the length of EOCD comment. + * + * <p>NOTE: Byte order of {@code zipEndOfCentralDirectory} must be little-endian. + */ + public static void updateZipEocdCommentLen(ByteBuffer zipEndOfCentralDirectory) { + assertByteOrderLittleEndian(zipEndOfCentralDirectory); + int commentLen = zipEndOfCentralDirectory.remaining() - ZIP_EOCD_REC_MIN_SIZE; + setUnsignedInt16( + zipEndOfCentralDirectory, + zipEndOfCentralDirectory.position() + ZIP_EOCD_COMMENT_LENGTH_FIELD_OFFSET, + commentLen); + } + + /** + * Returns the offset of the start of the ZIP Central Directory in the archive. + * + * <p>NOTE: Byte order of {@code zipEndOfCentralDirectory} must be little-endian. + */ + public static long getZipEocdCentralDirectoryOffset(ByteBuffer zipEndOfCentralDirectory) { + assertByteOrderLittleEndian(zipEndOfCentralDirectory); + return getUnsignedInt32( + zipEndOfCentralDirectory, + zipEndOfCentralDirectory.position() + ZIP_EOCD_CENTRAL_DIR_OFFSET_FIELD_OFFSET); + } + + /** + * Returns the size (in bytes) of the ZIP Central Directory. + * + * <p>NOTE: Byte order of {@code zipEndOfCentralDirectory} must be little-endian. + */ + public static long getZipEocdCentralDirectorySizeBytes(ByteBuffer zipEndOfCentralDirectory) { + assertByteOrderLittleEndian(zipEndOfCentralDirectory); + return getUnsignedInt32( + zipEndOfCentralDirectory, + zipEndOfCentralDirectory.position() + ZIP_EOCD_CENTRAL_DIR_SIZE_FIELD_OFFSET); + } + + /** + * Returns the total number of records in ZIP Central Directory. + * + * <p>NOTE: Byte order of {@code zipEndOfCentralDirectory} must be little-endian. + */ + public static int getZipEocdCentralDirectoryTotalRecordCount( + ByteBuffer zipEndOfCentralDirectory) { + assertByteOrderLittleEndian(zipEndOfCentralDirectory); + return getUnsignedInt16( + zipEndOfCentralDirectory, + zipEndOfCentralDirectory.position() + + ZIP_EOCD_CENTRAL_DIR_TOTAL_RECORD_COUNT_OFFSET); + } + + /** + * Returns the ZIP End of Central Directory record of the provided ZIP file. + * + * @return contents of the ZIP End of Central Directory record and the record's offset in the + * file or {@code null} if the file does not contain the record. + * + * @throws IOException if an I/O error occurs while reading the file. + */ + public static Pair<ByteBuffer, Long> findZipEndOfCentralDirectoryRecord(DataSource zip) + throws IOException { + // ZIP End of Central Directory (EOCD) record is located at the very end of the ZIP archive. + // The record can be identified by its 4-byte signature/magic which is located at the very + // beginning of the record. A complication is that the record is variable-length because of + // the comment field. + // The algorithm for locating the ZIP EOCD record is as follows. We search backwards from + // end of the buffer for the EOCD record signature. Whenever we find a signature, we check + // the candidate record's comment length is such that the remainder of the record takes up + // exactly the remaining bytes in the buffer. The search is bounded because the maximum + // size of the comment field is 65535 bytes because the field is an unsigned 16-bit number. + + long fileSize = zip.size(); + if (fileSize < ZIP_EOCD_REC_MIN_SIZE) { + return null; + } + + // Optimization: 99.99% of APKs have a zero-length comment field in the EoCD record and thus + // the EoCD record offset is known in advance. Try that offset first to avoid unnecessarily + // reading more data. + Pair<ByteBuffer, Long> result = findZipEndOfCentralDirectoryRecord(zip, 0); + if (result != null) { + return result; + } + + // EoCD does not start where we expected it to. Perhaps it contains a non-empty comment + // field. Expand the search. The maximum size of the comment field in EoCD is 65535 because + // the comment length field is an unsigned 16-bit number. + return findZipEndOfCentralDirectoryRecord(zip, UINT16_MAX_VALUE); + } + + /** + * Returns the ZIP End of Central Directory record of the provided ZIP file. + * + * @param maxCommentSize maximum accepted size (in bytes) of EoCD comment field. The permitted + * value is from 0 to 65535 inclusive. The smaller the value, the faster this method + * locates the record, provided its comment field is no longer than this value. + * + * @return contents of the ZIP End of Central Directory record and the record's offset in the + * file or {@code null} if the file does not contain the record. + * + * @throws IOException if an I/O error occurs while reading the file. + */ + private static Pair<ByteBuffer, Long> findZipEndOfCentralDirectoryRecord( + DataSource zip, int maxCommentSize) throws IOException { + // ZIP End of Central Directory (EOCD) record is located at the very end of the ZIP archive. + // The record can be identified by its 4-byte signature/magic which is located at the very + // beginning of the record. A complication is that the record is variable-length because of + // the comment field. + // The algorithm for locating the ZIP EOCD record is as follows. We search backwards from + // end of the buffer for the EOCD record signature. Whenever we find a signature, we check + // the candidate record's comment length is such that the remainder of the record takes up + // exactly the remaining bytes in the buffer. The search is bounded because the maximum + // size of the comment field is 65535 bytes because the field is an unsigned 16-bit number. + + if ((maxCommentSize < 0) || (maxCommentSize > UINT16_MAX_VALUE)) { + throw new IllegalArgumentException("maxCommentSize: " + maxCommentSize); + } + + long fileSize = zip.size(); + if (fileSize < ZIP_EOCD_REC_MIN_SIZE) { + // No space for EoCD record in the file. + return null; + } + // Lower maxCommentSize if the file is too small. + maxCommentSize = (int) Math.min(maxCommentSize, fileSize - ZIP_EOCD_REC_MIN_SIZE); + + int maxEocdSize = ZIP_EOCD_REC_MIN_SIZE + maxCommentSize; + long bufOffsetInFile = fileSize - maxEocdSize; + ByteBuffer buf = zip.getByteBuffer(bufOffsetInFile, maxEocdSize); + buf.order(ByteOrder.LITTLE_ENDIAN); + int eocdOffsetInBuf = findZipEndOfCentralDirectoryRecord(buf); + if (eocdOffsetInBuf == -1) { + // No EoCD record found in the buffer + return null; + } + // EoCD found + buf.position(eocdOffsetInBuf); + ByteBuffer eocd = buf.slice(); + eocd.order(ByteOrder.LITTLE_ENDIAN); + return Pair.of(eocd, bufOffsetInFile + eocdOffsetInBuf); + } + + /** + * Returns the position at which ZIP End of Central Directory record starts in the provided + * buffer or {@code -1} if the record is not present. + * + * <p>NOTE: Byte order of {@code zipContents} must be little-endian. + */ + private static int findZipEndOfCentralDirectoryRecord(ByteBuffer zipContents) { + assertByteOrderLittleEndian(zipContents); + + // ZIP End of Central Directory (EOCD) record is located at the very end of the ZIP archive. + // The record can be identified by its 4-byte signature/magic which is located at the very + // beginning of the record. A complication is that the record is variable-length because of + // the comment field. + // The algorithm for locating the ZIP EOCD record is as follows. We search backwards from + // end of the buffer for the EOCD record signature. Whenever we find a signature, we check + // the candidate record's comment length is such that the remainder of the record takes up + // exactly the remaining bytes in the buffer. The search is bounded because the maximum + // size of the comment field is 65535 bytes because the field is an unsigned 16-bit number. + + int archiveSize = zipContents.capacity(); + if (archiveSize < ZIP_EOCD_REC_MIN_SIZE) { + return -1; + } + int maxCommentLength = Math.min(archiveSize - ZIP_EOCD_REC_MIN_SIZE, UINT16_MAX_VALUE); + int eocdWithEmptyCommentStartPosition = archiveSize - ZIP_EOCD_REC_MIN_SIZE; + for (int expectedCommentLength = 0; expectedCommentLength <= maxCommentLength; + expectedCommentLength++) { + int eocdStartPos = eocdWithEmptyCommentStartPosition - expectedCommentLength; + if (zipContents.getInt(eocdStartPos) == ZIP_EOCD_REC_SIG) { + int actualCommentLength = + getUnsignedInt16( + zipContents, eocdStartPos + ZIP_EOCD_COMMENT_LENGTH_FIELD_OFFSET); + if (actualCommentLength == expectedCommentLength) { + return eocdStartPos; + } + } + } + + return -1; + } + + static void assertByteOrderLittleEndian(ByteBuffer buffer) { + if (buffer.order() != ByteOrder.LITTLE_ENDIAN) { + throw new IllegalArgumentException("ByteBuffer byte order must be little endian"); + } + } + + public static int getUnsignedInt16(ByteBuffer buffer, int offset) { + return buffer.getShort(offset) & 0xffff; + } + + public static int getUnsignedInt16(ByteBuffer buffer) { + return buffer.getShort() & 0xffff; + } + + public static List<CentralDirectoryRecord> parseZipCentralDirectory( + DataSource apk, + ZipSections apkSections) + throws IOException, ApkFormatException { + // Read the ZIP Central Directory + long cdSizeBytes = apkSections.getZipCentralDirectorySizeBytes(); + if (cdSizeBytes > Integer.MAX_VALUE) { + throw new ApkFormatException("ZIP Central Directory too large: " + cdSizeBytes); + } + long cdOffset = apkSections.getZipCentralDirectoryOffset(); + ByteBuffer cd = apk.getByteBuffer(cdOffset, (int) cdSizeBytes); + cd.order(ByteOrder.LITTLE_ENDIAN); + + // Parse the ZIP Central Directory + int expectedCdRecordCount = apkSections.getZipCentralDirectoryRecordCount(); + List<CentralDirectoryRecord> cdRecords = new ArrayList<>(expectedCdRecordCount); + for (int i = 0; i < expectedCdRecordCount; i++) { + CentralDirectoryRecord cdRecord; + int offsetInsideCd = cd.position(); + try { + cdRecord = CentralDirectoryRecord.getRecord(cd); + } catch (ZipFormatException e) { + throw new ApkFormatException( + "Malformed ZIP Central Directory record #" + (i + 1) + + " at file offset " + (cdOffset + offsetInsideCd), + e); + } + String entryName = cdRecord.getName(); + if (entryName.endsWith("/")) { + // Ignore directory entries + continue; + } + cdRecords.add(cdRecord); + } + // There may be more data in Central Directory, but we don't warn or throw because Android + // ignores unused CD data. + + return cdRecords; + } + + static void setUnsignedInt16(ByteBuffer buffer, int offset, int value) { + if ((value < 0) || (value > 0xffff)) { + throw new IllegalArgumentException("uint16 value of out range: " + value); + } + buffer.putShort(offset, (short) value); + } + + static void setUnsignedInt32(ByteBuffer buffer, int offset, long value) { + if ((value < 0) || (value > 0xffffffffL)) { + throw new IllegalArgumentException("uint32 value of out range: " + value); + } + buffer.putInt(offset, (int) value); + } + + public static void putUnsignedInt16(ByteBuffer buffer, int value) { + if ((value < 0) || (value > 0xffff)) { + throw new IllegalArgumentException("uint16 value of out range: " + value); + } + buffer.putShort((short) value); + } + + static long getUnsignedInt32(ByteBuffer buffer, int offset) { + return buffer.getInt(offset) & 0xffffffffL; + } + + static long getUnsignedInt32(ByteBuffer buffer) { + return buffer.getInt() & 0xffffffffL; + } + + static void putUnsignedInt32(ByteBuffer buffer, long value) { + if ((value < 0) || (value > 0xffffffffL)) { + throw new IllegalArgumentException("uint32 value of out range: " + value); + } + buffer.putInt((int) value); + } + + public static DeflateResult deflate(ByteBuffer input) { + byte[] inputBuf; + int inputOffset; + int inputLength = input.remaining(); + if (input.hasArray()) { + inputBuf = input.array(); + inputOffset = input.arrayOffset() + input.position(); + input.position(input.limit()); + } else { + inputBuf = new byte[inputLength]; + inputOffset = 0; + input.get(inputBuf); + } + CRC32 crc32 = new CRC32(); + crc32.update(inputBuf, inputOffset, inputLength); + long crc32Value = crc32.getValue(); + ByteArrayOutputStream out = new ByteArrayOutputStream(); + Deflater deflater = new Deflater(9, true); + deflater.setInput(inputBuf, inputOffset, inputLength); + deflater.finish(); + byte[] buf = new byte[65536]; + while (!deflater.finished()) { + int chunkSize = deflater.deflate(buf); + out.write(buf, 0, chunkSize); + } + return new DeflateResult(inputLength, crc32Value, out.toByteArray()); + } + + public static class DeflateResult { + public final int inputSizeBytes; + public final long inputCrc32; + public final byte[] output; + + public DeflateResult(int inputSizeBytes, long inputCrc32, byte[] output) { + this.inputSizeBytes = inputSizeBytes; + this.inputCrc32 = inputCrc32; + this.output = output; + } + } +}
\ No newline at end of file diff --git a/platform/android/java/editor/src/main/java/com/android/apksig/util/DataSink.java b/platform/android/java/editor/src/main/java/com/android/apksig/util/DataSink.java new file mode 100644 index 0000000000..5042933f1f --- /dev/null +++ b/platform/android/java/editor/src/main/java/com/android/apksig/util/DataSink.java @@ -0,0 +1,46 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.apksig.util; + +import java.io.IOException; +import java.nio.ByteBuffer; + +/** + * Consumer of input data which may be provided in one go or in chunks. + */ +public interface DataSink { + + /** + * Consumes the provided chunk of data. + * + * <p>This data sink guarantees to not hold references to the provided buffer after this method + * terminates. + * + * @throws IndexOutOfBoundsException if {@code offset} or {@code length} are negative, or if + * {@code offset + length} is greater than {@code buf.length}. + */ + void consume(byte[] buf, int offset, int length) throws IOException; + + /** + * Consumes all remaining data in the provided buffer and advances the buffer's position + * to the buffer's limit. + * + * <p>This data sink guarantees to not hold references to the provided buffer after this method + * terminates. + */ + void consume(ByteBuffer buf) throws IOException; +} diff --git a/platform/android/java/editor/src/main/java/com/android/apksig/util/DataSinks.java b/platform/android/java/editor/src/main/java/com/android/apksig/util/DataSinks.java new file mode 100644 index 0000000000..d9562d8341 --- /dev/null +++ b/platform/android/java/editor/src/main/java/com/android/apksig/util/DataSinks.java @@ -0,0 +1,75 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.apksig.util; + +import com.android.apksig.internal.util.ByteArrayDataSink; +import com.android.apksig.internal.util.MessageDigestSink; +import com.android.apksig.internal.util.OutputStreamDataSink; +import com.android.apksig.internal.util.RandomAccessFileDataSink; +import java.io.OutputStream; +import java.io.RandomAccessFile; +import java.security.MessageDigest; + +/** + * Utility methods for working with {@link DataSink} abstraction. + */ +public abstract class DataSinks { + private DataSinks() {} + + /** + * Returns a {@link DataSink} which outputs received data into the provided + * {@link OutputStream}. + */ + public static DataSink asDataSink(OutputStream out) { + return new OutputStreamDataSink(out); + } + + /** + * Returns a {@link DataSink} which outputs received data into the provided file, sequentially, + * starting at the beginning of the file. + */ + public static DataSink asDataSink(RandomAccessFile file) { + return new RandomAccessFileDataSink(file); + } + + /** + * Returns a {@link DataSink} which forwards data into the provided {@link MessageDigest} + * instances via their {@code update} method. Each {@code MessageDigest} instance receives the + * same data. + */ + public static DataSink asDataSink(MessageDigest... digests) { + return new MessageDigestSink(digests); + } + + /** + * Returns a new in-memory {@link DataSink} which exposes all data consumed so far via the + * {@link DataSource} interface. + */ + public static ReadableDataSink newInMemoryDataSink() { + return new ByteArrayDataSink(); + } + + /** + * Returns a new in-memory {@link DataSink} which exposes all data consumed so far via the + * {@link DataSource} interface. + * + * @param initialCapacity initial capacity in bytes + */ + public static ReadableDataSink newInMemoryDataSink(int initialCapacity) { + return new ByteArrayDataSink(initialCapacity); + } +} diff --git a/platform/android/java/editor/src/main/java/com/android/apksig/util/DataSource.java b/platform/android/java/editor/src/main/java/com/android/apksig/util/DataSource.java new file mode 100644 index 0000000000..a89a87c5f8 --- /dev/null +++ b/platform/android/java/editor/src/main/java/com/android/apksig/util/DataSource.java @@ -0,0 +1,110 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.apksig.util; + +import java.io.IOException; +import java.nio.ByteBuffer; + +/** + * Abstract representation of a source of data. + * + * <p>This abstraction serves three purposes: + * <ul> + * <li>Transparent handling of different types of sources, such as {@code byte[]}, + * {@link java.nio.ByteBuffer}, {@link java.io.RandomAccessFile}, memory-mapped file.</li> + * <li>Support sources larger than 2 GB. If all sources were smaller than 2 GB, {@code ByteBuffer} + * may have worked as the unifying abstraction.</li> + * <li>Support sources which do not fit into logical memory as a contiguous region.</li> + * </ul> + * + * <p>There are following ways to obtain a chunk of data from the data source: + * <ul> + * <li>Stream the chunk's data into a {@link DataSink} using + * {@link #feed(long, long, DataSink) feed}. This is best suited for scenarios where there is no + * need to have the chunk's data accessible at the same time, for example, when computing the + * digest of the chunk. If you need to keep the chunk's data around after {@code feed} + * completes, you must create a copy during {@code feed}. However, in that case the following + * methods of obtaining the chunk's data may be more appropriate.</li> + * <li>Obtain a {@link ByteBuffer} containing the chunk's data using + * {@link #getByteBuffer(long, int) getByteBuffer}. Depending on the data source, the chunk's + * data may or may not be copied by this operation. This is best suited for scenarios where + * you need to access the chunk's data in arbitrary order, but don't need to modify the data and + * thus don't require a copy of the data.</li> + * <li>Copy the chunk's data to a {@link ByteBuffer} using + * {@link #copyTo(long, int, ByteBuffer) copyTo}. This is best suited for scenarios where + * you require a copy of the chunk's data, such as to when you need to modify the data. + * </li> + * </ul> + */ +public interface DataSource { + + /** + * Returns the amount of data (in bytes) contained in this data source. + */ + long size(); + + /** + * Feeds the specified chunk from this data source into the provided sink. + * + * @param offset index (in bytes) at which the chunk starts inside data source + * @param size size (in bytes) of the chunk + * + * @throws IndexOutOfBoundsException if {@code offset} or {@code size} is negative, or if + * {@code offset + size} is greater than {@link #size()}. + */ + void feed(long offset, long size, DataSink sink) throws IOException; + + /** + * Returns a buffer holding the contents of the specified chunk of data from this data source. + * Changes to the data source are not guaranteed to be reflected in the returned buffer. + * Similarly, changes in the buffer are not guaranteed to be reflected in the data source. + * + * <p>The returned buffer's position is {@code 0}, and the buffer's limit and capacity is + * {@code size}. + * + * @param offset index (in bytes) at which the chunk starts inside data source + * @param size size (in bytes) of the chunk + * + * @throws IndexOutOfBoundsException if {@code offset} or {@code size} is negative, or if + * {@code offset + size} is greater than {@link #size()}. + */ + ByteBuffer getByteBuffer(long offset, int size) throws IOException; + + /** + * Copies the specified chunk from this data source into the provided destination buffer, + * advancing the destination buffer's position by {@code size}. + * + * @param offset index (in bytes) at which the chunk starts inside data source + * @param size size (in bytes) of the chunk + * + * @throws IndexOutOfBoundsException if {@code offset} or {@code size} is negative, or if + * {@code offset + size} is greater than {@link #size()}. + */ + void copyTo(long offset, int size, ByteBuffer dest) throws IOException; + + /** + * Returns a data source representing the specified region of data of this data source. Changes + * to data represented by this data source will also be visible in the returned data source. + * + * @param offset index (in bytes) at which the region starts inside data source + * @param size size (in bytes) of the region + * + * @throws IndexOutOfBoundsException if {@code offset} or {@code size} is negative, or if + * {@code offset + size} is greater than {@link #size()}. + */ + DataSource slice(long offset, long size); +} diff --git a/platform/android/java/editor/src/main/java/com/android/apksig/util/DataSources.java b/platform/android/java/editor/src/main/java/com/android/apksig/util/DataSources.java new file mode 100644 index 0000000000..1f0b40b66a --- /dev/null +++ b/platform/android/java/editor/src/main/java/com/android/apksig/util/DataSources.java @@ -0,0 +1,80 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.apksig.util; + +import com.android.apksig.internal.util.ByteBufferDataSource; +import com.android.apksig.internal.util.FileChannelDataSource; +import java.io.RandomAccessFile; +import java.nio.ByteBuffer; +import java.nio.channels.FileChannel; + +/** + * Utility methods for working with {@link DataSource} abstraction. + */ +public abstract class DataSources { + private DataSources() {} + + /** + * Returns a {@link DataSource} backed by the provided {@link ByteBuffer}. The data source + * represents the data contained between the position and limit of the buffer. Changes to the + * buffer's contents will be visible in the data source. + */ + public static DataSource asDataSource(ByteBuffer buffer) { + if (buffer == null) { + throw new NullPointerException(); + } + return new ByteBufferDataSource(buffer); + } + + /** + * Returns a {@link DataSource} backed by the provided {@link RandomAccessFile}. Changes to the + * file, including changes to size of file, will be visible in the data source. + */ + public static DataSource asDataSource(RandomAccessFile file) { + return asDataSource(file.getChannel()); + } + + /** + * Returns a {@link DataSource} backed by the provided region of the {@link RandomAccessFile}. + * Changes to the file will be visible in the data source. + */ + public static DataSource asDataSource(RandomAccessFile file, long offset, long size) { + return asDataSource(file.getChannel(), offset, size); + } + + /** + * Returns a {@link DataSource} backed by the provided {@link FileChannel}. Changes to the + * file, including changes to size of file, will be visible in the data source. + */ + public static DataSource asDataSource(FileChannel channel) { + if (channel == null) { + throw new NullPointerException(); + } + return new FileChannelDataSource(channel); + } + + /** + * Returns a {@link DataSource} backed by the provided region of the {@link FileChannel}. + * Changes to the file will be visible in the data source. + */ + public static DataSource asDataSource(FileChannel channel, long offset, long size) { + if (channel == null) { + throw new NullPointerException(); + } + return new FileChannelDataSource(channel, offset, size); + } +} diff --git a/platform/android/java/editor/src/main/java/com/android/apksig/util/ReadableDataSink.java b/platform/android/java/editor/src/main/java/com/android/apksig/util/ReadableDataSink.java new file mode 100644 index 0000000000..ffc3e2d351 --- /dev/null +++ b/platform/android/java/editor/src/main/java/com/android/apksig/util/ReadableDataSink.java @@ -0,0 +1,24 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.apksig.util; + +/** + * {@link DataSink} which exposes all data consumed so far as a {@link DataSource}. This abstraction + * offers append-only write access and random read access. + */ +public interface ReadableDataSink extends DataSink, DataSource { +} diff --git a/platform/android/java/editor/src/main/java/com/android/apksig/util/RunnablesExecutor.java b/platform/android/java/editor/src/main/java/com/android/apksig/util/RunnablesExecutor.java new file mode 100644 index 0000000000..74017f8d8c --- /dev/null +++ b/platform/android/java/editor/src/main/java/com/android/apksig/util/RunnablesExecutor.java @@ -0,0 +1,61 @@ +/* + * Copyright (C) 2019 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.apksig.util; + +import static java.util.concurrent.TimeUnit.MILLISECONDS; + +import java.util.concurrent.ArrayBlockingQueue; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Phaser; +import java.util.concurrent.ThreadPoolExecutor; + +public interface RunnablesExecutor { + static final RunnablesExecutor SINGLE_THREADED = p -> p.createRunnable().run(); + + static final RunnablesExecutor MULTI_THREADED = new RunnablesExecutor() { + private final int PARALLELISM = Math.min(32, Runtime.getRuntime().availableProcessors()); + private final int QUEUE_SIZE = 4; + + @Override + public void execute(RunnablesProvider provider) { + final ExecutorService mExecutor = + new ThreadPoolExecutor(PARALLELISM, PARALLELISM, + 0L, MILLISECONDS, + new ArrayBlockingQueue<>(QUEUE_SIZE), + new ThreadPoolExecutor.CallerRunsPolicy()); + + Phaser tasks = new Phaser(1); + + for (int i = 0; i < PARALLELISM; ++i) { + Runnable task = () -> { + Runnable r = provider.createRunnable(); + r.run(); + tasks.arriveAndDeregister(); + }; + tasks.register(); + mExecutor.execute(task); + } + + // Waiting for the tasks to complete. + tasks.arriveAndAwaitAdvance(); + + mExecutor.shutdownNow(); + } + }; + + void execute(RunnablesProvider provider); +} diff --git a/platform/android/java/editor/src/main/java/com/android/apksig/util/RunnablesProvider.java b/platform/android/java/editor/src/main/java/com/android/apksig/util/RunnablesProvider.java new file mode 100644 index 0000000000..f96dcfe4d1 --- /dev/null +++ b/platform/android/java/editor/src/main/java/com/android/apksig/util/RunnablesProvider.java @@ -0,0 +1,21 @@ +/* + * Copyright (C) 2019 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.apksig.util; + +public interface RunnablesProvider { + Runnable createRunnable(); +} diff --git a/platform/android/java/editor/src/main/java/com/android/apksig/zip/ZipFormatException.java b/platform/android/java/editor/src/main/java/com/android/apksig/zip/ZipFormatException.java new file mode 100644 index 0000000000..6116c0da80 --- /dev/null +++ b/platform/android/java/editor/src/main/java/com/android/apksig/zip/ZipFormatException.java @@ -0,0 +1,32 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.apksig.zip; + +/** + * Indicates that a ZIP archive is not well-formed. + */ +public class ZipFormatException extends Exception { + private static final long serialVersionUID = 1L; + + public ZipFormatException(String message) { + super(message); + } + + public ZipFormatException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/platform/android/java/editor/src/main/java/com/android/apksig/zip/ZipSections.java b/platform/android/java/editor/src/main/java/com/android/apksig/zip/ZipSections.java new file mode 100644 index 0000000000..17bce05187 --- /dev/null +++ b/platform/android/java/editor/src/main/java/com/android/apksig/zip/ZipSections.java @@ -0,0 +1,85 @@ +/* + * Copyright (C) 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.apksig.zip; + +import java.nio.ByteBuffer; + +/** + * Base representation of an APK's zip sections containing the central directory's offset, the size + * of the central directory in bytes, the number of records in the central directory, the offset + * of the end of central directory, and a ByteBuffer containing the end of central directory + * contents. + */ +public class ZipSections { + private final long mCentralDirectoryOffset; + private final long mCentralDirectorySizeBytes; + private final int mCentralDirectoryRecordCount; + private final long mEocdOffset; + private final ByteBuffer mEocd; + + public ZipSections( + long centralDirectoryOffset, + long centralDirectorySizeBytes, + int centralDirectoryRecordCount, + long eocdOffset, + ByteBuffer eocd) { + mCentralDirectoryOffset = centralDirectoryOffset; + mCentralDirectorySizeBytes = centralDirectorySizeBytes; + mCentralDirectoryRecordCount = centralDirectoryRecordCount; + mEocdOffset = eocdOffset; + mEocd = eocd; + } + + /** + * Returns the start offset of the ZIP Central Directory. This value is taken from the + * ZIP End of Central Directory record. + */ + public long getZipCentralDirectoryOffset() { + return mCentralDirectoryOffset; + } + + /** + * Returns the size (in bytes) of the ZIP Central Directory. This value is taken from the + * ZIP End of Central Directory record. + */ + public long getZipCentralDirectorySizeBytes() { + return mCentralDirectorySizeBytes; + } + + /** + * Returns the number of records in the ZIP Central Directory. This value is taken from the + * ZIP End of Central Directory record. + */ + public int getZipCentralDirectoryRecordCount() { + return mCentralDirectoryRecordCount; + } + + /** + * Returns the start offset of the ZIP End of Central Directory record. The record extends + * until the very end of the APK. + */ + public long getZipEndOfCentralDirectoryOffset() { + return mEocdOffset; + } + + /** + * Returns the contents of the ZIP End of Central Directory. + */ + public ByteBuffer getZipEndOfCentralDirectory() { + return mEocd; + } +}
\ No newline at end of file diff --git a/platform/android/java/editor/src/main/java/org/godotengine/editor/GodotEditor.kt b/platform/android/java/editor/src/main/java/org/godotengine/editor/GodotEditor.kt index 5515347bd6..9cc133046b 100644 --- a/platform/android/java/editor/src/main/java/org/godotengine/editor/GodotEditor.kt +++ b/platform/android/java/editor/src/main/java/org/godotengine/editor/GodotEditor.kt @@ -43,8 +43,11 @@ import android.widget.Toast import androidx.annotation.CallSuper import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen import androidx.window.layout.WindowMetricsCalculator +import org.godotengine.editor.utils.signApk +import org.godotengine.editor.utils.verifyApk import org.godotengine.godot.GodotActivity import org.godotengine.godot.GodotLib +import org.godotengine.godot.error.Error import org.godotengine.godot.utils.PermissionsUtil import org.godotengine.godot.utils.ProcessPhoenix import java.util.* @@ -203,7 +206,14 @@ open class GodotEditor : GodotActivity() { } if (editorWindowInfo.windowClassName == javaClass.name) { Log.d(TAG, "Restarting ${editorWindowInfo.windowClassName} with parameters ${args.contentToString()}") - ProcessPhoenix.triggerRebirth(this, newInstance) + val godot = godot + if (godot != null) { + godot.destroyAndKillProcess { + ProcessPhoenix.triggerRebirth(this, newInstance) + } + } else { + ProcessPhoenix.triggerRebirth(this, newInstance) + } } else { Log.d(TAG, "Starting ${editorWindowInfo.windowClassName} with parameters ${args.contentToString()}") newInstance.putExtra(EXTRA_NEW_LAUNCH, true) @@ -343,4 +353,20 @@ open class GodotEditor : GodotActivity() { } } } + + override fun signApk( + inputPath: String, + outputPath: String, + keystorePath: String, + keystoreUser: String, + keystorePassword: String + ): Error { + val godot = godot ?: return Error.ERR_UNCONFIGURED + return signApk(godot.fileAccessHandler, inputPath, outputPath, keystorePath, keystoreUser, keystorePassword) + } + + override fun verifyApk(apkPath: String): Error { + val godot = godot ?: return Error.ERR_UNCONFIGURED + return verifyApk(godot.fileAccessHandler, apkPath) + } } diff --git a/platform/android/java/editor/src/main/java/org/godotengine/editor/GodotGame.kt b/platform/android/java/editor/src/main/java/org/godotengine/editor/GodotGame.kt index 8e4e089211..2bcfba559c 100644 --- a/platform/android/java/editor/src/main/java/org/godotengine/editor/GodotGame.kt +++ b/platform/android/java/editor/src/main/java/org/godotengine/editor/GodotGame.kt @@ -30,6 +30,8 @@ package org.godotengine.editor +import org.godotengine.godot.GodotLib + /** * Drives the 'run project' window of the Godot Editor. */ @@ -39,9 +41,9 @@ class GodotGame : GodotEditor() { override fun overrideOrientationRequest() = false - override fun enableLongPressGestures() = false + override fun enableLongPressGestures() = java.lang.Boolean.parseBoolean(GodotLib.getGlobal("input_devices/pointing/android/enable_long_press_as_right_click")) - override fun enablePanAndScaleGestures() = false + override fun enablePanAndScaleGestures() = java.lang.Boolean.parseBoolean(GodotLib.getGlobal("input_devices/pointing/android/enable_pan_and_scale_gestures")) override fun checkForProjectPermissionsToEnable() { // Nothing to do.. by the time we get here, the project permissions will have already diff --git a/platform/android/java/editor/src/main/java/org/godotengine/editor/utils/ApkSignerUtil.kt b/platform/android/java/editor/src/main/java/org/godotengine/editor/utils/ApkSignerUtil.kt new file mode 100644 index 0000000000..42c18c9562 --- /dev/null +++ b/platform/android/java/editor/src/main/java/org/godotengine/editor/utils/ApkSignerUtil.kt @@ -0,0 +1,204 @@ +/**************************************************************************/ +/* ApkSignerUtil.kt */ +/**************************************************************************/ +/* This file is part of: */ +/* GODOT ENGINE */ +/* https://godotengine.org */ +/**************************************************************************/ +/* Copyright (c) 2014-present Godot Engine contributors (see AUTHORS.md). */ +/* Copyright (c) 2007-2014 Juan Linietsky, Ariel Manzur. */ +/* */ +/* Permission is hereby granted, free of charge, to any person obtaining */ +/* a copy of this software and associated documentation files (the */ +/* "Software"), to deal in the Software without restriction, including */ +/* without limitation the rights to use, copy, modify, merge, publish, */ +/* distribute, sublicense, and/or sell copies of the Software, and to */ +/* permit persons to whom the Software is furnished to do so, subject to */ +/* the following conditions: */ +/* */ +/* The above copyright notice and this permission notice shall be */ +/* included in all copies or substantial portions of the Software. */ +/* */ +/* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, */ +/* EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF */ +/* MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. */ +/* IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY */ +/* CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, */ +/* TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE */ +/* SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ +/**************************************************************************/ + +@file:JvmName("ApkSignerUtil") + +package org.godotengine.editor.utils + +import android.util.Log +import com.android.apksig.ApkSigner +import com.android.apksig.ApkVerifier +import org.bouncycastle.jce.provider.BouncyCastleProvider +import org.godotengine.godot.error.Error +import org.godotengine.godot.io.file.FileAccessHandler +import java.io.File +import java.security.KeyStore +import java.security.PrivateKey +import java.security.Security +import java.security.cert.X509Certificate +import java.util.ArrayList + + +/** + * Contains utilities methods to sign and verify Android apks using apksigner + */ +private const val TAG = "ApkSignerUtil" + +private const val DEFAULT_KEYSTORE_TYPE = "PKCS12" + +/** + * Validates that the correct version of the BouncyCastleProvider is added. + */ +private fun validateBouncyCastleProvider() { + val bcProvider = Security.getProvider(BouncyCastleProvider.PROVIDER_NAME) + if (bcProvider !is BouncyCastleProvider) { + Log.v(TAG, "Removing BouncyCastleProvider $bcProvider (${bcProvider::class.java.name})") + Security.removeProvider(BouncyCastleProvider.PROVIDER_NAME) + + val updatedBcProvider = BouncyCastleProvider() + val addResult = Security.addProvider(updatedBcProvider) + if (addResult == -1) { + Log.e(TAG, "Unable to add BouncyCastleProvider ${updatedBcProvider::class.java.name}") + } else { + Log.v(TAG, "Updated BouncyCastleProvider to $updatedBcProvider (${updatedBcProvider::class.java.name})") + } + } +} + +/** + * Verifies the given Android apk + * + * @return true if verification was successful, false otherwise. + */ +internal fun verifyApk(fileAccessHandler: FileAccessHandler, apkPath: String): Error { + if (!fileAccessHandler.fileExists(apkPath)) { + Log.e(TAG, "Unable to access apk $apkPath") + return Error.ERR_FILE_NOT_FOUND + } + + try { + val apkVerifier = ApkVerifier.Builder(File(apkPath)).build() + + Log.v(TAG, "Verifying apk $apkPath") + val result = apkVerifier.verify() + + Log.v(TAG, "Verification result: ${result.isVerified}") + return if (result.isVerified) { + Error.OK + } else { + Error.FAILED + } + } catch (e: Exception) { + Log.e(TAG, "Error occurred during verification for $apkPath", e) + return Error.ERR_INVALID_DATA + } +} + +/** + * Signs the given Android apk + * + * @return true if signing is successful, false otherwise. + */ +internal fun signApk(fileAccessHandler: FileAccessHandler, + inputPath: String, + outputPath: String, + keystorePath: String, + keystoreUser: String, + keystorePassword: String, + keystoreType: String = DEFAULT_KEYSTORE_TYPE): Error { + if (!fileAccessHandler.fileExists(inputPath)) { + Log.e(TAG, "Unable to access input path $inputPath") + return Error.ERR_FILE_NOT_FOUND + } + + val tmpOutputPath = if (outputPath != inputPath) { outputPath } else { "$outputPath.signed" } + if (!fileAccessHandler.canAccess(tmpOutputPath)) { + Log.e(TAG, "Unable to access output path $tmpOutputPath") + return Error.ERR_FILE_NO_PERMISSION + } + + if (!fileAccessHandler.fileExists(keystorePath) || + keystoreUser.isBlank() || + keystorePassword.isBlank()) { + Log.e(TAG, "Invalid keystore credentials") + return Error.ERR_INVALID_PARAMETER + } + + validateBouncyCastleProvider() + + // 1. Obtain a KeyStore implementation + val keyStore = KeyStore.getInstance(keystoreType) + + // 2. Load the keystore + val inputStream = fileAccessHandler.getInputStream(keystorePath) + if (inputStream == null) { + Log.e(TAG, "Unable to retrieve input stream from $keystorePath") + return Error.ERR_FILE_CANT_READ + } + try { + inputStream.use { + Log.v(TAG, "Loading keystore $keystorePath with type $keystoreType") + keyStore.load(it, keystorePassword.toCharArray()) + } + } catch (e: Exception) { + Log.e(TAG, "Unable to load the keystore from $keystorePath", e) + return Error.ERR_FILE_CANT_READ + } + + // 3. Load the private key and cert chain from the keystore + if (!keyStore.isKeyEntry(keystoreUser)) { + Log.e(TAG, "Key alias $keystoreUser is invalid") + return Error.ERR_INVALID_PARAMETER + } + + val keyStoreKey = try { + keyStore.getKey(keystoreUser, keystorePassword.toCharArray()) + } catch (e: Exception) { + Log.e(TAG, "Unable to recover keystore alias $keystoreUser") + return Error.ERR_CANT_ACQUIRE_RESOURCE + } + + if (keyStoreKey !is PrivateKey) { + Log.e(TAG, "Unable to recover keystore alias $keystoreUser") + return Error.ERR_CANT_ACQUIRE_RESOURCE + } + + val certChain = keyStore.getCertificateChain(keystoreUser) + if (certChain.isNullOrEmpty()) { + Log.e(TAG, "Keystore alias $keystoreUser does not contain certificates") + return Error.ERR_INVALID_DATA + } + val certs = ArrayList<X509Certificate>(certChain.size) + for (cert in certChain) { + certs.add(cert as X509Certificate) + } + + val signerConfig = ApkSigner.SignerConfig.Builder(keystoreUser, keyStoreKey, certs).build() + + val apkSigner = ApkSigner.Builder(listOf(signerConfig)) + .setInputApk(File(inputPath)) + .setOutputApk(File(tmpOutputPath)) + .build() + + try { + apkSigner.sign() + } catch (e: Exception) { + Log.e(TAG, "Unable to sign $inputPath", e) + return Error.FAILED + } + + if (outputPath != tmpOutputPath && !fileAccessHandler.renameFile(tmpOutputPath, outputPath)) { + Log.e(TAG, "Unable to rename temp output file $tmpOutputPath to $outputPath") + return Error.ERR_FILE_CANT_WRITE + } + + Log.v(TAG, "Signed $inputPath") + return Error.OK +} diff --git a/platform/android/java/lib/src/org/godotengine/godot/Godot.kt b/platform/android/java/lib/src/org/godotengine/godot/Godot.kt index 290be727ab..49e8ffb008 100644 --- a/platform/android/java/lib/src/org/godotengine/godot/Godot.kt +++ b/platform/android/java/lib/src/org/godotengine/godot/Godot.kt @@ -39,8 +39,6 @@ import android.content.res.Configuration import android.content.res.Resources import android.graphics.Color import android.hardware.Sensor -import android.hardware.SensorEvent -import android.hardware.SensorEventListener import android.hardware.SensorManager import android.os.* import android.util.Log @@ -52,7 +50,9 @@ import androidx.core.view.ViewCompat import androidx.core.view.WindowInsetsAnimationCompat import androidx.core.view.WindowInsetsCompat import com.google.android.vending.expansion.downloader.* +import org.godotengine.godot.error.Error import org.godotengine.godot.input.GodotEditText +import org.godotengine.godot.input.GodotInputHandler import org.godotengine.godot.io.directory.DirectoryAccessHandler import org.godotengine.godot.io.file.FileAccessHandler import org.godotengine.godot.plugin.GodotPluginRegistry @@ -73,6 +73,8 @@ import java.io.InputStream import java.lang.Exception import java.security.MessageDigest import java.util.* +import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.atomic.AtomicReference /** * Core component used to interface with the native layer of the engine. @@ -80,36 +82,48 @@ import java.util.* * Can be hosted by [Activity], [Fragment] or [Service] android components, so long as its * lifecycle methods are properly invoked. */ -class Godot(private val context: Context) : SensorEventListener { +class Godot(private val context: Context) { - private companion object { + internal companion object { private val TAG = Godot::class.java.simpleName - } - private val windowManager: WindowManager by lazy { - requireActivity().getSystemService(Context.WINDOW_SERVICE) as WindowManager + // Supported build flavors + const val EDITOR_FLAVOR = "editor" + const val TEMPLATE_FLAVOR = "template" + + /** + * @return true if this is an editor build, false if this is a template build + */ + fun isEditorBuild() = BuildConfig.FLAVOR == EDITOR_FLAVOR } + + private val mSensorManager: SensorManager = context.getSystemService(Context.SENSOR_SERVICE) as SensorManager + private val mClipboard: ClipboardManager = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + private val vibratorService: Vibrator = context.getSystemService(Context.VIBRATOR_SERVICE) as Vibrator + private val pluginRegistry: GodotPluginRegistry by lazy { GodotPluginRegistry.getPluginRegistry() } - private val mSensorManager: SensorManager by lazy { - requireActivity().getSystemService(Context.SENSOR_SERVICE) as SensorManager - } + + private val accelerometer_enabled = AtomicBoolean(false) private val mAccelerometer: Sensor? by lazy { mSensorManager.getDefaultSensor(Sensor.TYPE_ACCELEROMETER) } + + private val gravity_enabled = AtomicBoolean(false) private val mGravity: Sensor? by lazy { mSensorManager.getDefaultSensor(Sensor.TYPE_GRAVITY) } + + private val magnetometer_enabled = AtomicBoolean(false) private val mMagnetometer: Sensor? by lazy { mSensorManager.getDefaultSensor(Sensor.TYPE_MAGNETIC_FIELD) } + + private val gyroscope_enabled = AtomicBoolean(false) private val mGyroscope: Sensor? by lazy { mSensorManager.getDefaultSensor(Sensor.TYPE_GYROSCOPE) } - private val mClipboard: ClipboardManager by lazy { - requireActivity().getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager - } private val uiChangeListener = View.OnSystemUiVisibilityChangeListener { visibility: Int -> if (visibility and View.SYSTEM_UI_FLAG_FULLSCREEN == 0) { @@ -126,6 +140,12 @@ class Godot(private val context: Context) : SensorEventListener { val fileAccessHandler = FileAccessHandler(context) val netUtils = GodotNetUtils(context) private val commandLineFileParser = CommandLineFileParser() + private val godotInputHandler = GodotInputHandler(context, this) + + /** + * Task to run when the engine terminates. + */ + private val runOnTerminate = AtomicReference<Runnable>() /** * Tracks whether [onCreate] was completed successfully. @@ -148,6 +168,17 @@ class Godot(private val context: Context) : SensorEventListener { private var renderViewInitialized = false private var primaryHost: GodotHost? = null + /** + * Tracks whether we're in the RESUMED lifecycle state. + * See [onResume] and [onPause] + */ + private var resumed = false + + /** + * Tracks whether [onGodotSetupCompleted] fired. + */ + private val godotMainLoopStarted = AtomicBoolean(false) + var io: GodotIO? = null private var commandLine : MutableList<String> = ArrayList<String>() @@ -192,6 +223,8 @@ class Godot(private val context: Context) : SensorEventListener { return } + Log.v(TAG, "OnCreate: $primaryHost") + darkMode = context.resources?.configuration?.uiMode?.and(Configuration.UI_MODE_NIGHT_MASK) == Configuration.UI_MODE_NIGHT_YES beginBenchmarkMeasure("Startup", "Godot::onCreate") @@ -200,6 +233,8 @@ class Godot(private val context: Context) : SensorEventListener { val activity = requireActivity() val window = activity.window window.addFlags(WindowManager.LayoutParams.FLAG_TURN_SCREEN_ON) + + Log.v(TAG, "Initializing Godot plugin registry") GodotPluginRegistry.initializePluginRegistry(this, primaryHost.getHostPlugins(this)) if (io == null) { io = GodotIO(activity) @@ -323,13 +358,17 @@ class Godot(private val context: Context) : SensorEventListener { return false } - if (expansionPackPath.isNotEmpty()) { - commandLine.add("--main-pack") - commandLine.add(expansionPackPath) - } - val activity = requireActivity() - if (!nativeLayerInitializeCompleted) { - nativeLayerInitializeCompleted = GodotLib.initialize( + Log.v(TAG, "OnInitNativeLayer: $host") + + beginBenchmarkMeasure("Startup", "Godot::onInitNativeLayer") + try { + if (expansionPackPath.isNotEmpty()) { + commandLine.add("--main-pack") + commandLine.add(expansionPackPath) + } + val activity = requireActivity() + if (!nativeLayerInitializeCompleted) { + nativeLayerInitializeCompleted = GodotLib.initialize( activity, this, activity.assets, @@ -338,15 +377,20 @@ class Godot(private val context: Context) : SensorEventListener { directoryAccessHandler, fileAccessHandler, useApkExpansion, - ) - } + ) + Log.v(TAG, "Godot native layer initialization completed: $nativeLayerInitializeCompleted") + } - if (nativeLayerInitializeCompleted && !nativeLayerSetupCompleted) { - nativeLayerSetupCompleted = GodotLib.setup(commandLine.toTypedArray(), tts) - if (!nativeLayerSetupCompleted) { - Log.e(TAG, "Unable to setup the Godot engine! Aborting...") - alert(R.string.error_engine_setup_message, R.string.text_error_title, this::forceQuit) + if (nativeLayerInitializeCompleted && !nativeLayerSetupCompleted) { + nativeLayerSetupCompleted = GodotLib.setup(commandLine.toTypedArray(), tts) + if (!nativeLayerSetupCompleted) { + throw IllegalStateException("Unable to setup the Godot engine! Aborting...") + } else { + Log.v(TAG, "Godot native layer setup completed") + } } + } finally { + endBenchmarkMeasure("Startup", "Godot::onInitNativeLayer") } return isNativeInitialized() } @@ -370,6 +414,9 @@ class Godot(private val context: Context) : SensorEventListener { throw IllegalStateException("onInitNativeLayer() must be invoked successfully prior to initializing the render view") } + Log.v(TAG, "OnInitRenderView: $host") + + beginBenchmarkMeasure("Startup", "Godot::onInitRenderView") try { val activity: Activity = host.activity containerLayout = providedContainerLayout @@ -392,13 +439,12 @@ class Godot(private val context: Context) : SensorEventListener { containerLayout?.addView(editText) renderView = if (usesVulkan()) { if (!meetsVulkanRequirements(activity.packageManager)) { - alert(R.string.error_missing_vulkan_requirements_message, R.string.text_error_title, this::forceQuit) - return null + throw IllegalStateException(activity.getString(R.string.error_missing_vulkan_requirements_message)) } - GodotVulkanRenderView(host, this) + GodotVulkanRenderView(host, this, godotInputHandler) } else { // Fallback to openGl - GodotGLRenderView(host, this, xrMode, useDebugOpengl) + GodotGLRenderView(host, this, godotInputHandler, xrMode, useDebugOpengl) } if (host == primaryHost) { @@ -482,11 +528,14 @@ class Godot(private val context: Context) : SensorEventListener { containerLayout?.removeAllViews() containerLayout = null } + + endBenchmarkMeasure("Startup", "Godot::onInitRenderView") } return containerLayout } fun onStart(host: GodotHost) { + Log.v(TAG, "OnStart: $host") if (host != primaryHost) { return } @@ -495,23 +544,14 @@ class Godot(private val context: Context) : SensorEventListener { } fun onResume(host: GodotHost) { + Log.v(TAG, "OnResume: $host") + resumed = true if (host != primaryHost) { return } renderView?.onActivityResumed() - if (mAccelerometer != null) { - mSensorManager.registerListener(this, mAccelerometer, SensorManager.SENSOR_DELAY_GAME) - } - if (mGravity != null) { - mSensorManager.registerListener(this, mGravity, SensorManager.SENSOR_DELAY_GAME) - } - if (mMagnetometer != null) { - mSensorManager.registerListener(this, mMagnetometer, SensorManager.SENSOR_DELAY_GAME) - } - if (mGyroscope != null) { - mSensorManager.registerListener(this, mGyroscope, SensorManager.SENSOR_DELAY_GAME) - } + registerSensorsIfNeeded() if (useImmersive) { val window = requireActivity().window window.decorView.systemUiVisibility = View.SYSTEM_UI_FLAG_LAYOUT_STABLE or @@ -526,19 +566,41 @@ class Godot(private val context: Context) : SensorEventListener { } } + private fun registerSensorsIfNeeded() { + if (!resumed || !godotMainLoopStarted.get()) { + return + } + + if (accelerometer_enabled.get() && mAccelerometer != null) { + mSensorManager.registerListener(godotInputHandler, mAccelerometer, SensorManager.SENSOR_DELAY_GAME) + } + if (gravity_enabled.get() && mGravity != null) { + mSensorManager.registerListener(godotInputHandler, mGravity, SensorManager.SENSOR_DELAY_GAME) + } + if (magnetometer_enabled.get() && mMagnetometer != null) { + mSensorManager.registerListener(godotInputHandler, mMagnetometer, SensorManager.SENSOR_DELAY_GAME) + } + if (gyroscope_enabled.get() && mGyroscope != null) { + mSensorManager.registerListener(godotInputHandler, mGyroscope, SensorManager.SENSOR_DELAY_GAME) + } + } + fun onPause(host: GodotHost) { + Log.v(TAG, "OnPause: $host") + resumed = false if (host != primaryHost) { return } renderView?.onActivityPaused() - mSensorManager.unregisterListener(this) + mSensorManager.unregisterListener(godotInputHandler) for (plugin in pluginRegistry.allPlugins) { plugin.onMainPause() } } fun onStop(host: GodotHost) { + Log.v(TAG, "OnStop: $host") if (host != primaryHost) { return } @@ -547,6 +609,7 @@ class Godot(private val context: Context) : SensorEventListener { } fun onDestroy(primaryHost: GodotHost) { + Log.v(TAG, "OnDestroy: $primaryHost") if (this.primaryHost != primaryHost) { return } @@ -555,10 +618,7 @@ class Godot(private val context: Context) : SensorEventListener { plugin.onMainDestroy() } - runOnRenderThread { - GodotLib.ondestroy() - forceQuit() - } + renderView?.onActivityDestroyed() } /** @@ -604,18 +664,22 @@ class Godot(private val context: Context) : SensorEventListener { * Invoked on the render thread when the Godot setup is complete. */ private fun onGodotSetupCompleted() { - Log.d(TAG, "OnGodotSetupCompleted") + Log.v(TAG, "OnGodotSetupCompleted") // These properties are defined after Godot setup completion, so we retrieve them here. val longPressEnabled = java.lang.Boolean.parseBoolean(GodotLib.getGlobal("input_devices/pointing/android/enable_long_press_as_right_click")) val panScaleEnabled = java.lang.Boolean.parseBoolean(GodotLib.getGlobal("input_devices/pointing/android/enable_pan_and_scale_gestures")) - val rotaryInputAxis = java.lang.Integer.parseInt(GodotLib.getGlobal("input_devices/pointing/android/rotary_input_scroll_axis")) + val rotaryInputAxisValue = GodotLib.getGlobal("input_devices/pointing/android/rotary_input_scroll_axis") runOnUiThread { renderView?.inputHandler?.apply { enableLongPress(longPressEnabled) enablePanningAndScalingGestures(panScaleEnabled) - setRotaryInputAxis(rotaryInputAxis) + try { + setRotaryInputAxis(Integer.parseInt(rotaryInputAxisValue)) + } catch (e: NumberFormatException) { + Log.w(TAG, e) + } } } @@ -629,7 +693,17 @@ class Godot(private val context: Context) : SensorEventListener { * Invoked on the render thread when the Godot main loop has started. */ private fun onGodotMainLoopStarted() { - Log.d(TAG, "OnGodotMainLoopStarted") + Log.v(TAG, "OnGodotMainLoopStarted") + godotMainLoopStarted.set(true) + + accelerometer_enabled.set(java.lang.Boolean.parseBoolean(GodotLib.getGlobal("input_devices/sensors/enable_accelerometer"))) + gravity_enabled.set(java.lang.Boolean.parseBoolean(GodotLib.getGlobal("input_devices/sensors/enable_gravity"))) + gyroscope_enabled.set(java.lang.Boolean.parseBoolean(GodotLib.getGlobal("input_devices/sensors/enable_gyroscope"))) + magnetometer_enabled.set(java.lang.Boolean.parseBoolean(GodotLib.getGlobal("input_devices/sensors/enable_magnetometer"))) + + runOnUiThread { + registerSensorsIfNeeded() + } for (plugin in pluginRegistry.allPlugins) { plugin.onGodotMainLoopStarted() @@ -637,6 +711,15 @@ class Godot(private val context: Context) : SensorEventListener { primaryHost?.onGodotMainLoopStarted() } + /** + * Invoked on the render thread when the engine is about to terminate. + */ + @Keep + private fun onGodotTerminating() { + Log.v(TAG, "OnGodotTerminating") + runOnTerminate.get()?.run() + } + private fun restart() { primaryHost?.onGodotRestartRequested(this) } @@ -646,12 +729,7 @@ class Godot(private val context: Context) : SensorEventListener { decorView.setOnSystemUiVisibilityChangeListener(uiChangeListener) } - @Keep - private fun alert(message: String, title: String) { - alert(message, title, null) - } - - private fun alert( + fun alert( @StringRes messageResId: Int, @StringRes titleResId: Int, okCallback: Runnable? @@ -660,7 +738,9 @@ class Godot(private val context: Context) : SensorEventListener { alert(res.getString(messageResId), res.getString(titleResId), okCallback) } - private fun alert(message: String, title: String, okCallback: Runnable?) { + @JvmOverloads + @Keep + fun alert(message: String, title: String, okCallback: Runnable? = null) { val activity: Activity = getActivity() ?: return runOnUiThread { val builder = AlertDialog.Builder(activity) @@ -770,8 +850,28 @@ class Godot(private val context: Context) : SensorEventListener { mClipboard.setPrimaryClip(clip) } - private fun forceQuit() { - forceQuit(0) + /** + * Destroys the Godot Engine and kill the process it's running in. + */ + @JvmOverloads + fun destroyAndKillProcess(destroyRunnable: Runnable? = null) { + val host = primaryHost + val activity = host?.activity + if (host == null || activity == null) { + // Run the destroyRunnable right away as we are about to force quit. + destroyRunnable?.run() + + // Fallback to force quit + forceQuit(0) + return + } + + // Store the destroyRunnable so it can be run when the engine is terminating + runOnTerminate.set(destroyRunnable) + + runOnUiThread { + onDestroy(host) + } } @Keep @@ -786,11 +886,7 @@ class Godot(private val context: Context) : SensorEventListener { } ?: return false } - fun onBackPressed(host: GodotHost) { - if (host != primaryHost) { - return - } - + fun onBackPressed() { var shouldQuit = true for (plugin in pluginRegistry.allPlugins) { if (plugin.onMainBackPressed()) { @@ -802,77 +898,6 @@ class Godot(private val context: Context) : SensorEventListener { } } - private fun getRotatedValues(values: FloatArray?): FloatArray? { - if (values == null || values.size != 3) { - return null - } - val rotatedValues = FloatArray(3) - when (windowManager.defaultDisplay.rotation) { - Surface.ROTATION_0 -> { - rotatedValues[0] = values[0] - rotatedValues[1] = values[1] - rotatedValues[2] = values[2] - } - Surface.ROTATION_90 -> { - rotatedValues[0] = -values[1] - rotatedValues[1] = values[0] - rotatedValues[2] = values[2] - } - Surface.ROTATION_180 -> { - rotatedValues[0] = -values[0] - rotatedValues[1] = -values[1] - rotatedValues[2] = values[2] - } - Surface.ROTATION_270 -> { - rotatedValues[0] = values[1] - rotatedValues[1] = -values[0] - rotatedValues[2] = values[2] - } - } - return rotatedValues - } - - override fun onSensorChanged(event: SensorEvent) { - if (renderView == null) { - return - } - - val rotatedValues = getRotatedValues(event.values) - - when (event.sensor.type) { - Sensor.TYPE_ACCELEROMETER -> { - rotatedValues?.let { - renderView?.queueOnRenderThread { - GodotLib.accelerometer(-it[0], -it[1], -it[2]) - } - } - } - Sensor.TYPE_GRAVITY -> { - rotatedValues?.let { - renderView?.queueOnRenderThread { - GodotLib.gravity(-it[0], -it[1], -it[2]) - } - } - } - Sensor.TYPE_MAGNETIC_FIELD -> { - rotatedValues?.let { - renderView?.queueOnRenderThread { - GodotLib.magnetometer(-it[0], -it[1], -it[2]) - } - } - } - Sensor.TYPE_GYROSCOPE -> { - rotatedValues?.let { - renderView?.queueOnRenderThread { - GodotLib.gyroscope(it[0], it[1], it[2]) - } - } - } - } - } - - override fun onAccuracyChanged(sensor: Sensor?, accuracy: Int) {} - /** * Used by the native code (java_godot_wrapper.h) to vibrate the device. * @param durationMs @@ -881,7 +906,6 @@ class Godot(private val context: Context) : SensorEventListener { @Keep private fun vibrate(durationMs: Int, amplitude: Int) { if (durationMs > 0 && requestPermission("VIBRATE")) { - val vibratorService = getActivity()?.getSystemService(Context.VIBRATOR_SERVICE) as Vibrator? ?: return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { if (amplitude <= -1) { vibratorService.vibrate( @@ -1008,7 +1032,7 @@ class Godot(private val context: Context) : SensorEventListener { @Keep private fun initInputDevices() { - renderView?.initInputDevices() + godotInputHandler.initInputDevices() } @Keep @@ -1030,4 +1054,20 @@ class Godot(private val context: Context) : SensorEventListener { private fun nativeDumpBenchmark(benchmarkFile: String) { dumpBenchmark(fileAccessHandler, benchmarkFile) } + + @Keep + private fun nativeSignApk(inputPath: String, + outputPath: String, + keystorePath: String, + keystoreUser: String, + keystorePassword: String): Int { + val signResult = primaryHost?.signApk(inputPath, outputPath, keystorePath, keystoreUser, keystorePassword) ?: Error.ERR_UNAVAILABLE + return signResult.toNativeValue() + } + + @Keep + private fun nativeVerifyApk(apkPath: String): Int { + val verifyResult = primaryHost?.verifyApk(apkPath) ?: Error.ERR_UNAVAILABLE + return verifyResult.toNativeValue() + } } diff --git a/platform/android/java/lib/src/org/godotengine/godot/GodotActivity.kt b/platform/android/java/lib/src/org/godotengine/godot/GodotActivity.kt index 4c5e857b7a..913e3d04c5 100644 --- a/platform/android/java/lib/src/org/godotengine/godot/GodotActivity.kt +++ b/platform/android/java/lib/src/org/godotengine/godot/GodotActivity.kt @@ -85,12 +85,8 @@ abstract class GodotActivity : FragmentActivity(), GodotHost { protected open fun getGodotAppLayout() = R.layout.godot_app_layout override fun onDestroy() { - Log.v(TAG, "Destroying Godot app...") + Log.v(TAG, "Destroying GodotActivity $this...") super.onDestroy() - - godotFragment?.let { - terminateGodotInstance(it.godot) - } } override fun onGodotForceQuit(instance: Godot) { diff --git a/platform/android/java/lib/src/org/godotengine/godot/GodotFragment.java b/platform/android/java/lib/src/org/godotengine/godot/GodotFragment.java index a323045e1b..e0f5744368 100644 --- a/platform/android/java/lib/src/org/godotengine/godot/GodotFragment.java +++ b/platform/android/java/lib/src/org/godotengine/godot/GodotFragment.java @@ -30,6 +30,7 @@ package org.godotengine.godot; +import org.godotengine.godot.error.Error; import org.godotengine.godot.plugin.GodotPlugin; import org.godotengine.godot.utils.BenchmarkUtils; @@ -42,6 +43,7 @@ import android.content.res.Configuration; import android.os.Build; import android.os.Bundle; import android.os.Messenger; +import android.text.TextUtils; import android.util.Log; import android.view.LayoutInflater; import android.view.View; @@ -186,7 +188,12 @@ public class GodotFragment extends Fragment implements IDownloaderClient, GodotH final Activity activity = getActivity(); mCurrentIntent = activity.getIntent(); - godot = new Godot(requireContext()); + if (parentHost != null) { + godot = parentHost.getGodot(); + } + if (godot == null) { + godot = new Godot(requireContext()); + } performEngineInitialization(); BenchmarkUtils.endBenchmarkMeasure("Startup", "GodotFragment::onCreate"); } @@ -203,6 +210,12 @@ public class GodotFragment extends Fragment implements IDownloaderClient, GodotH if (godotContainerLayout == null) { throw new IllegalStateException("Unable to initialize engine render view"); } + } catch (IllegalStateException e) { + Log.e(TAG, "Engine initialization failed", e); + final String errorMessage = TextUtils.isEmpty(e.getMessage()) + ? getString(R.string.error_engine_setup_message) + : e.getMessage(); + godot.alert(errorMessage, getString(R.string.text_error_title), godot::destroyAndKillProcess); } catch (IllegalArgumentException ignored) { final Activity activity = getActivity(); Intent notifierIntent = new Intent(activity, activity.getClass()); @@ -318,7 +331,7 @@ public class GodotFragment extends Fragment implements IDownloaderClient, GodotH } public void onBackPressed() { - godot.onBackPressed(this); + godot.onBackPressed(); } /** @@ -472,4 +485,20 @@ public class GodotFragment extends Fragment implements IDownloaderClient, GodotH } return Collections.emptySet(); } + + @Override + public Error signApk(@NonNull String inputPath, @NonNull String outputPath, @NonNull String keystorePath, @NonNull String keystoreUser, @NonNull String keystorePassword) { + if (parentHost != null) { + return parentHost.signApk(inputPath, outputPath, keystorePath, keystoreUser, keystorePassword); + } + return Error.ERR_UNAVAILABLE; + } + + @Override + public Error verifyApk(@NonNull String apkPath) { + if (parentHost != null) { + return parentHost.verifyApk(apkPath); + } + return Error.ERR_UNAVAILABLE; + } } diff --git a/platform/android/java/lib/src/org/godotengine/godot/GodotGLRenderView.java b/platform/android/java/lib/src/org/godotengine/godot/GodotGLRenderView.java index 81043ce782..15a811ce83 100644 --- a/platform/android/java/lib/src/org/godotengine/godot/GodotGLRenderView.java +++ b/platform/android/java/lib/src/org/godotengine/godot/GodotGLRenderView.java @@ -42,7 +42,6 @@ import org.godotengine.godot.xr.regular.RegularContextFactory; import org.godotengine.godot.xr.regular.RegularFallbackConfigChooser; import android.annotation.SuppressLint; -import android.content.Context; import android.content.res.AssetManager; import android.graphics.Bitmap; import android.graphics.BitmapFactory; @@ -77,19 +76,19 @@ import java.io.InputStream; * that matches it exactly (with regards to red/green/blue/alpha channels * bit depths). Failure to do so would result in an EGL_BAD_MATCH error. */ -public class GodotGLRenderView extends GLSurfaceView implements GodotRenderView { +class GodotGLRenderView extends GLSurfaceView implements GodotRenderView { private final GodotHost host; private final Godot godot; private final GodotInputHandler inputHandler; private final GodotRenderer godotRenderer; private final SparseArray<PointerIcon> customPointerIcons = new SparseArray<>(); - public GodotGLRenderView(GodotHost host, Godot godot, XRMode xrMode, boolean useDebugOpengl) { + public GodotGLRenderView(GodotHost host, Godot godot, GodotInputHandler inputHandler, XRMode xrMode, boolean useDebugOpengl) { super(host.getActivity()); this.host = host; this.godot = godot; - this.inputHandler = new GodotInputHandler(this); + this.inputHandler = inputHandler; this.godotRenderer = new GodotRenderer(); if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { setPointerIcon(PointerIcon.getSystemIcon(getContext(), PointerIcon.TYPE_DEFAULT)); @@ -103,11 +102,6 @@ public class GodotGLRenderView extends GLSurfaceView implements GodotRenderView } @Override - public void initInputDevices() { - this.inputHandler.initInputDevices(); - } - - @Override public void queueOnRenderThread(Runnable event) { queueEvent(event); } @@ -141,8 +135,8 @@ public class GodotGLRenderView extends GLSurfaceView implements GodotRenderView } @Override - public void onBackPressed() { - godot.onBackPressed(host); + public void onActivityDestroyed() { + requestRenderThreadExitAndWait(); } @Override diff --git a/platform/android/java/lib/src/org/godotengine/godot/GodotHost.java b/platform/android/java/lib/src/org/godotengine/godot/GodotHost.java index 1862b9fa9b..f1c84e90a7 100644 --- a/platform/android/java/lib/src/org/godotengine/godot/GodotHost.java +++ b/platform/android/java/lib/src/org/godotengine/godot/GodotHost.java @@ -30,10 +30,13 @@ package org.godotengine.godot; +import org.godotengine.godot.error.Error; import org.godotengine.godot.plugin.GodotPlugin; import android.app.Activity; +import androidx.annotation.NonNull; + import java.util.Collections; import java.util.List; import java.util.Set; @@ -108,4 +111,29 @@ public interface GodotHost { default Set<GodotPlugin> getHostPlugins(Godot engine) { return Collections.emptySet(); } + + /** + * Signs the given Android apk + * + * @param inputPath Path to the apk that should be signed + * @param outputPath Path for the signed output apk; can be the same as inputPath + * @param keystorePath Path to the keystore to use for signing the apk + * @param keystoreUser Keystore user credential + * @param keystorePassword Keystore password credential + * + * @return {@link Error#OK} if signing is successful + */ + default Error signApk(@NonNull String inputPath, @NonNull String outputPath, @NonNull String keystorePath, @NonNull String keystoreUser, @NonNull String keystorePassword) { + return Error.ERR_UNAVAILABLE; + } + + /** + * Verifies the given Android apk is signed + * + * @param apkPath Path to the apk that should be verified + * @return {@link Error#OK} if verification was successful + */ + default Error verifyApk(@NonNull String apkPath) { + return Error.ERR_UNAVAILABLE; + } } diff --git a/platform/android/java/lib/src/org/godotengine/godot/GodotIO.java b/platform/android/java/lib/src/org/godotengine/godot/GodotIO.java index 4b51bd778d..219631284a 100644 --- a/platform/android/java/lib/src/org/godotengine/godot/GodotIO.java +++ b/platform/android/java/lib/src/org/godotengine/godot/GodotIO.java @@ -121,7 +121,7 @@ public class GodotIO { activity.startActivity(intent); return 0; - } catch (ActivityNotFoundException e) { + } catch (Exception e) { Log.e(TAG, "Unable to open uri " + uriString, e); return 1; } diff --git a/platform/android/java/lib/src/org/godotengine/godot/GodotLib.java b/platform/android/java/lib/src/org/godotengine/godot/GodotLib.java index d0c3d4a687..295a4a6340 100644 --- a/platform/android/java/lib/src/org/godotengine/godot/GodotLib.java +++ b/platform/android/java/lib/src/org/godotengine/godot/GodotLib.java @@ -240,4 +240,15 @@ public class GodotLib { * @see GodotRenderer#onActivityPaused() */ public static native void onRendererPaused(); + + /** + * @return true if input must be dispatched from the render thread. If false, input is + * dispatched from the UI thread. + */ + public static native boolean shouldDispatchInputToRenderThread(); + + /** + * @return the project resource directory + */ + public static native String getProjectResourceDir(); } diff --git a/platform/android/java/lib/src/org/godotengine/godot/GodotRenderView.java b/platform/android/java/lib/src/org/godotengine/godot/GodotRenderView.java index 5b2f9f57c7..30821eaa8e 100644 --- a/platform/android/java/lib/src/org/godotengine/godot/GodotRenderView.java +++ b/platform/android/java/lib/src/org/godotengine/godot/GodotRenderView.java @@ -37,13 +37,14 @@ import android.view.SurfaceView; public interface GodotRenderView { SurfaceView getView(); - void initInputDevices(); - /** * Starts the thread that will drive Godot's rendering. */ void startRenderer(); + /** + * Queues a runnable to be run on the rendering thread. + */ void queueOnRenderThread(Runnable event); void onActivityPaused(); @@ -54,7 +55,7 @@ public interface GodotRenderView { void onActivityStarted(); - void onBackPressed(); + void onActivityDestroyed(); GodotInputHandler getInputHandler(); diff --git a/platform/android/java/lib/src/org/godotengine/godot/GodotVulkanRenderView.java b/platform/android/java/lib/src/org/godotengine/godot/GodotVulkanRenderView.java index a1ee9bd6b4..d5b05913d8 100644 --- a/platform/android/java/lib/src/org/godotengine/godot/GodotVulkanRenderView.java +++ b/platform/android/java/lib/src/org/godotengine/godot/GodotVulkanRenderView.java @@ -50,19 +50,19 @@ import androidx.annotation.Keep; import java.io.InputStream; -public class GodotVulkanRenderView extends VkSurfaceView implements GodotRenderView { +class GodotVulkanRenderView extends VkSurfaceView implements GodotRenderView { private final GodotHost host; private final Godot godot; private final GodotInputHandler mInputHandler; private final VkRenderer mRenderer; private final SparseArray<PointerIcon> customPointerIcons = new SparseArray<>(); - public GodotVulkanRenderView(GodotHost host, Godot godot) { + public GodotVulkanRenderView(GodotHost host, Godot godot, GodotInputHandler inputHandler) { super(host.getActivity()); this.host = host; this.godot = godot; - mInputHandler = new GodotInputHandler(this); + mInputHandler = inputHandler; mRenderer = new VkRenderer(); if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { setPointerIcon(PointerIcon.getSystemIcon(getContext(), PointerIcon.TYPE_DEFAULT)); @@ -81,11 +81,6 @@ public class GodotVulkanRenderView extends VkSurfaceView implements GodotRenderV } @Override - public void initInputDevices() { - mInputHandler.initInputDevices(); - } - - @Override public void queueOnRenderThread(Runnable event) { queueOnVkThread(event); } @@ -119,8 +114,8 @@ public class GodotVulkanRenderView extends VkSurfaceView implements GodotRenderV } @Override - public void onBackPressed() { - godot.onBackPressed(host); + public void onActivityDestroyed() { + requestRenderThreadExitAndWait(); } @Override diff --git a/platform/android/java/lib/src/org/godotengine/godot/error/Error.kt b/platform/android/java/lib/src/org/godotengine/godot/error/Error.kt new file mode 100644 index 0000000000..00ef5ee341 --- /dev/null +++ b/platform/android/java/lib/src/org/godotengine/godot/error/Error.kt @@ -0,0 +1,100 @@ +/**************************************************************************/ +/* Error.kt */ +/**************************************************************************/ +/* This file is part of: */ +/* GODOT ENGINE */ +/* https://godotengine.org */ +/**************************************************************************/ +/* Copyright (c) 2014-present Godot Engine contributors (see AUTHORS.md). */ +/* Copyright (c) 2007-2014 Juan Linietsky, Ariel Manzur. */ +/* */ +/* Permission is hereby granted, free of charge, to any person obtaining */ +/* a copy of this software and associated documentation files (the */ +/* "Software"), to deal in the Software without restriction, including */ +/* without limitation the rights to use, copy, modify, merge, publish, */ +/* distribute, sublicense, and/or sell copies of the Software, and to */ +/* permit persons to whom the Software is furnished to do so, subject to */ +/* the following conditions: */ +/* */ +/* The above copyright notice and this permission notice shall be */ +/* included in all copies or substantial portions of the Software. */ +/* */ +/* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, */ +/* EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF */ +/* MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. */ +/* IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY */ +/* CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, */ +/* TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE */ +/* SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ +/**************************************************************************/ + +package org.godotengine.godot.error + +/** + * Godot error list. + * + * This enum MUST match its native counterpart in 'core/error/error_list.h' + */ +enum class Error(private val description: String) { + OK("OK"), // (0) + FAILED("Failed"), ///< Generic fail error + ERR_UNAVAILABLE("Unavailable"), ///< What is requested is unsupported/unavailable + ERR_UNCONFIGURED("Unconfigured"), ///< The object being used hasn't been properly set up yet + ERR_UNAUTHORIZED("Unauthorized"), ///< Missing credentials for requested resource + ERR_PARAMETER_RANGE_ERROR("Parameter out of range"), ///< Parameter given out of range (5) + ERR_OUT_OF_MEMORY("Out of memory"), ///< Out of memory + ERR_FILE_NOT_FOUND("File not found"), + ERR_FILE_BAD_DRIVE("File: Bad drive"), + ERR_FILE_BAD_PATH("File: Bad path"), + ERR_FILE_NO_PERMISSION("File: Permission denied"), // (10) + ERR_FILE_ALREADY_IN_USE("File already in use"), + ERR_FILE_CANT_OPEN("Can't open file"), + ERR_FILE_CANT_WRITE("Can't write file"), + ERR_FILE_CANT_READ("Can't read file"), + ERR_FILE_UNRECOGNIZED("File unrecognized"), // (15) + ERR_FILE_CORRUPT("File corrupt"), + ERR_FILE_MISSING_DEPENDENCIES("Missing dependencies for file"), + ERR_FILE_EOF("End of file"), + ERR_CANT_OPEN("Can't open"), ///< Can't open a resource/socket/file + ERR_CANT_CREATE("Can't create"), // (20) + ERR_QUERY_FAILED("Query failed"), + ERR_ALREADY_IN_USE("Already in use"), + ERR_LOCKED("Locked"), ///< resource is locked + ERR_TIMEOUT("Timeout"), + ERR_CANT_CONNECT("Can't connect"), // (25) + ERR_CANT_RESOLVE("Can't resolve"), + ERR_CONNECTION_ERROR("Connection error"), + ERR_CANT_ACQUIRE_RESOURCE("Can't acquire resource"), + ERR_CANT_FORK("Can't fork"), + ERR_INVALID_DATA("Invalid data"), ///< Data passed is invalid (30) + ERR_INVALID_PARAMETER("Invalid parameter"), ///< Parameter passed is invalid + ERR_ALREADY_EXISTS("Already exists"), ///< When adding, item already exists + ERR_DOES_NOT_EXIST("Does not exist"), ///< When retrieving/erasing, if item does not exist + ERR_DATABASE_CANT_READ("Can't read database"), ///< database is full + ERR_DATABASE_CANT_WRITE("Can't write database"), ///< database is full (35) + ERR_COMPILATION_FAILED("Compilation failed"), + ERR_METHOD_NOT_FOUND("Method not found"), + ERR_LINK_FAILED("Link failed"), + ERR_SCRIPT_FAILED("Script failed"), + ERR_CYCLIC_LINK("Cyclic link detected"), // (40) + ERR_INVALID_DECLARATION("Invalid declaration"), + ERR_DUPLICATE_SYMBOL("Duplicate symbol"), + ERR_PARSE_ERROR("Parse error"), + ERR_BUSY("Busy"), + ERR_SKIP("Skip"), // (45) + ERR_HELP("Help"), ///< user requested help!! + ERR_BUG("Bug"), ///< a bug in the software certainly happened, due to a double check failing or unexpected behavior. + ERR_PRINTER_ON_FIRE("Printer on fire"); /// the parallel port printer is engulfed in flames + + companion object { + internal fun fromNativeValue(nativeValue: Int): Error? { + return Error.entries.getOrNull(nativeValue) + } + } + + internal fun toNativeValue(): Int = this.ordinal + + override fun toString(): String { + return description + } +} diff --git a/platform/android/java/lib/src/org/godotengine/godot/gl/GLSurfaceView.java b/platform/android/java/lib/src/org/godotengine/godot/gl/GLSurfaceView.java index c316812404..6a4e9da699 100644 --- a/platform/android/java/lib/src/org/godotengine/godot/gl/GLSurfaceView.java +++ b/platform/android/java/lib/src/org/godotengine/godot/gl/GLSurfaceView.java @@ -595,6 +595,15 @@ public class GLSurfaceView extends SurfaceView implements SurfaceHolder.Callback protected final void resumeGLThread() { mGLThread.onResume(); } + + /** + * Requests the render thread to exit and block until it does. + */ + protected final void requestRenderThreadExitAndWait() { + if (mGLThread != null) { + mGLThread.requestExitAndWait(); + } + } // -- GODOT end -- /** @@ -783,6 +792,11 @@ public class GLSurfaceView extends SurfaceView implements SurfaceHolder.Callback * @return true if the buffers should be swapped, false otherwise. */ boolean onDrawFrame(GL10 gl); + + /** + * Invoked when the render thread is in the process of shutting down. + */ + void onRenderThreadExiting(); // -- GODOT end -- } @@ -1621,6 +1635,12 @@ public class GLSurfaceView extends SurfaceView implements SurfaceHolder.Callback * clean-up everything... */ synchronized (sGLThreadManager) { + Log.d("GLThread", "Exiting render thread"); + GLSurfaceView view = mGLSurfaceViewWeakRef.get(); + if (view != null) { + view.mRenderer.onRenderThreadExiting(); + } + stopEglSurfaceLocked(); stopEglContextLocked(); } @@ -1704,15 +1724,6 @@ public class GLSurfaceView extends SurfaceView implements SurfaceHolder.Callback mHasSurface = true; mFinishedCreatingEglSurface = false; sGLThreadManager.notifyAll(); - while (mWaitingForSurface - && !mFinishedCreatingEglSurface - && !mExited) { - try { - sGLThreadManager.wait(); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - } - } } } @@ -1723,13 +1734,6 @@ public class GLSurfaceView extends SurfaceView implements SurfaceHolder.Callback } mHasSurface = false; sGLThreadManager.notifyAll(); - while((!mWaitingForSurface) && (!mExited)) { - try { - sGLThreadManager.wait(); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - } - } } } @@ -1740,16 +1744,6 @@ public class GLSurfaceView extends SurfaceView implements SurfaceHolder.Callback } mRequestPaused = true; sGLThreadManager.notifyAll(); - while ((! mExited) && (! mPaused)) { - if (LOG_PAUSE_RESUME) { - Log.i("Main thread", "onPause waiting for mPaused."); - } - try { - sGLThreadManager.wait(); - } catch (InterruptedException ex) { - Thread.currentThread().interrupt(); - } - } } } @@ -1762,16 +1756,6 @@ public class GLSurfaceView extends SurfaceView implements SurfaceHolder.Callback mRequestRender = true; mRenderComplete = false; sGLThreadManager.notifyAll(); - while ((! mExited) && mPaused && (!mRenderComplete)) { - if (LOG_PAUSE_RESUME) { - Log.i("Main thread", "onResume waiting for !mPaused."); - } - try { - sGLThreadManager.wait(); - } catch (InterruptedException ex) { - Thread.currentThread().interrupt(); - } - } } } @@ -1793,19 +1777,6 @@ public class GLSurfaceView extends SurfaceView implements SurfaceHolder.Callback } sGLThreadManager.notifyAll(); - - // Wait for thread to react to resize and render a frame - while (! mExited && !mPaused && !mRenderComplete - && ableToDraw()) { - if (LOG_SURFACE) { - Log.i("Main thread", "onWindowResize waiting for render complete from tid=" + getId()); - } - try { - sGLThreadManager.wait(); - } catch (InterruptedException ex) { - Thread.currentThread().interrupt(); - } - } } } diff --git a/platform/android/java/lib/src/org/godotengine/godot/gl/GodotRenderer.java b/platform/android/java/lib/src/org/godotengine/godot/gl/GodotRenderer.java index 9d44d8826c..7e5e262b2d 100644 --- a/platform/android/java/lib/src/org/godotengine/godot/gl/GodotRenderer.java +++ b/platform/android/java/lib/src/org/godotengine/godot/gl/GodotRenderer.java @@ -34,6 +34,8 @@ import org.godotengine.godot.GodotLib; import org.godotengine.godot.plugin.GodotPlugin; import org.godotengine.godot.plugin.GodotPluginRegistry; +import android.util.Log; + import javax.microedition.khronos.egl.EGLConfig; import javax.microedition.khronos.opengles.GL10; @@ -41,6 +43,8 @@ import javax.microedition.khronos.opengles.GL10; * Godot's GL renderer implementation. */ public class GodotRenderer implements GLSurfaceView.Renderer { + private final String TAG = GodotRenderer.class.getSimpleName(); + private final GodotPluginRegistry pluginRegistry; private boolean activityJustResumed = false; @@ -62,6 +66,12 @@ public class GodotRenderer implements GLSurfaceView.Renderer { return swapBuffers; } + @Override + public void onRenderThreadExiting() { + Log.d(TAG, "Destroying Godot Engine"); + GodotLib.ondestroy(); + } + public void onSurfaceChanged(GL10 gl, int width, int height) { GodotLib.resize(null, width, height); for (GodotPlugin plugin : pluginRegistry.getAllPlugins()) { diff --git a/platform/android/java/lib/src/org/godotengine/godot/input/GodotGestureHandler.kt b/platform/android/java/lib/src/org/godotengine/godot/input/GodotGestureHandler.kt index 49b34a5229..2929a0a0b0 100644 --- a/platform/android/java/lib/src/org/godotengine/godot/input/GodotGestureHandler.kt +++ b/platform/android/java/lib/src/org/godotengine/godot/input/GodotGestureHandler.kt @@ -44,7 +44,7 @@ import org.godotengine.godot.GodotLib * @See https://developer.android.com/reference/android/view/GestureDetector.SimpleOnGestureListener * @See https://developer.android.com/reference/android/view/ScaleGestureDetector.OnScaleGestureListener */ -internal class GodotGestureHandler : SimpleOnGestureListener(), OnScaleGestureListener { +internal class GodotGestureHandler(private val inputHandler: GodotInputHandler) : SimpleOnGestureListener(), OnScaleGestureListener { companion object { private val TAG = GodotGestureHandler::class.java.simpleName @@ -65,18 +65,21 @@ internal class GodotGestureHandler : SimpleOnGestureListener(), OnScaleGestureLi private var lastDragY: Float = 0.0f override fun onDown(event: MotionEvent): Boolean { - GodotInputHandler.handleMotionEvent(event, MotionEvent.ACTION_DOWN, nextDownIsDoubleTap) + inputHandler.handleMotionEvent(event, MotionEvent.ACTION_DOWN, nextDownIsDoubleTap) nextDownIsDoubleTap = false return true } override fun onSingleTapUp(event: MotionEvent): Boolean { - GodotInputHandler.handleMotionEvent(event) + inputHandler.handleMotionEvent(event) return true } override fun onLongPress(event: MotionEvent) { - contextClickRouter(event) + val toolType = GodotInputHandler.getEventToolType(event) + if (toolType != MotionEvent.TOOL_TYPE_MOUSE) { + contextClickRouter(event) + } } private fun contextClickRouter(event: MotionEvent) { @@ -85,10 +88,10 @@ internal class GodotGestureHandler : SimpleOnGestureListener(), OnScaleGestureLi } // Cancel the previous down event - GodotInputHandler.handleMotionEvent(event, MotionEvent.ACTION_CANCEL) + inputHandler.handleMotionEvent(event, MotionEvent.ACTION_CANCEL) // Turn a context click into a single tap right mouse button click. - GodotInputHandler.handleMouseEvent( + inputHandler.handleMouseEvent( event, MotionEvent.ACTION_DOWN, MotionEvent.BUTTON_SECONDARY, @@ -104,7 +107,7 @@ internal class GodotGestureHandler : SimpleOnGestureListener(), OnScaleGestureLi if (!hasCapture) { // Dispatch a mouse relative ACTION_UP event to signal the end of the capture - GodotInputHandler.handleMouseEvent(MotionEvent.ACTION_UP, true) + inputHandler.handleMouseEvent(MotionEvent.ACTION_UP, true) } pointerCaptureInProgress = hasCapture } @@ -131,9 +134,9 @@ internal class GodotGestureHandler : SimpleOnGestureListener(), OnScaleGestureLi if (contextClickInProgress || GodotInputHandler.isMouseEvent(event)) { // This may be an ACTION_BUTTON_RELEASE event which we don't handle, // so we convert it to an ACTION_UP event. - GodotInputHandler.handleMouseEvent(event, MotionEvent.ACTION_UP) + inputHandler.handleMouseEvent(event, MotionEvent.ACTION_UP) } else { - GodotInputHandler.handleTouchEvent(event) + inputHandler.handleTouchEvent(event) } pointerCaptureInProgress = false dragInProgress = false @@ -148,7 +151,7 @@ internal class GodotGestureHandler : SimpleOnGestureListener(), OnScaleGestureLi private fun onActionMove(event: MotionEvent): Boolean { if (contextClickInProgress) { - GodotInputHandler.handleMouseEvent(event, event.actionMasked, MotionEvent.BUTTON_SECONDARY, false) + inputHandler.handleMouseEvent(event, event.actionMasked, MotionEvent.BUTTON_SECONDARY, false) return true } else if (!scaleInProgress) { // The 'onScroll' event is triggered with a long delay. @@ -158,7 +161,7 @@ internal class GodotGestureHandler : SimpleOnGestureListener(), OnScaleGestureLi if (lastDragX != event.getX(0) || lastDragY != event.getY(0)) { lastDragX = event.getX(0) lastDragY = event.getY(0) - GodotInputHandler.handleMotionEvent(event) + inputHandler.handleMotionEvent(event) return true } } @@ -168,9 +171,9 @@ internal class GodotGestureHandler : SimpleOnGestureListener(), OnScaleGestureLi override fun onDoubleTapEvent(event: MotionEvent): Boolean { if (event.actionMasked == MotionEvent.ACTION_UP) { nextDownIsDoubleTap = false - GodotInputHandler.handleMotionEvent(event) + inputHandler.handleMotionEvent(event) } else if (event.actionMasked == MotionEvent.ACTION_MOVE && !panningAndScalingEnabled) { - GodotInputHandler.handleMotionEvent(event) + inputHandler.handleMotionEvent(event) } return true @@ -191,7 +194,7 @@ internal class GodotGestureHandler : SimpleOnGestureListener(), OnScaleGestureLi if (dragInProgress || lastDragX != 0.0f || lastDragY != 0.0f) { if (originEvent != null) { // Cancel the drag - GodotInputHandler.handleMotionEvent(originEvent, MotionEvent.ACTION_CANCEL) + inputHandler.handleMotionEvent(originEvent, MotionEvent.ACTION_CANCEL) } dragInProgress = false lastDragX = 0.0f @@ -202,12 +205,12 @@ internal class GodotGestureHandler : SimpleOnGestureListener(), OnScaleGestureLi val x = terminusEvent.x val y = terminusEvent.y if (terminusEvent.pointerCount >= 2 && panningAndScalingEnabled && !pointerCaptureInProgress && !dragInProgress) { - GodotLib.pan(x, y, distanceX / 5f, distanceY / 5f) + inputHandler.handlePanEvent(x, y, distanceX / 5f, distanceY / 5f) } else if (!scaleInProgress) { dragInProgress = true lastDragX = terminusEvent.getX(0) lastDragY = terminusEvent.getY(0) - GodotInputHandler.handleMotionEvent(terminusEvent) + inputHandler.handleMotionEvent(terminusEvent) } return true } @@ -218,11 +221,7 @@ internal class GodotGestureHandler : SimpleOnGestureListener(), OnScaleGestureLi } if (detector.scaleFactor >= 0.8f && detector.scaleFactor != 1f && detector.scaleFactor <= 1.2f) { - GodotLib.magnify( - detector.focusX, - detector.focusY, - detector.scaleFactor - ) + inputHandler.handleMagnifyEvent(detector.focusX, detector.focusY, detector.scaleFactor) } return true } diff --git a/platform/android/java/lib/src/org/godotengine/godot/input/GodotInputHandler.java b/platform/android/java/lib/src/org/godotengine/godot/input/GodotInputHandler.java index 83e76e49c9..fb41cd00c0 100644 --- a/platform/android/java/lib/src/org/godotengine/godot/input/GodotInputHandler.java +++ b/platform/android/java/lib/src/org/godotengine/godot/input/GodotInputHandler.java @@ -32,10 +32,14 @@ package org.godotengine.godot.input; import static org.godotengine.godot.utils.GLUtils.DEBUG; +import org.godotengine.godot.Godot; import org.godotengine.godot.GodotLib; import org.godotengine.godot.GodotRenderView; import android.content.Context; +import android.hardware.Sensor; +import android.hardware.SensorEvent; +import android.hardware.SensorEventListener; import android.hardware.input.InputManager; import android.os.Build; import android.util.Log; @@ -46,6 +50,10 @@ import android.view.InputDevice; import android.view.KeyEvent; import android.view.MotionEvent; import android.view.ScaleGestureDetector; +import android.view.Surface; +import android.view.WindowManager; + +import androidx.annotation.NonNull; import java.util.Collections; import java.util.HashSet; @@ -54,7 +62,7 @@ import java.util.Set; /** * Handles input related events for the {@link GodotRenderView} view. */ -public class GodotInputHandler implements InputManager.InputDeviceListener { +public class GodotInputHandler implements InputManager.InputDeviceListener, SensorEventListener { private static final String TAG = GodotInputHandler.class.getSimpleName(); private static final int ROTARY_INPUT_VERTICAL_AXIS = 1; @@ -64,8 +72,9 @@ public class GodotInputHandler implements InputManager.InputDeviceListener { private final SparseArray<Joystick> mJoysticksDevices = new SparseArray<>(4); private final HashSet<Integer> mHardwareKeyboardIds = new HashSet<>(); - private final GodotRenderView mRenderView; + private final Godot godot; private final InputManager mInputManager; + private final WindowManager windowManager; private final GestureDetector gestureDetector; private final ScaleGestureDetector scaleGestureDetector; private final GodotGestureHandler godotGestureHandler; @@ -75,15 +84,16 @@ public class GodotInputHandler implements InputManager.InputDeviceListener { */ private int lastSeenToolType = MotionEvent.TOOL_TYPE_UNKNOWN; - private static int rotaryInputAxis = ROTARY_INPUT_VERTICAL_AXIS; + private int rotaryInputAxis = ROTARY_INPUT_VERTICAL_AXIS; - public GodotInputHandler(GodotRenderView godotView) { - final Context context = godotView.getView().getContext(); - mRenderView = godotView; + public GodotInputHandler(Context context, Godot godot) { + this.godot = godot; mInputManager = (InputManager)context.getSystemService(Context.INPUT_SERVICE); mInputManager.registerInputDeviceListener(this, null); - this.godotGestureHandler = new GodotGestureHandler(); + windowManager = (WindowManager)context.getSystemService(Context.WINDOW_SERVICE); + + this.godotGestureHandler = new GodotGestureHandler(this); this.gestureDetector = new GestureDetector(context, godotGestureHandler); this.gestureDetector.setIsLongpressEnabled(false); this.scaleGestureDetector = new ScaleGestureDetector(context, godotGestureHandler); @@ -109,6 +119,14 @@ public class GodotInputHandler implements InputManager.InputDeviceListener { } /** + * @return true if input must be dispatched from the render thread. If false, input is + * dispatched from the UI thread. + */ + private boolean shouldDispatchInputToRenderThread() { + return GodotLib.shouldDispatchInputToRenderThread(); + } + + /** * On Wear OS devices, sets which axis of the mouse wheel rotary input is mapped to. This is 1 (vertical axis) by default. */ public void setRotaryInputAxis(int axis) { @@ -151,14 +169,14 @@ public class GodotInputHandler implements InputManager.InputDeviceListener { if (mJoystickIds.indexOfKey(deviceId) >= 0) { final int button = getGodotButton(keyCode); final int godotJoyId = mJoystickIds.get(deviceId); - GodotLib.joybutton(godotJoyId, button, false); + handleJoystickButtonEvent(godotJoyId, button, false); } } else { // getKeyCode(): The physical key that was pressed. final int physical_keycode = event.getKeyCode(); final int unicode = event.getUnicodeChar(); final int key_label = event.getDisplayLabel(); - GodotLib.key(physical_keycode, unicode, key_label, false, event.getRepeatCount() > 0); + handleKeyEvent(physical_keycode, unicode, key_label, false, event.getRepeatCount() > 0); }; return true; @@ -166,7 +184,7 @@ public class GodotInputHandler implements InputManager.InputDeviceListener { public boolean onKeyDown(final int keyCode, KeyEvent event) { if (keyCode == KeyEvent.KEYCODE_BACK) { - mRenderView.onBackPressed(); + godot.onBackPressed(); // press 'back' button should not terminate program //normal handle 'back' event in game logic return true; @@ -187,13 +205,13 @@ public class GodotInputHandler implements InputManager.InputDeviceListener { if (mJoystickIds.indexOfKey(deviceId) >= 0) { final int button = getGodotButton(keyCode); final int godotJoyId = mJoystickIds.get(deviceId); - GodotLib.joybutton(godotJoyId, button, true); + handleJoystickButtonEvent(godotJoyId, button, true); } } else { final int physical_keycode = event.getKeyCode(); final int unicode = event.getUnicodeChar(); final int key_label = event.getDisplayLabel(); - GodotLib.key(physical_keycode, unicode, key_label, true, event.getRepeatCount() > 0); + handleKeyEvent(physical_keycode, unicode, key_label, true, event.getRepeatCount() > 0); } return true; @@ -248,7 +266,7 @@ public class GodotInputHandler implements InputManager.InputDeviceListener { if (joystick.axesValues.indexOfKey(axis) < 0 || (float)joystick.axesValues.get(axis) != value) { // save value to prevent repeats joystick.axesValues.put(axis, value); - GodotLib.joyaxis(godotJoyId, i, value); + handleJoystickAxisEvent(godotJoyId, i, value); } } @@ -258,7 +276,7 @@ public class GodotInputHandler implements InputManager.InputDeviceListener { if (joystick.hatX != hatX || joystick.hatY != hatY) { joystick.hatX = hatX; joystick.hatY = hatY; - GodotLib.joyhat(godotJoyId, hatX, hatY); + handleJoystickHatEvent(godotJoyId, hatX, hatY); } } return true; @@ -284,10 +302,12 @@ public class GodotInputHandler implements InputManager.InputDeviceListener { int[] deviceIds = mInputManager.getInputDeviceIds(); for (int deviceId : deviceIds) { InputDevice device = mInputManager.getInputDevice(deviceId); - if (DEBUG) { - Log.v(TAG, String.format("init() deviceId:%d, Name:%s\n", deviceId, device.getName())); + if (device != null) { + if (DEBUG) { + Log.v(TAG, String.format("init() deviceId:%d, Name:%s\n", deviceId, device.getName())); + } + onInputDeviceAdded(deviceId); } - onInputDeviceAdded(deviceId); } } @@ -364,7 +384,7 @@ public class GodotInputHandler implements InputManager.InputDeviceListener { } mJoysticksDevices.put(deviceId, joystick); - GodotLib.joyconnectionchanged(id, true, joystick.name); + handleJoystickConnectionChangedEvent(id, true, joystick.name); } @Override @@ -378,7 +398,7 @@ public class GodotInputHandler implements InputManager.InputDeviceListener { final int godotJoyId = mJoystickIds.get(deviceId); mJoystickIds.delete(deviceId); mJoysticksDevices.delete(deviceId); - GodotLib.joyconnectionchanged(godotJoyId, false, ""); + handleJoystickConnectionChangedEvent(godotJoyId, false, ""); } @Override @@ -452,7 +472,7 @@ public class GodotInputHandler implements InputManager.InputDeviceListener { return button; } - private static int getEventToolType(MotionEvent event) { + static int getEventToolType(MotionEvent event) { return event.getPointerCount() > 0 ? event.getToolType(0) : MotionEvent.TOOL_TYPE_UNKNOWN; } @@ -482,22 +502,22 @@ public class GodotInputHandler implements InputManager.InputDeviceListener { } } - static boolean handleMotionEvent(final MotionEvent event) { + boolean handleMotionEvent(final MotionEvent event) { return handleMotionEvent(event, event.getActionMasked()); } - static boolean handleMotionEvent(final MotionEvent event, int eventActionOverride) { + boolean handleMotionEvent(final MotionEvent event, int eventActionOverride) { return handleMotionEvent(event, eventActionOverride, false); } - static boolean handleMotionEvent(final MotionEvent event, int eventActionOverride, boolean doubleTap) { + boolean handleMotionEvent(final MotionEvent event, int eventActionOverride, boolean doubleTap) { if (isMouseEvent(event)) { return handleMouseEvent(event, eventActionOverride, doubleTap); } return handleTouchEvent(event, eventActionOverride, doubleTap); } - private static float getEventTiltX(MotionEvent event) { + static float getEventTiltX(MotionEvent event) { // Orientation is returned as a radian value between 0 to pi clockwise or 0 to -pi counterclockwise. final float orientation = event.getOrientation(); @@ -510,7 +530,7 @@ public class GodotInputHandler implements InputManager.InputDeviceListener { return (float)-Math.sin(orientation) * tiltMult; } - private static float getEventTiltY(MotionEvent event) { + static float getEventTiltY(MotionEvent event) { // Orientation is returned as a radian value between 0 to pi clockwise or 0 to -pi counterclockwise. final float orientation = event.getOrientation(); @@ -523,19 +543,19 @@ public class GodotInputHandler implements InputManager.InputDeviceListener { return (float)Math.cos(orientation) * tiltMult; } - static boolean handleMouseEvent(final MotionEvent event) { + boolean handleMouseEvent(final MotionEvent event) { return handleMouseEvent(event, event.getActionMasked()); } - static boolean handleMouseEvent(final MotionEvent event, int eventActionOverride) { + boolean handleMouseEvent(final MotionEvent event, int eventActionOverride) { return handleMouseEvent(event, eventActionOverride, false); } - static boolean handleMouseEvent(final MotionEvent event, int eventActionOverride, boolean doubleTap) { + boolean handleMouseEvent(final MotionEvent event, int eventActionOverride, boolean doubleTap) { return handleMouseEvent(event, eventActionOverride, event.getButtonState(), doubleTap); } - static boolean handleMouseEvent(final MotionEvent event, int eventActionOverride, int buttonMaskOverride, boolean doubleTap) { + boolean handleMouseEvent(final MotionEvent event, int eventActionOverride, int buttonMaskOverride, boolean doubleTap) { final float x = event.getX(); final float y = event.getY(); @@ -564,11 +584,16 @@ public class GodotInputHandler implements InputManager.InputDeviceListener { return handleMouseEvent(eventActionOverride, buttonMaskOverride, x, y, horizontalFactor, verticalFactor, doubleTap, sourceMouseRelative, pressure, getEventTiltX(event), getEventTiltY(event)); } - static boolean handleMouseEvent(int eventAction, boolean sourceMouseRelative) { + boolean handleMouseEvent(int eventAction, boolean sourceMouseRelative) { return handleMouseEvent(eventAction, 0, 0f, 0f, 0f, 0f, false, sourceMouseRelative, 1f, 0f, 0f); } - static boolean handleMouseEvent(int eventAction, int buttonsMask, float x, float y, float deltaX, float deltaY, boolean doubleClick, boolean sourceMouseRelative, float pressure, float tiltX, float tiltY) { + boolean handleMouseEvent(int eventAction, int buttonsMask, float x, float y, float deltaX, float deltaY, boolean doubleClick, boolean sourceMouseRelative, float pressure, float tiltX, float tiltY) { + InputEventRunnable runnable = InputEventRunnable.obtain(); + if (runnable == null) { + return false; + } + // Fix the buttonsMask switch (eventAction) { case MotionEvent.ACTION_CANCEL: @@ -596,38 +621,31 @@ public class GodotInputHandler implements InputManager.InputDeviceListener { case MotionEvent.ACTION_HOVER_MOVE: case MotionEvent.ACTION_MOVE: case MotionEvent.ACTION_SCROLL: { - GodotLib.dispatchMouseEvent(eventAction, buttonsMask, x, y, deltaX, deltaY, doubleClick, sourceMouseRelative, pressure, tiltX, tiltY); + runnable.setMouseEvent(eventAction, buttonsMask, x, y, deltaX, deltaY, doubleClick, sourceMouseRelative, pressure, tiltX, tiltY); + dispatchInputEventRunnable(runnable); return true; } } return false; } - static boolean handleTouchEvent(final MotionEvent event) { + boolean handleTouchEvent(final MotionEvent event) { return handleTouchEvent(event, event.getActionMasked()); } - static boolean handleTouchEvent(final MotionEvent event, int eventActionOverride) { + boolean handleTouchEvent(final MotionEvent event, int eventActionOverride) { return handleTouchEvent(event, eventActionOverride, false); } - static boolean handleTouchEvent(final MotionEvent event, int eventActionOverride, boolean doubleTap) { - final int pointerCount = event.getPointerCount(); - if (pointerCount == 0) { + boolean handleTouchEvent(final MotionEvent event, int eventActionOverride, boolean doubleTap) { + if (event.getPointerCount() == 0) { return true; } - final float[] positions = new float[pointerCount * 6]; // pointerId1, x1, y1, pressure1, tiltX1, tiltY1, pointerId2, etc... - - for (int i = 0; i < pointerCount; i++) { - positions[i * 6 + 0] = event.getPointerId(i); - positions[i * 6 + 1] = event.getX(i); - positions[i * 6 + 2] = event.getY(i); - positions[i * 6 + 3] = event.getPressure(i); - positions[i * 6 + 4] = getEventTiltX(event); - positions[i * 6 + 5] = getEventTiltY(event); + InputEventRunnable runnable = InputEventRunnable.obtain(); + if (runnable == null) { + return false; } - final int actionPointerId = event.getPointerId(event.getActionIndex()); switch (eventActionOverride) { case MotionEvent.ACTION_DOWN: @@ -636,10 +654,137 @@ public class GodotInputHandler implements InputManager.InputDeviceListener { case MotionEvent.ACTION_MOVE: case MotionEvent.ACTION_POINTER_UP: case MotionEvent.ACTION_POINTER_DOWN: { - GodotLib.dispatchTouchEvent(eventActionOverride, actionPointerId, pointerCount, positions, doubleTap); + runnable.setTouchEvent(event, eventActionOverride, doubleTap); + dispatchInputEventRunnable(runnable); return true; } } return false; } + + void handleMagnifyEvent(float x, float y, float factor) { + InputEventRunnable runnable = InputEventRunnable.obtain(); + if (runnable == null) { + return; + } + + runnable.setMagnifyEvent(x, y, factor); + dispatchInputEventRunnable(runnable); + } + + void handlePanEvent(float x, float y, float deltaX, float deltaY) { + InputEventRunnable runnable = InputEventRunnable.obtain(); + if (runnable == null) { + return; + } + + runnable.setPanEvent(x, y, deltaX, deltaY); + dispatchInputEventRunnable(runnable); + } + + private void handleJoystickButtonEvent(int device, int button, boolean pressed) { + InputEventRunnable runnable = InputEventRunnable.obtain(); + if (runnable == null) { + return; + } + + runnable.setJoystickButtonEvent(device, button, pressed); + dispatchInputEventRunnable(runnable); + } + + private void handleJoystickAxisEvent(int device, int axis, float value) { + InputEventRunnable runnable = InputEventRunnable.obtain(); + if (runnable == null) { + return; + } + + runnable.setJoystickAxisEvent(device, axis, value); + dispatchInputEventRunnable(runnable); + } + + private void handleJoystickHatEvent(int device, int hatX, int hatY) { + InputEventRunnable runnable = InputEventRunnable.obtain(); + if (runnable == null) { + return; + } + + runnable.setJoystickHatEvent(device, hatX, hatY); + dispatchInputEventRunnable(runnable); + } + + private void handleJoystickConnectionChangedEvent(int device, boolean connected, String name) { + InputEventRunnable runnable = InputEventRunnable.obtain(); + if (runnable == null) { + return; + } + + runnable.setJoystickConnectionChangedEvent(device, connected, name); + dispatchInputEventRunnable(runnable); + } + + void handleKeyEvent(int physicalKeycode, int unicode, int keyLabel, boolean pressed, boolean echo) { + InputEventRunnable runnable = InputEventRunnable.obtain(); + if (runnable == null) { + return; + } + + runnable.setKeyEvent(physicalKeycode, unicode, keyLabel, pressed, echo); + dispatchInputEventRunnable(runnable); + } + + private void dispatchInputEventRunnable(@NonNull InputEventRunnable runnable) { + if (shouldDispatchInputToRenderThread()) { + godot.runOnRenderThread(runnable); + } else { + runnable.run(); + } + } + + @Override + public void onSensorChanged(SensorEvent event) { + final float[] values = event.values; + if (values == null || values.length != 3) { + return; + } + + InputEventRunnable runnable = InputEventRunnable.obtain(); + if (runnable == null) { + return; + } + + float rotatedValue0 = 0f; + float rotatedValue1 = 0f; + float rotatedValue2 = 0f; + switch (windowManager.getDefaultDisplay().getRotation()) { + case Surface.ROTATION_0: + rotatedValue0 = values[0]; + rotatedValue1 = values[1]; + rotatedValue2 = values[2]; + break; + + case Surface.ROTATION_90: + rotatedValue0 = -values[1]; + rotatedValue1 = values[0]; + rotatedValue2 = values[2]; + break; + + case Surface.ROTATION_180: + rotatedValue0 = -values[0]; + rotatedValue1 = -values[1]; + rotatedValue2 = values[2]; + break; + + case Surface.ROTATION_270: + rotatedValue0 = values[1]; + rotatedValue1 = -values[0]; + rotatedValue2 = values[2]; + break; + } + + runnable.setSensorEvent(event.sensor.getType(), rotatedValue0, rotatedValue1, rotatedValue2); + godot.runOnRenderThread(runnable); + } + + @Override + public void onAccuracyChanged(Sensor sensor, int accuracy) {} } diff --git a/platform/android/java/lib/src/org/godotengine/godot/input/GodotTextInputWrapper.java b/platform/android/java/lib/src/org/godotengine/godot/input/GodotTextInputWrapper.java index 06b565c30f..e545669970 100644 --- a/platform/android/java/lib/src/org/godotengine/godot/input/GodotTextInputWrapper.java +++ b/platform/android/java/lib/src/org/godotengine/godot/input/GodotTextInputWrapper.java @@ -93,8 +93,8 @@ public class GodotTextInputWrapper implements TextWatcher, OnEditorActionListene @Override public void beforeTextChanged(final CharSequence pCharSequence, final int start, final int count, final int after) { for (int i = 0; i < count; ++i) { - GodotLib.key(KeyEvent.KEYCODE_DEL, 0, 0, true, false); - GodotLib.key(KeyEvent.KEYCODE_DEL, 0, 0, false, false); + mRenderView.getInputHandler().handleKeyEvent(KeyEvent.KEYCODE_DEL, 0, 0, true, false); + mRenderView.getInputHandler().handleKeyEvent(KeyEvent.KEYCODE_DEL, 0, 0, false, false); if (mHasSelection) { mHasSelection = false; @@ -115,8 +115,8 @@ public class GodotTextInputWrapper implements TextWatcher, OnEditorActionListene // Return keys are handled through action events continue; } - GodotLib.key(0, character, 0, true, false); - GodotLib.key(0, character, 0, false, false); + mRenderView.getInputHandler().handleKeyEvent(0, character, 0, true, false); + mRenderView.getInputHandler().handleKeyEvent(0, character, 0, false, false); } } @@ -127,18 +127,16 @@ public class GodotTextInputWrapper implements TextWatcher, OnEditorActionListene if (characters != null) { for (int i = 0; i < characters.length(); i++) { final int character = characters.codePointAt(i); - GodotLib.key(0, character, 0, true, false); - GodotLib.key(0, character, 0, false, false); + mRenderView.getInputHandler().handleKeyEvent(0, character, 0, true, false); + mRenderView.getInputHandler().handleKeyEvent(0, character, 0, false, false); } } } if (pActionID == EditorInfo.IME_ACTION_DONE) { // Enter key has been pressed - mRenderView.queueOnRenderThread(() -> { - GodotLib.key(KeyEvent.KEYCODE_ENTER, 0, 0, true, false); - GodotLib.key(KeyEvent.KEYCODE_ENTER, 0, 0, false, false); - }); + mRenderView.getInputHandler().handleKeyEvent(KeyEvent.KEYCODE_ENTER, 0, 0, true, false); + mRenderView.getInputHandler().handleKeyEvent(KeyEvent.KEYCODE_ENTER, 0, 0, false, false); mRenderView.getView().requestFocus(); return true; } diff --git a/platform/android/java/lib/src/org/godotengine/godot/input/InputEventRunnable.java b/platform/android/java/lib/src/org/godotengine/godot/input/InputEventRunnable.java new file mode 100644 index 0000000000..a282791b2e --- /dev/null +++ b/platform/android/java/lib/src/org/godotengine/godot/input/InputEventRunnable.java @@ -0,0 +1,353 @@ +/**************************************************************************/ +/* InputEventRunnable.java */ +/**************************************************************************/ +/* This file is part of: */ +/* GODOT ENGINE */ +/* https://godotengine.org */ +/**************************************************************************/ +/* Copyright (c) 2014-present Godot Engine contributors (see AUTHORS.md). */ +/* Copyright (c) 2007-2014 Juan Linietsky, Ariel Manzur. */ +/* */ +/* Permission is hereby granted, free of charge, to any person obtaining */ +/* a copy of this software and associated documentation files (the */ +/* "Software"), to deal in the Software without restriction, including */ +/* without limitation the rights to use, copy, modify, merge, publish, */ +/* distribute, sublicense, and/or sell copies of the Software, and to */ +/* permit persons to whom the Software is furnished to do so, subject to */ +/* the following conditions: */ +/* */ +/* The above copyright notice and this permission notice shall be */ +/* included in all copies or substantial portions of the Software. */ +/* */ +/* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, */ +/* EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF */ +/* MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. */ +/* IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY */ +/* CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, */ +/* TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE */ +/* SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ +/**************************************************************************/ + +package org.godotengine.godot.input; + +import org.godotengine.godot.GodotLib; + +import android.hardware.Sensor; +import android.util.Log; +import android.view.MotionEvent; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.core.util.Pools; + +import java.util.concurrent.ArrayBlockingQueue; +import java.util.concurrent.atomic.AtomicInteger; + +/** + * Used to dispatch input events. + * + * This is a specialized version of @{@link Runnable} which allows to allocate a finite pool of + * objects for input events dispatching, thus avoid the creation (and garbage collection) of + * spurious @{@link Runnable} objects. + */ +final class InputEventRunnable implements Runnable { + private static final String TAG = InputEventRunnable.class.getSimpleName(); + + private static final int MAX_TOUCH_POINTER_COUNT = 10; // assuming 10 fingers as max supported concurrent touch pointers + + private static final Pools.Pool<InputEventRunnable> POOL = new Pools.Pool<>() { + private static final int MAX_POOL_SIZE = 120 * 10; // up to 120Hz input events rate for up to 5 secs (ANR limit) * 2 + + private final ArrayBlockingQueue<InputEventRunnable> queue = new ArrayBlockingQueue<>(MAX_POOL_SIZE); + private final AtomicInteger createdCount = new AtomicInteger(); + + @Nullable + @Override + public InputEventRunnable acquire() { + InputEventRunnable instance = queue.poll(); + if (instance == null) { + int creationCount = createdCount.incrementAndGet(); + if (creationCount <= MAX_POOL_SIZE) { + instance = new InputEventRunnable(creationCount - 1); + } + } + + return instance; + } + + @Override + public boolean release(@NonNull InputEventRunnable instance) { + return queue.offer(instance); + } + }; + + @Nullable + static InputEventRunnable obtain() { + InputEventRunnable runnable = POOL.acquire(); + if (runnable == null) { + Log.w(TAG, "Input event pool is at capacity"); + } + return runnable; + } + + /** + * Used to track when this instance was created and added to the pool. Primarily used for + * debug purposes. + */ + private final int creationRank; + + private InputEventRunnable(int creationRank) { + this.creationRank = creationRank; + } + + /** + * Set of supported input events. + */ + private enum EventType { + MOUSE, + TOUCH, + MAGNIFY, + PAN, + JOYSTICK_BUTTON, + JOYSTICK_AXIS, + JOYSTICK_HAT, + JOYSTICK_CONNECTION_CHANGED, + KEY, + SENSOR + } + + private EventType currentEventType = null; + + // common event fields + private float eventX; + private float eventY; + private float eventDeltaX; + private float eventDeltaY; + private boolean eventPressed; + + // common touch / mouse fields + private int eventAction; + private boolean doubleTap; + + // Mouse event fields and setter + private int buttonsMask; + private boolean sourceMouseRelative; + private float pressure; + private float tiltX; + private float tiltY; + void setMouseEvent(int eventAction, int buttonsMask, float x, float y, float deltaX, float deltaY, boolean doubleClick, boolean sourceMouseRelative, float pressure, float tiltX, float tiltY) { + this.currentEventType = EventType.MOUSE; + this.eventAction = eventAction; + this.buttonsMask = buttonsMask; + this.eventX = x; + this.eventY = y; + this.eventDeltaX = deltaX; + this.eventDeltaY = deltaY; + this.doubleTap = doubleClick; + this.sourceMouseRelative = sourceMouseRelative; + this.pressure = pressure; + this.tiltX = tiltX; + this.tiltY = tiltY; + } + + // Touch event fields and setter + private int actionPointerId; + private int pointerCount; + private final float[] positions = new float[MAX_TOUCH_POINTER_COUNT * 6]; // pointerId1, x1, y1, pressure1, tiltX1, tiltY1, pointerId2, etc... + void setTouchEvent(MotionEvent event, int eventAction, boolean doubleTap) { + this.currentEventType = EventType.TOUCH; + this.eventAction = eventAction; + this.doubleTap = doubleTap; + this.actionPointerId = event.getPointerId(event.getActionIndex()); + this.pointerCount = Math.min(event.getPointerCount(), MAX_TOUCH_POINTER_COUNT); + for (int i = 0; i < pointerCount; i++) { + positions[i * 6 + 0] = event.getPointerId(i); + positions[i * 6 + 1] = event.getX(i); + positions[i * 6 + 2] = event.getY(i); + positions[i * 6 + 3] = event.getPressure(i); + positions[i * 6 + 4] = GodotInputHandler.getEventTiltX(event); + positions[i * 6 + 5] = GodotInputHandler.getEventTiltY(event); + } + } + + // Magnify event fields and setter + private float magnifyFactor; + void setMagnifyEvent(float x, float y, float factor) { + this.currentEventType = EventType.MAGNIFY; + this.eventX = x; + this.eventY = y; + this.magnifyFactor = factor; + } + + // Pan event setter + void setPanEvent(float x, float y, float deltaX, float deltaY) { + this.currentEventType = EventType.PAN; + this.eventX = x; + this.eventY = y; + this.eventDeltaX = deltaX; + this.eventDeltaY = deltaY; + } + + // common joystick field + private int joystickDevice; + + // Joystick button event fields and setter + private int button; + void setJoystickButtonEvent(int device, int button, boolean pressed) { + this.currentEventType = EventType.JOYSTICK_BUTTON; + this.joystickDevice = device; + this.button = button; + this.eventPressed = pressed; + } + + // Joystick axis event fields and setter + private int axis; + private float value; + void setJoystickAxisEvent(int device, int axis, float value) { + this.currentEventType = EventType.JOYSTICK_AXIS; + this.joystickDevice = device; + this.axis = axis; + this.value = value; + } + + // Joystick hat event fields and setter + private int hatX; + private int hatY; + void setJoystickHatEvent(int device, int hatX, int hatY) { + this.currentEventType = EventType.JOYSTICK_HAT; + this.joystickDevice = device; + this.hatX = hatX; + this.hatY = hatY; + } + + // Joystick connection changed event fields and setter + private boolean connected; + private String joystickName; + void setJoystickConnectionChangedEvent(int device, boolean connected, String name) { + this.currentEventType = EventType.JOYSTICK_CONNECTION_CHANGED; + this.joystickDevice = device; + this.connected = connected; + this.joystickName = name; + } + + // Key event fields and setter + private int physicalKeycode; + private int unicode; + private int keyLabel; + private boolean echo; + void setKeyEvent(int physicalKeycode, int unicode, int keyLabel, boolean pressed, boolean echo) { + this.currentEventType = EventType.KEY; + this.physicalKeycode = physicalKeycode; + this.unicode = unicode; + this.keyLabel = keyLabel; + this.eventPressed = pressed; + this.echo = echo; + } + + // Sensor event fields and setter + private int sensorType; + private float rotatedValue0; + private float rotatedValue1; + private float rotatedValue2; + void setSensorEvent(int sensorType, float rotatedValue0, float rotatedValue1, float rotatedValue2) { + this.currentEventType = EventType.SENSOR; + this.sensorType = sensorType; + this.rotatedValue0 = rotatedValue0; + this.rotatedValue1 = rotatedValue1; + this.rotatedValue2 = rotatedValue2; + } + + @Override + public void run() { + try { + if (currentEventType == null) { + Log.w(TAG, "Invalid event type"); + return; + } + + switch (currentEventType) { + case MOUSE: + GodotLib.dispatchMouseEvent( + eventAction, + buttonsMask, + eventX, + eventY, + eventDeltaX, + eventDeltaY, + doubleTap, + sourceMouseRelative, + pressure, + tiltX, + tiltY); + break; + + case TOUCH: + GodotLib.dispatchTouchEvent( + eventAction, + actionPointerId, + pointerCount, + positions, + doubleTap); + break; + + case MAGNIFY: + GodotLib.magnify(eventX, eventY, magnifyFactor); + break; + + case PAN: + GodotLib.pan(eventX, eventY, eventDeltaX, eventDeltaY); + break; + + case JOYSTICK_BUTTON: + GodotLib.joybutton(joystickDevice, button, eventPressed); + break; + + case JOYSTICK_AXIS: + GodotLib.joyaxis(joystickDevice, axis, value); + break; + + case JOYSTICK_HAT: + GodotLib.joyhat(joystickDevice, hatX, hatY); + break; + + case JOYSTICK_CONNECTION_CHANGED: + GodotLib.joyconnectionchanged(joystickDevice, connected, joystickName); + break; + + case KEY: + GodotLib.key(physicalKeycode, unicode, keyLabel, eventPressed, echo); + break; + + case SENSOR: + switch (sensorType) { + case Sensor.TYPE_ACCELEROMETER: + GodotLib.accelerometer(-rotatedValue0, -rotatedValue1, -rotatedValue2); + break; + + case Sensor.TYPE_GRAVITY: + GodotLib.gravity(-rotatedValue0, -rotatedValue1, -rotatedValue2); + break; + + case Sensor.TYPE_MAGNETIC_FIELD: + GodotLib.magnetometer(-rotatedValue0, -rotatedValue1, -rotatedValue2); + break; + + case Sensor.TYPE_GYROSCOPE: + GodotLib.gyroscope(rotatedValue0, rotatedValue1, rotatedValue2); + break; + } + break; + } + } finally { + recycle(); + } + } + + /** + * Release the current instance back to the pool + */ + private void recycle() { + currentEventType = null; + POOL.release(this); + } +} diff --git a/platform/android/java/lib/src/org/godotengine/godot/io/StorageScope.kt b/platform/android/java/lib/src/org/godotengine/godot/io/StorageScope.kt index 8ee3d5f48f..574ecd58eb 100644 --- a/platform/android/java/lib/src/org/godotengine/godot/io/StorageScope.kt +++ b/platform/android/java/lib/src/org/godotengine/godot/io/StorageScope.kt @@ -34,12 +34,18 @@ import android.content.Context import android.os.Build import android.os.Environment import java.io.File +import org.godotengine.godot.GodotLib /** * Represents the different storage scopes. */ internal enum class StorageScope { /** + * Covers the 'assets' directory + */ + ASSETS, + + /** * Covers internal and external directories accessible to the app without restrictions. */ APP, @@ -56,6 +62,10 @@ internal enum class StorageScope { class Identifier(context: Context) { + companion object { + internal const val ASSETS_PREFIX = "assets://" + } + private val internalAppDir: String? = context.filesDir.canonicalPath private val internalCacheDir: String? = context.cacheDir.canonicalPath private val externalAppDir: String? = context.getExternalFilesDir(null)?.canonicalPath @@ -64,6 +74,14 @@ internal enum class StorageScope { private val documentsSharedDir: String? = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOCUMENTS).canonicalPath /** + * Determine if the given path is accessible. + */ + fun canAccess(path: String?): Boolean { + val storageScope = identifyStorageScope(path) + return storageScope == APP || storageScope == SHARED + } + + /** * Determines which [StorageScope] the given path falls under. */ fun identifyStorageScope(path: String?): StorageScope { @@ -71,9 +89,16 @@ internal enum class StorageScope { return UNKNOWN } - val pathFile = File(path) + if (path.startsWith(ASSETS_PREFIX)) { + return ASSETS + } + + var pathFile = File(path) if (!pathFile.isAbsolute) { - return UNKNOWN + pathFile = File(GodotLib.getProjectResourceDir(), path) + if (!pathFile.isAbsolute) { + return UNKNOWN + } } // If we have 'All Files Access' permission, we can access all directories without diff --git a/platform/android/java/lib/src/org/godotengine/godot/io/directory/AssetsDirectoryAccess.kt b/platform/android/java/lib/src/org/godotengine/godot/io/directory/AssetsDirectoryAccess.kt index b9b7ebac6e..523e852518 100644 --- a/platform/android/java/lib/src/org/godotengine/godot/io/directory/AssetsDirectoryAccess.kt +++ b/platform/android/java/lib/src/org/godotengine/godot/io/directory/AssetsDirectoryAccess.kt @@ -33,18 +33,30 @@ package org.godotengine.godot.io.directory import android.content.Context import android.util.Log import android.util.SparseArray +import org.godotengine.godot.io.StorageScope import org.godotengine.godot.io.directory.DirectoryAccessHandler.Companion.INVALID_DIR_ID import org.godotengine.godot.io.directory.DirectoryAccessHandler.Companion.STARTING_DIR_ID +import org.godotengine.godot.io.file.AssetData import java.io.File import java.io.IOException /** * Handles directories access within the Android assets directory. */ -internal class AssetsDirectoryAccess(context: Context) : DirectoryAccessHandler.DirectoryAccess { +internal class AssetsDirectoryAccess(private val context: Context) : DirectoryAccessHandler.DirectoryAccess { companion object { private val TAG = AssetsDirectoryAccess::class.java.simpleName + + internal fun getAssetsPath(originalPath: String): String { + if (originalPath.startsWith(File.separator)) { + return originalPath.substring(File.separator.length) + } + if (originalPath.startsWith(StorageScope.Identifier.ASSETS_PREFIX)) { + return originalPath.substring(StorageScope.Identifier.ASSETS_PREFIX.length) + } + return originalPath + } } private data class AssetDir(val path: String, val files: Array<String>, var current: Int = 0) @@ -54,13 +66,6 @@ internal class AssetsDirectoryAccess(context: Context) : DirectoryAccessHandler. private var lastDirId = STARTING_DIR_ID private val dirs = SparseArray<AssetDir>() - private fun getAssetsPath(originalPath: String): String { - if (originalPath.startsWith(File.separatorChar)) { - return originalPath.substring(1) - } - return originalPath - } - override fun hasDirId(dirId: Int) = dirs.indexOfKey(dirId) >= 0 override fun dirOpen(path: String): Int { @@ -68,8 +73,8 @@ internal class AssetsDirectoryAccess(context: Context) : DirectoryAccessHandler. try { val files = assetManager.list(assetsPath) ?: return INVALID_DIR_ID // Empty directories don't get added to the 'assets' directory, so - // if ad.files.length > 0 ==> path is directory - // if ad.files.length == 0 ==> path is file + // if files.length > 0 ==> path is directory + // if files.length == 0 ==> path is file if (files.isEmpty()) { return INVALID_DIR_ID } @@ -89,8 +94,8 @@ internal class AssetsDirectoryAccess(context: Context) : DirectoryAccessHandler. try { val files = assetManager.list(assetsPath) ?: return false // Empty directories don't get added to the 'assets' directory, so - // if ad.files.length > 0 ==> path is directory - // if ad.files.length == 0 ==> path is file + // if files.length > 0 ==> path is directory + // if files.length == 0 ==> path is file return files.isNotEmpty() } catch (e: IOException) { Log.e(TAG, "Exception on dirExists", e) @@ -98,19 +103,7 @@ internal class AssetsDirectoryAccess(context: Context) : DirectoryAccessHandler. } } - override fun fileExists(path: String): Boolean { - val assetsPath = getAssetsPath(path) - try { - val files = assetManager.list(assetsPath) ?: return false - // Empty directories don't get added to the 'assets' directory, so - // if ad.files.length > 0 ==> path is directory - // if ad.files.length == 0 ==> path is file - return files.isEmpty() - } catch (e: IOException) { - Log.e(TAG, "Exception on fileExists", e) - return false - } - } + override fun fileExists(path: String) = AssetData.fileExists(context, path) override fun dirIsDir(dirId: Int): Boolean { val ad: AssetDir = dirs[dirId] @@ -171,7 +164,7 @@ internal class AssetsDirectoryAccess(context: Context) : DirectoryAccessHandler. override fun getSpaceLeft() = 0L - override fun rename(from: String, to: String) = false + override fun rename(from: String, to: String) = AssetData.rename(from, to) - override fun remove(filename: String) = false + override fun remove(filename: String) = AssetData.delete(filename) } diff --git a/platform/android/java/lib/src/org/godotengine/godot/io/directory/DirectoryAccessHandler.kt b/platform/android/java/lib/src/org/godotengine/godot/io/directory/DirectoryAccessHandler.kt index dd6d5180c5..9f3461200b 100644 --- a/platform/android/java/lib/src/org/godotengine/godot/io/directory/DirectoryAccessHandler.kt +++ b/platform/android/java/lib/src/org/godotengine/godot/io/directory/DirectoryAccessHandler.kt @@ -32,7 +32,8 @@ package org.godotengine.godot.io.directory import android.content.Context import android.util.Log -import org.godotengine.godot.io.directory.DirectoryAccessHandler.AccessType.ACCESS_FILESYSTEM +import org.godotengine.godot.Godot +import org.godotengine.godot.io.StorageScope import org.godotengine.godot.io.directory.DirectoryAccessHandler.AccessType.ACCESS_RESOURCES /** @@ -45,18 +46,82 @@ class DirectoryAccessHandler(context: Context) { internal const val INVALID_DIR_ID = -1 internal const val STARTING_DIR_ID = 1 - - private fun getAccessTypeFromNative(accessType: Int): AccessType? { - return when (accessType) { - ACCESS_RESOURCES.nativeValue -> ACCESS_RESOURCES - ACCESS_FILESYSTEM.nativeValue -> ACCESS_FILESYSTEM - else -> null - } - } } private enum class AccessType(val nativeValue: Int) { - ACCESS_RESOURCES(0), ACCESS_FILESYSTEM(2) + ACCESS_RESOURCES(0), + + /** + * Maps to [ACCESS_FILESYSTEM] + */ + ACCESS_USERDATA(1), + ACCESS_FILESYSTEM(2); + + fun generateDirAccessId(dirId: Int) = (dirId * DIR_ACCESS_ID_MULTIPLIER) + nativeValue + + companion object { + const val DIR_ACCESS_ID_MULTIPLIER = 10 + + fun fromDirAccessId(dirAccessId: Int): Pair<AccessType?, Int> { + val nativeValue = dirAccessId % DIR_ACCESS_ID_MULTIPLIER + val dirId = dirAccessId / DIR_ACCESS_ID_MULTIPLIER + return Pair(fromNative(nativeValue), dirId) + } + + private fun fromNative(nativeAccessType: Int): AccessType? { + for (accessType in entries) { + if (accessType.nativeValue == nativeAccessType) { + return accessType + } + } + return null + } + + fun fromNative(nativeAccessType: Int, storageScope: StorageScope? = null): AccessType? { + val accessType = fromNative(nativeAccessType) + if (accessType == null) { + Log.w(TAG, "Unsupported access type $nativeAccessType") + return null + } + + // 'Resources' access type takes precedence as it is simple to handle: + // if we receive a 'Resources' access type and this is a template build, + // we provide a 'Resources' directory handler. + // If this is an editor build, 'Resources' refers to the opened project resources + // and so we provide a 'Filesystem' directory handler. + if (accessType == ACCESS_RESOURCES) { + return if (Godot.isEditorBuild()) { + ACCESS_FILESYSTEM + } else { + ACCESS_RESOURCES + } + } else { + // We've received a 'Filesystem' or 'Userdata' access type. On Android, this + // may refer to: + // - assets directory (path has 'assets:/' prefix) + // - app directories + // - device shared directories + // As such we check the storage scope (if available) to figure what type of + // directory handler to provide + if (storageScope != null) { + val accessTypeFromStorageScope = when (storageScope) { + StorageScope.ASSETS -> ACCESS_RESOURCES + StorageScope.APP, StorageScope.SHARED -> ACCESS_FILESYSTEM + StorageScope.UNKNOWN -> null + } + + if (accessTypeFromStorageScope != null) { + return accessTypeFromStorageScope + } + } + // If we're not able to infer the type of directory handler from the storage + // scope, we fall-back to the 'Filesystem' directory handler as it's the default + // for the 'Filesystem' access type. + // Note that ACCESS_USERDATA also maps to ACCESS_FILESYSTEM + return ACCESS_FILESYSTEM + } + } + } } internal interface DirectoryAccess { @@ -76,8 +141,10 @@ class DirectoryAccessHandler(context: Context) { fun remove(filename: String): Boolean } + private val storageScopeIdentifier = StorageScope.Identifier(context) + private val assetsDirAccess = AssetsDirectoryAccess(context) - private val fileSystemDirAccess = FilesystemDirectoryAccess(context) + private val fileSystemDirAccess = FilesystemDirectoryAccess(context, storageScopeIdentifier) fun assetsFileExists(assetsPath: String) = assetsDirAccess.fileExists(assetsPath) fun filesystemFileExists(path: String) = fileSystemDirAccess.fileExists(path) @@ -85,24 +152,32 @@ class DirectoryAccessHandler(context: Context) { private fun hasDirId(accessType: AccessType, dirId: Int): Boolean { return when (accessType) { ACCESS_RESOURCES -> assetsDirAccess.hasDirId(dirId) - ACCESS_FILESYSTEM -> fileSystemDirAccess.hasDirId(dirId) + else -> fileSystemDirAccess.hasDirId(dirId) } } fun dirOpen(nativeAccessType: Int, path: String?): Int { - val accessType = getAccessTypeFromNative(nativeAccessType) - if (path == null || accessType == null) { + if (path == null) { return INVALID_DIR_ID } - return when (accessType) { + val storageScope = storageScopeIdentifier.identifyStorageScope(path) + val accessType = AccessType.fromNative(nativeAccessType, storageScope) ?: return INVALID_DIR_ID + + val dirId = when (accessType) { ACCESS_RESOURCES -> assetsDirAccess.dirOpen(path) - ACCESS_FILESYSTEM -> fileSystemDirAccess.dirOpen(path) + else -> fileSystemDirAccess.dirOpen(path) + } + if (dirId == INVALID_DIR_ID) { + return INVALID_DIR_ID } + + val dirAccessId = accessType.generateDirAccessId(dirId) + return dirAccessId } - fun dirNext(nativeAccessType: Int, dirId: Int): String { - val accessType = getAccessTypeFromNative(nativeAccessType) + fun dirNext(dirAccessId: Int): String { + val (accessType, dirId) = AccessType.fromDirAccessId(dirAccessId) if (accessType == null || !hasDirId(accessType, dirId)) { Log.w(TAG, "dirNext: Invalid dir id: $dirId") return "" @@ -110,12 +185,12 @@ class DirectoryAccessHandler(context: Context) { return when (accessType) { ACCESS_RESOURCES -> assetsDirAccess.dirNext(dirId) - ACCESS_FILESYSTEM -> fileSystemDirAccess.dirNext(dirId) + else -> fileSystemDirAccess.dirNext(dirId) } } - fun dirClose(nativeAccessType: Int, dirId: Int) { - val accessType = getAccessTypeFromNative(nativeAccessType) + fun dirClose(dirAccessId: Int) { + val (accessType, dirId) = AccessType.fromDirAccessId(dirAccessId) if (accessType == null || !hasDirId(accessType, dirId)) { Log.w(TAG, "dirClose: Invalid dir id: $dirId") return @@ -123,12 +198,12 @@ class DirectoryAccessHandler(context: Context) { when (accessType) { ACCESS_RESOURCES -> assetsDirAccess.dirClose(dirId) - ACCESS_FILESYSTEM -> fileSystemDirAccess.dirClose(dirId) + else -> fileSystemDirAccess.dirClose(dirId) } } - fun dirIsDir(nativeAccessType: Int, dirId: Int): Boolean { - val accessType = getAccessTypeFromNative(nativeAccessType) + fun dirIsDir(dirAccessId: Int): Boolean { + val (accessType, dirId) = AccessType.fromDirAccessId(dirAccessId) if (accessType == null || !hasDirId(accessType, dirId)) { Log.w(TAG, "dirIsDir: Invalid dir id: $dirId") return false @@ -136,91 +211,106 @@ class DirectoryAccessHandler(context: Context) { return when (accessType) { ACCESS_RESOURCES -> assetsDirAccess.dirIsDir(dirId) - ACCESS_FILESYSTEM -> fileSystemDirAccess.dirIsDir(dirId) + else -> fileSystemDirAccess.dirIsDir(dirId) } } - fun isCurrentHidden(nativeAccessType: Int, dirId: Int): Boolean { - val accessType = getAccessTypeFromNative(nativeAccessType) + fun isCurrentHidden(dirAccessId: Int): Boolean { + val (accessType, dirId) = AccessType.fromDirAccessId(dirAccessId) if (accessType == null || !hasDirId(accessType, dirId)) { return false } return when (accessType) { ACCESS_RESOURCES -> assetsDirAccess.isCurrentHidden(dirId) - ACCESS_FILESYSTEM -> fileSystemDirAccess.isCurrentHidden(dirId) + else -> fileSystemDirAccess.isCurrentHidden(dirId) } } fun dirExists(nativeAccessType: Int, path: String?): Boolean { - val accessType = getAccessTypeFromNative(nativeAccessType) - if (path == null || accessType == null) { + if (path == null) { return false } + val storageScope = storageScopeIdentifier.identifyStorageScope(path) + val accessType = AccessType.fromNative(nativeAccessType, storageScope) ?: return false + return when (accessType) { ACCESS_RESOURCES -> assetsDirAccess.dirExists(path) - ACCESS_FILESYSTEM -> fileSystemDirAccess.dirExists(path) + else -> fileSystemDirAccess.dirExists(path) } } fun fileExists(nativeAccessType: Int, path: String?): Boolean { - val accessType = getAccessTypeFromNative(nativeAccessType) - if (path == null || accessType == null) { + if (path == null) { return false } + val storageScope = storageScopeIdentifier.identifyStorageScope(path) + val accessType = AccessType.fromNative(nativeAccessType, storageScope) ?: return false + return when (accessType) { ACCESS_RESOURCES -> assetsDirAccess.fileExists(path) - ACCESS_FILESYSTEM -> fileSystemDirAccess.fileExists(path) + else -> fileSystemDirAccess.fileExists(path) } } fun getDriveCount(nativeAccessType: Int): Int { - val accessType = getAccessTypeFromNative(nativeAccessType) ?: return 0 + val accessType = AccessType.fromNative(nativeAccessType) ?: return 0 return when(accessType) { ACCESS_RESOURCES -> assetsDirAccess.getDriveCount() - ACCESS_FILESYSTEM -> fileSystemDirAccess.getDriveCount() + else -> fileSystemDirAccess.getDriveCount() } } fun getDrive(nativeAccessType: Int, drive: Int): String { - val accessType = getAccessTypeFromNative(nativeAccessType) ?: return "" + val accessType = AccessType.fromNative(nativeAccessType) ?: return "" return when (accessType) { ACCESS_RESOURCES -> assetsDirAccess.getDrive(drive) - ACCESS_FILESYSTEM -> fileSystemDirAccess.getDrive(drive) + else -> fileSystemDirAccess.getDrive(drive) } } - fun makeDir(nativeAccessType: Int, dir: String): Boolean { - val accessType = getAccessTypeFromNative(nativeAccessType) ?: return false + fun makeDir(nativeAccessType: Int, dir: String?): Boolean { + if (dir == null) { + return false + } + + val storageScope = storageScopeIdentifier.identifyStorageScope(dir) + val accessType = AccessType.fromNative(nativeAccessType, storageScope) ?: return false + return when (accessType) { ACCESS_RESOURCES -> assetsDirAccess.makeDir(dir) - ACCESS_FILESYSTEM -> fileSystemDirAccess.makeDir(dir) + else -> fileSystemDirAccess.makeDir(dir) } } fun getSpaceLeft(nativeAccessType: Int): Long { - val accessType = getAccessTypeFromNative(nativeAccessType) ?: return 0L + val accessType = AccessType.fromNative(nativeAccessType) ?: return 0L return when (accessType) { ACCESS_RESOURCES -> assetsDirAccess.getSpaceLeft() - ACCESS_FILESYSTEM -> fileSystemDirAccess.getSpaceLeft() + else -> fileSystemDirAccess.getSpaceLeft() } } fun rename(nativeAccessType: Int, from: String, to: String): Boolean { - val accessType = getAccessTypeFromNative(nativeAccessType) ?: return false + val accessType = AccessType.fromNative(nativeAccessType) ?: return false return when (accessType) { ACCESS_RESOURCES -> assetsDirAccess.rename(from, to) - ACCESS_FILESYSTEM -> fileSystemDirAccess.rename(from, to) + else -> fileSystemDirAccess.rename(from, to) } } - fun remove(nativeAccessType: Int, filename: String): Boolean { - val accessType = getAccessTypeFromNative(nativeAccessType) ?: return false + fun remove(nativeAccessType: Int, filename: String?): Boolean { + if (filename == null) { + return false + } + + val storageScope = storageScopeIdentifier.identifyStorageScope(filename) + val accessType = AccessType.fromNative(nativeAccessType, storageScope) ?: return false return when (accessType) { ACCESS_RESOURCES -> assetsDirAccess.remove(filename) - ACCESS_FILESYSTEM -> fileSystemDirAccess.remove(filename) + else -> fileSystemDirAccess.remove(filename) } } diff --git a/platform/android/java/lib/src/org/godotengine/godot/io/directory/FilesystemDirectoryAccess.kt b/platform/android/java/lib/src/org/godotengine/godot/io/directory/FilesystemDirectoryAccess.kt index c8b4f79f30..2830216e12 100644 --- a/platform/android/java/lib/src/org/godotengine/godot/io/directory/FilesystemDirectoryAccess.kt +++ b/platform/android/java/lib/src/org/godotengine/godot/io/directory/FilesystemDirectoryAccess.kt @@ -45,7 +45,7 @@ import java.io.File /** * Handles directories access with the internal and external filesystem. */ -internal class FilesystemDirectoryAccess(private val context: Context): +internal class FilesystemDirectoryAccess(private val context: Context, private val storageScopeIdentifier: StorageScope.Identifier): DirectoryAccessHandler.DirectoryAccess { companion object { @@ -54,7 +54,6 @@ internal class FilesystemDirectoryAccess(private val context: Context): private data class DirData(val dirFile: File, val files: Array<File>, var current: Int = 0) - private val storageScopeIdentifier = StorageScope.Identifier(context) private val storageManager = context.getSystemService(Context.STORAGE_SERVICE) as StorageManager private var lastDirId = STARTING_DIR_ID private val dirs = SparseArray<DirData>() @@ -63,7 +62,8 @@ internal class FilesystemDirectoryAccess(private val context: Context): // Directory access is available for shared storage on Android 11+ // On Android 10, access is also available as long as the `requestLegacyExternalStorage` // tag is available. - return storageScopeIdentifier.identifyStorageScope(path) != StorageScope.UNKNOWN + val storageScope = storageScopeIdentifier.identifyStorageScope(path) + return storageScope != StorageScope.UNKNOWN && storageScope != StorageScope.ASSETS } override fun hasDirId(dirId: Int) = dirs.indexOfKey(dirId) >= 0 diff --git a/platform/android/java/lib/src/org/godotengine/godot/io/file/AssetData.kt b/platform/android/java/lib/src/org/godotengine/godot/io/file/AssetData.kt new file mode 100644 index 0000000000..1ab739d90b --- /dev/null +++ b/platform/android/java/lib/src/org/godotengine/godot/io/file/AssetData.kt @@ -0,0 +1,151 @@ +/**************************************************************************/ +/* AssetData.kt */ +/**************************************************************************/ +/* This file is part of: */ +/* GODOT ENGINE */ +/* https://godotengine.org */ +/**************************************************************************/ +/* Copyright (c) 2014-present Godot Engine contributors (see AUTHORS.md). */ +/* Copyright (c) 2007-2014 Juan Linietsky, Ariel Manzur. */ +/* */ +/* Permission is hereby granted, free of charge, to any person obtaining */ +/* a copy of this software and associated documentation files (the */ +/* "Software"), to deal in the Software without restriction, including */ +/* without limitation the rights to use, copy, modify, merge, publish, */ +/* distribute, sublicense, and/or sell copies of the Software, and to */ +/* permit persons to whom the Software is furnished to do so, subject to */ +/* the following conditions: */ +/* */ +/* The above copyright notice and this permission notice shall be */ +/* included in all copies or substantial portions of the Software. */ +/* */ +/* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, */ +/* EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF */ +/* MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. */ +/* IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY */ +/* CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, */ +/* TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE */ +/* SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ +/**************************************************************************/ + +package org.godotengine.godot.io.file + +import android.content.Context +import android.content.res.AssetManager +import android.util.Log +import org.godotengine.godot.error.Error +import org.godotengine.godot.io.directory.AssetsDirectoryAccess +import java.io.IOException +import java.io.InputStream +import java.lang.UnsupportedOperationException +import java.nio.ByteBuffer +import java.nio.channels.Channels +import java.nio.channels.ReadableByteChannel + +/** + * Implementation of the [DataAccess] which handles access and interaction with files in the + * 'assets' directory + */ +internal class AssetData(context: Context, private val filePath: String, accessFlag: FileAccessFlags) : DataAccess() { + + companion object { + private val TAG = AssetData::class.java.simpleName + + fun fileExists(context: Context, path: String): Boolean { + val assetsPath = AssetsDirectoryAccess.getAssetsPath(path) + try { + val files = context.assets.list(assetsPath) ?: return false + // Empty directories don't get added to the 'assets' directory, so + // if files.length > 0 ==> path is directory + // if files.length == 0 ==> path is file + return files.isEmpty() + } catch (e: IOException) { + Log.e(TAG, "Exception on fileExists", e) + return false + } + } + + fun fileLastModified(path: String) = 0L + + fun delete(path: String) = false + + fun rename(from: String, to: String) = false + } + + private val inputStream: InputStream + internal val readChannel: ReadableByteChannel + + private var position = 0L + private val length: Long + + init { + if (accessFlag == FileAccessFlags.WRITE) { + throw UnsupportedOperationException("Writing to the 'assets' directory is not supported") + } + + val assetsPath = AssetsDirectoryAccess.getAssetsPath(filePath) + inputStream = context.assets.open(assetsPath, AssetManager.ACCESS_BUFFER) + readChannel = Channels.newChannel(inputStream) + + length = inputStream.available().toLong() + } + + override fun close() { + try { + inputStream.close() + } catch (e: IOException) { + Log.w(TAG, "Exception when closing file $filePath.", e) + } + } + + override fun flush() { + Log.w(TAG, "flush() is not supported.") + } + + override fun seek(position: Long) { + try { + inputStream.skip(position) + + this.position = position + if (this.position > length) { + this.position = length + endOfFile = true + } else { + endOfFile = false + } + + } catch(e: IOException) { + Log.w(TAG, "Exception when seeking file $filePath.", e) + } + } + + override fun resize(length: Long): Error { + Log.w(TAG, "resize() is not supported.") + return Error.ERR_UNAVAILABLE + } + + override fun position() = position + + override fun size() = length + + override fun read(buffer: ByteBuffer): Int { + return try { + val readBytes = readChannel.read(buffer) + if (readBytes == -1) { + endOfFile = true + 0 + } else { + position += readBytes + endOfFile = position() >= size() + readBytes + } + } catch (e: IOException) { + Log.w(TAG, "Exception while reading from $filePath.", e) + 0 + } + } + + override fun write(buffer: ByteBuffer) { + Log.w(TAG, "write() is not supported.") + } +} diff --git a/platform/android/java/lib/src/org/godotengine/godot/io/file/DataAccess.kt b/platform/android/java/lib/src/org/godotengine/godot/io/file/DataAccess.kt index 11cf7b3566..73f020f249 100644 --- a/platform/android/java/lib/src/org/godotengine/godot/io/file/DataAccess.kt +++ b/platform/android/java/lib/src/org/godotengine/godot/io/file/DataAccess.kt @@ -33,12 +33,17 @@ package org.godotengine.godot.io.file import android.content.Context import android.os.Build import android.util.Log +import org.godotengine.godot.error.Error import org.godotengine.godot.io.StorageScope +import java.io.FileNotFoundException import java.io.IOException +import java.io.InputStream import java.nio.ByteBuffer +import java.nio.channels.Channels import java.nio.channels.ClosedChannelException import java.nio.channels.FileChannel import java.nio.channels.NonWritableChannelException +import kotlin.jvm.Throws import kotlin.math.max /** @@ -47,11 +52,37 @@ import kotlin.math.max * Its derived instances provide concrete implementations to handle regular file access, as well * as file access through the media store API on versions of Android were scoped storage is enabled. */ -internal abstract class DataAccess(private val filePath: String) { +internal abstract class DataAccess { companion object { private val TAG = DataAccess::class.java.simpleName + @Throws(java.lang.Exception::class, FileNotFoundException::class) + fun getInputStream(storageScope: StorageScope, context: Context, filePath: String): InputStream? { + return when(storageScope) { + StorageScope.ASSETS -> { + val assetData = AssetData(context, filePath, FileAccessFlags.READ) + Channels.newInputStream(assetData.readChannel) + } + + StorageScope.APP -> { + val fileData = FileData(filePath, FileAccessFlags.READ) + Channels.newInputStream(fileData.fileChannel) + } + StorageScope.SHARED -> { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + val mediaStoreData = MediaStoreData(context, filePath, FileAccessFlags.READ) + Channels.newInputStream(mediaStoreData.fileChannel) + } else { + null + } + } + + StorageScope.UNKNOWN -> null + } + } + + @Throws(java.lang.Exception::class, FileNotFoundException::class) fun generateDataAccess( storageScope: StorageScope, context: Context, @@ -61,6 +92,8 @@ internal abstract class DataAccess(private val filePath: String) { return when (storageScope) { StorageScope.APP -> FileData(filePath, accessFlag) + StorageScope.ASSETS -> AssetData(context, filePath, accessFlag) + StorageScope.SHARED -> if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { MediaStoreData(context, filePath, accessFlag) } else { @@ -74,7 +107,13 @@ internal abstract class DataAccess(private val filePath: String) { fun fileExists(storageScope: StorageScope, context: Context, path: String): Boolean { return when(storageScope) { StorageScope.APP -> FileData.fileExists(path) - StorageScope.SHARED -> MediaStoreData.fileExists(context, path) + StorageScope.ASSETS -> AssetData.fileExists(context, path) + StorageScope.SHARED -> if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + MediaStoreData.fileExists(context, path) + } else { + false + } + StorageScope.UNKNOWN -> false } } @@ -82,7 +121,13 @@ internal abstract class DataAccess(private val filePath: String) { fun fileLastModified(storageScope: StorageScope, context: Context, path: String): Long { return when(storageScope) { StorageScope.APP -> FileData.fileLastModified(path) - StorageScope.SHARED -> MediaStoreData.fileLastModified(context, path) + StorageScope.ASSETS -> AssetData.fileLastModified(path) + StorageScope.SHARED -> if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + MediaStoreData.fileLastModified(context, path) + } else { + 0L + } + StorageScope.UNKNOWN -> 0L } } @@ -90,7 +135,13 @@ internal abstract class DataAccess(private val filePath: String) { fun removeFile(storageScope: StorageScope, context: Context, path: String): Boolean { return when(storageScope) { StorageScope.APP -> FileData.delete(path) - StorageScope.SHARED -> MediaStoreData.delete(context, path) + StorageScope.ASSETS -> AssetData.delete(path) + StorageScope.SHARED -> if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + MediaStoreData.delete(context, path) + } else { + false + } + StorageScope.UNKNOWN -> false } } @@ -98,103 +149,120 @@ internal abstract class DataAccess(private val filePath: String) { fun renameFile(storageScope: StorageScope, context: Context, from: String, to: String): Boolean { return when(storageScope) { StorageScope.APP -> FileData.rename(from, to) - StorageScope.SHARED -> MediaStoreData.rename(context, from, to) + StorageScope.ASSETS -> AssetData.rename(from, to) + StorageScope.SHARED -> if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + MediaStoreData.rename(context, from, to) + } else { + false + } + StorageScope.UNKNOWN -> false } } } - protected abstract val fileChannel: FileChannel internal var endOfFile = false + abstract fun close() + abstract fun flush() + abstract fun seek(position: Long) + abstract fun resize(length: Long): Error + abstract fun position(): Long + abstract fun size(): Long + abstract fun read(buffer: ByteBuffer): Int + abstract fun write(buffer: ByteBuffer) - fun close() { - try { - fileChannel.close() - } catch (e: IOException) { - Log.w(TAG, "Exception when closing file $filePath.", e) - } + fun seekFromEnd(positionFromEnd: Long) { + val positionFromBeginning = max(0, size() - positionFromEnd) + seek(positionFromBeginning) } - fun flush() { - try { - fileChannel.force(false) - } catch (e: IOException) { - Log.w(TAG, "Exception when flushing file $filePath.", e) + abstract class FileChannelDataAccess(private val filePath: String) : DataAccess() { + internal abstract val fileChannel: FileChannel + + override fun close() { + try { + fileChannel.close() + } catch (e: IOException) { + Log.w(TAG, "Exception when closing file $filePath.", e) + } } - } - fun seek(position: Long) { - try { - fileChannel.position(position) - endOfFile = position >= fileChannel.size() - } catch (e: Exception) { - Log.w(TAG, "Exception when seeking file $filePath.", e) + override fun flush() { + try { + fileChannel.force(false) + } catch (e: IOException) { + Log.w(TAG, "Exception when flushing file $filePath.", e) + } } - } - fun seekFromEnd(positionFromEnd: Long) { - val positionFromBeginning = max(0, size() - positionFromEnd) - seek(positionFromBeginning) - } + override fun seek(position: Long) { + try { + fileChannel.position(position) + endOfFile = position >= fileChannel.size() + } catch (e: Exception) { + Log.w(TAG, "Exception when seeking file $filePath.", e) + } + } - fun resize(length: Long): Int { - return try { - fileChannel.truncate(length) - FileErrors.OK.nativeValue - } catch (e: NonWritableChannelException) { - FileErrors.FILE_CANT_OPEN.nativeValue - } catch (e: ClosedChannelException) { - FileErrors.FILE_CANT_OPEN.nativeValue - } catch (e: IllegalArgumentException) { - FileErrors.INVALID_PARAMETER.nativeValue - } catch (e: IOException) { - FileErrors.FAILED.nativeValue + override fun resize(length: Long): Error { + return try { + fileChannel.truncate(length) + Error.OK + } catch (e: NonWritableChannelException) { + Error.ERR_FILE_CANT_OPEN + } catch (e: ClosedChannelException) { + Error.ERR_FILE_CANT_OPEN + } catch (e: IllegalArgumentException) { + Error.ERR_INVALID_PARAMETER + } catch (e: IOException) { + Error.FAILED + } } - } - fun position(): Long { - return try { - fileChannel.position() + override fun position(): Long { + return try { + fileChannel.position() + } catch (e: IOException) { + Log.w( + TAG, + "Exception when retrieving position for file $filePath.", + e + ) + 0L + } + } + + override fun size() = try { + fileChannel.size() } catch (e: IOException) { - Log.w( - TAG, - "Exception when retrieving position for file $filePath.", - e - ) + Log.w(TAG, "Exception when retrieving size for file $filePath.", e) 0L } - } - - fun size() = try { - fileChannel.size() - } catch (e: IOException) { - Log.w(TAG, "Exception when retrieving size for file $filePath.", e) - 0L - } - fun read(buffer: ByteBuffer): Int { - return try { - val readBytes = fileChannel.read(buffer) - endOfFile = readBytes == -1 || (fileChannel.position() >= fileChannel.size()) - if (readBytes == -1) { + override fun read(buffer: ByteBuffer): Int { + return try { + val readBytes = fileChannel.read(buffer) + endOfFile = readBytes == -1 || (fileChannel.position() >= fileChannel.size()) + if (readBytes == -1) { + 0 + } else { + readBytes + } + } catch (e: IOException) { + Log.w(TAG, "Exception while reading from file $filePath.", e) 0 - } else { - readBytes } - } catch (e: IOException) { - Log.w(TAG, "Exception while reading from file $filePath.", e) - 0 } - } - fun write(buffer: ByteBuffer) { - try { - val writtenBytes = fileChannel.write(buffer) - if (writtenBytes > 0) { - endOfFile = false + override fun write(buffer: ByteBuffer) { + try { + val writtenBytes = fileChannel.write(buffer) + if (writtenBytes > 0) { + endOfFile = false + } + } catch (e: IOException) { + Log.w(TAG, "Exception while writing to file $filePath.", e) } - } catch (e: IOException) { - Log.w(TAG, "Exception while writing to file $filePath.", e) } } } diff --git a/platform/android/java/lib/src/org/godotengine/godot/io/file/FileAccessFlags.kt b/platform/android/java/lib/src/org/godotengine/godot/io/file/FileAccessFlags.kt index 38974af753..f81127e90a 100644 --- a/platform/android/java/lib/src/org/godotengine/godot/io/file/FileAccessFlags.kt +++ b/platform/android/java/lib/src/org/godotengine/godot/io/file/FileAccessFlags.kt @@ -76,7 +76,7 @@ internal enum class FileAccessFlags(val nativeValue: Int) { companion object { fun fromNativeModeFlags(modeFlag: Int): FileAccessFlags? { - for (flag in values()) { + for (flag in entries) { if (flag.nativeValue == modeFlag) { return flag } diff --git a/platform/android/java/lib/src/org/godotengine/godot/io/file/FileAccessHandler.kt b/platform/android/java/lib/src/org/godotengine/godot/io/file/FileAccessHandler.kt index 1d773467e8..dee7aebdc3 100644 --- a/platform/android/java/lib/src/org/godotengine/godot/io/file/FileAccessHandler.kt +++ b/platform/android/java/lib/src/org/godotengine/godot/io/file/FileAccessHandler.kt @@ -33,8 +33,11 @@ package org.godotengine.godot.io.file import android.content.Context import android.util.Log import android.util.SparseArray +import org.godotengine.godot.error.Error import org.godotengine.godot.io.StorageScope import java.io.FileNotFoundException +import java.io.InputStream +import java.lang.UnsupportedOperationException import java.nio.ByteBuffer /** @@ -45,8 +48,20 @@ class FileAccessHandler(val context: Context) { companion object { private val TAG = FileAccessHandler::class.java.simpleName - internal const val INVALID_FILE_ID = 0 + private const val INVALID_FILE_ID = 0 private const val STARTING_FILE_ID = 1 + private val FILE_OPEN_FAILED = Pair(Error.FAILED, INVALID_FILE_ID) + + internal fun getInputStream(context: Context, storageScopeIdentifier: StorageScope.Identifier, path: String?): InputStream? { + val storageScope = storageScopeIdentifier.identifyStorageScope(path) + return try { + path?.let { + DataAccess.getInputStream(storageScope, context, path) + } + } catch (e: Exception) { + null + } + } internal fun fileExists(context: Context, storageScopeIdentifier: StorageScope.Identifier, path: String?): Boolean { val storageScope = storageScopeIdentifier.identifyStorageScope(path) @@ -92,35 +107,55 @@ class FileAccessHandler(val context: Context) { } } - private val storageScopeIdentifier = StorageScope.Identifier(context) + internal val storageScopeIdentifier = StorageScope.Identifier(context) private val files = SparseArray<DataAccess>() private var lastFileId = STARTING_FILE_ID private fun hasFileId(fileId: Int) = files.indexOfKey(fileId) >= 0 + fun canAccess(filePath: String?): Boolean { + return storageScopeIdentifier.canAccess(filePath) + } + + /** + * Returns a positive (> 0) file id when the operation succeeds. + * Otherwise, returns a negative value of [Error]. + */ fun fileOpen(path: String?, modeFlags: Int): Int { - val accessFlag = FileAccessFlags.fromNativeModeFlags(modeFlags) ?: return INVALID_FILE_ID - return fileOpen(path, accessFlag) + val (fileError, fileId) = fileOpen(path, FileAccessFlags.fromNativeModeFlags(modeFlags)) + return if (fileError == Error.OK) { + fileId + } else { + // Return the negative of the [Error#toNativeValue()] value to differentiate from the + // positive file id. + -fileError.toNativeValue() + } } - internal fun fileOpen(path: String?, accessFlag: FileAccessFlags): Int { + internal fun fileOpen(path: String?, accessFlag: FileAccessFlags?): Pair<Error, Int> { + if (accessFlag == null) { + return FILE_OPEN_FAILED + } + val storageScope = storageScopeIdentifier.identifyStorageScope(path) if (storageScope == StorageScope.UNKNOWN) { - return INVALID_FILE_ID + return FILE_OPEN_FAILED } return try { path?.let { - val dataAccess = DataAccess.generateDataAccess(storageScope, context, it, accessFlag) ?: return INVALID_FILE_ID + val dataAccess = DataAccess.generateDataAccess(storageScope, context, it, accessFlag) ?: return FILE_OPEN_FAILED files.put(++lastFileId, dataAccess) - lastFileId - } ?: INVALID_FILE_ID + Pair(Error.OK, lastFileId) + } ?: FILE_OPEN_FAILED } catch (e: FileNotFoundException) { - FileErrors.FILE_NOT_FOUND.nativeValue + Pair(Error.ERR_FILE_NOT_FOUND, INVALID_FILE_ID) + } catch (e: UnsupportedOperationException) { + Pair(Error.ERR_UNAVAILABLE, INVALID_FILE_ID) } catch (e: Exception) { Log.w(TAG, "Error while opening $path", e) - INVALID_FILE_ID + FILE_OPEN_FAILED } } @@ -172,6 +207,10 @@ class FileAccessHandler(val context: Context) { files[fileId].flush() } + fun getInputStream(path: String?) = Companion.getInputStream(context, storageScopeIdentifier, path) + + fun renameFile(from: String, to: String) = Companion.renameFile(context, storageScopeIdentifier, from, to) + fun fileExists(path: String?) = Companion.fileExists(context, storageScopeIdentifier, path) fun fileLastModified(filepath: String?): Long { @@ -191,10 +230,10 @@ class FileAccessHandler(val context: Context) { fun fileResize(fileId: Int, length: Long): Int { if (!hasFileId(fileId)) { - return FileErrors.FAILED.nativeValue + return Error.FAILED.toNativeValue() } - return files[fileId].resize(length) + return files[fileId].resize(length).toNativeValue() } fun fileGetPosition(fileId: Int): Long { diff --git a/platform/android/java/lib/src/org/godotengine/godot/io/file/FileData.kt b/platform/android/java/lib/src/org/godotengine/godot/io/file/FileData.kt index f2c0577c21..873daada3c 100644 --- a/platform/android/java/lib/src/org/godotengine/godot/io/file/FileData.kt +++ b/platform/android/java/lib/src/org/godotengine/godot/io/file/FileData.kt @@ -38,7 +38,7 @@ import java.nio.channels.FileChannel /** * Implementation of [DataAccess] which handles regular (not scoped) file access and interactions. */ -internal class FileData(filePath: String, accessFlag: FileAccessFlags) : DataAccess(filePath) { +internal class FileData(filePath: String, accessFlag: FileAccessFlags) : DataAccess.FileChannelDataAccess(filePath) { companion object { private val TAG = FileData::class.java.simpleName @@ -53,7 +53,7 @@ internal class FileData(filePath: String, accessFlag: FileAccessFlags) : DataAcc fun fileLastModified(filepath: String): Long { return try { - File(filepath).lastModified() + File(filepath).lastModified() / 1000L } catch (e: SecurityException) { 0L } @@ -80,10 +80,16 @@ internal class FileData(filePath: String, accessFlag: FileAccessFlags) : DataAcc override val fileChannel: FileChannel init { - if (accessFlag == FileAccessFlags.WRITE) { - fileChannel = FileOutputStream(filePath, !accessFlag.shouldTruncate()).channel + fileChannel = if (accessFlag == FileAccessFlags.WRITE) { + // Create parent directory is necessary + val parentDir = File(filePath).parentFile + if (parentDir != null && !parentDir.exists()) { + parentDir.mkdirs() + } + + FileOutputStream(filePath, !accessFlag.shouldTruncate()).channel } else { - fileChannel = RandomAccessFile(filePath, accessFlag.getMode()).channel + RandomAccessFile(filePath, accessFlag.getMode()).channel } if (accessFlag.shouldTruncate()) { diff --git a/platform/android/java/lib/src/org/godotengine/godot/io/file/MediaStoreData.kt b/platform/android/java/lib/src/org/godotengine/godot/io/file/MediaStoreData.kt index 5410eed727..97362e2542 100644 --- a/platform/android/java/lib/src/org/godotengine/godot/io/file/MediaStoreData.kt +++ b/platform/android/java/lib/src/org/godotengine/godot/io/file/MediaStoreData.kt @@ -52,7 +52,7 @@ import java.nio.channels.FileChannel */ @RequiresApi(Build.VERSION_CODES.Q) internal class MediaStoreData(context: Context, filePath: String, accessFlag: FileAccessFlags) : - DataAccess(filePath) { + DataAccess.FileChannelDataAccess(filePath) { private data class DataItem( val id: Long, @@ -203,7 +203,7 @@ internal class MediaStoreData(context: Context, filePath: String, accessFlag: Fi } val dataItem = result[0] - return dataItem.dateModified.toLong() + return dataItem.dateModified.toLong() / 1000L } fun rename(context: Context, from: String, to: String): Boolean { diff --git a/platform/android/java/lib/src/org/godotengine/godot/plugin/GodotPluginRegistry.java b/platform/android/java/lib/src/org/godotengine/godot/plugin/GodotPluginRegistry.java index 711bca02e7..8976dd65db 100644 --- a/platform/android/java/lib/src/org/godotengine/godot/plugin/GodotPluginRegistry.java +++ b/platform/android/java/lib/src/org/godotengine/godot/plugin/GodotPluginRegistry.java @@ -43,6 +43,7 @@ import androidx.annotation.Nullable; import java.lang.reflect.Constructor; import java.util.Collection; +import java.util.Collections; import java.util.Set; import java.util.concurrent.ConcurrentHashMap; @@ -82,6 +83,9 @@ public final class GodotPluginRegistry { * Retrieve the full set of loaded plugins. */ public Collection<GodotPlugin> getAllPlugins() { + if (registry.isEmpty()) { + return Collections.emptyList(); + } return registry.values(); } diff --git a/platform/android/java/lib/src/org/godotengine/godot/utils/BenchmarkUtils.kt b/platform/android/java/lib/src/org/godotengine/godot/utils/BenchmarkUtils.kt index 69748c0a8d..738f27e877 100644 --- a/platform/android/java/lib/src/org/godotengine/godot/utils/BenchmarkUtils.kt +++ b/platform/android/java/lib/src/org/godotengine/godot/utils/BenchmarkUtils.kt @@ -37,6 +37,7 @@ import android.os.SystemClock import android.os.Trace import android.util.Log import org.godotengine.godot.BuildConfig +import org.godotengine.godot.error.Error import org.godotengine.godot.io.file.FileAccessFlags import org.godotengine.godot.io.file.FileAccessHandler import org.json.JSONObject @@ -81,7 +82,8 @@ fun beginBenchmarkMeasure(scope: String, label: String) { * * * Note: Only enabled on 'editorDev' build variant. */ -fun endBenchmarkMeasure(scope: String, label: String) { +@JvmOverloads +fun endBenchmarkMeasure(scope: String, label: String, dumpBenchmark: Boolean = false) { if (BuildConfig.FLAVOR != "editor" || BuildConfig.BUILD_TYPE != "dev") { return } @@ -93,6 +95,10 @@ fun endBenchmarkMeasure(scope: String, label: String) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { Trace.endAsyncSection("[$scope] $label", 0) } + + if (dumpBenchmark) { + dumpBenchmark() + } } /** @@ -102,11 +108,11 @@ fun endBenchmarkMeasure(scope: String, label: String) { * * Note: Only enabled on 'editorDev' build variant. */ @JvmOverloads -fun dumpBenchmark(fileAccessHandler: FileAccessHandler?, filepath: String? = benchmarkFile) { +fun dumpBenchmark(fileAccessHandler: FileAccessHandler? = null, filepath: String? = benchmarkFile) { if (BuildConfig.FLAVOR != "editor" || BuildConfig.BUILD_TYPE != "dev") { return } - if (!useBenchmark) { + if (!useBenchmark || benchmarkTracker.isEmpty()) { return } @@ -123,8 +129,8 @@ fun dumpBenchmark(fileAccessHandler: FileAccessHandler?, filepath: String? = ben Log.i(TAG, "BENCHMARK:\n$printOut") if (fileAccessHandler != null && !filepath.isNullOrBlank()) { - val fileId = fileAccessHandler.fileOpen(filepath, FileAccessFlags.WRITE) - if (fileId != FileAccessHandler.INVALID_FILE_ID) { + val (fileError, fileId) = fileAccessHandler.fileOpen(filepath, FileAccessFlags.WRITE) + if (fileError == Error.OK) { val jsonOutput = JSONObject(benchmarkTracker.toMap()).toString(4) fileAccessHandler.fileWrite(fileId, ByteBuffer.wrap(jsonOutput.toByteArray())) fileAccessHandler.fileClose(fileId) diff --git a/platform/android/java/lib/src/org/godotengine/godot/vulkan/VkRenderer.kt b/platform/android/java/lib/src/org/godotengine/godot/vulkan/VkRenderer.kt index 6f09f51d4c..a93a7dbe09 100644 --- a/platform/android/java/lib/src/org/godotengine/godot/vulkan/VkRenderer.kt +++ b/platform/android/java/lib/src/org/godotengine/godot/vulkan/VkRenderer.kt @@ -31,11 +31,9 @@ @file:JvmName("VkRenderer") package org.godotengine.godot.vulkan +import android.util.Log import android.view.Surface - -import org.godotengine.godot.Godot import org.godotengine.godot.GodotLib -import org.godotengine.godot.plugin.GodotPlugin import org.godotengine.godot.plugin.GodotPluginRegistry /** @@ -52,6 +50,11 @@ import org.godotengine.godot.plugin.GodotPluginRegistry * @see [VkSurfaceView.startRenderer] */ internal class VkRenderer { + + companion object { + private val TAG = VkRenderer::class.java.simpleName + } + private val pluginRegistry: GodotPluginRegistry = GodotPluginRegistry.getPluginRegistry() /** @@ -101,8 +104,10 @@ internal class VkRenderer { } /** - * Called when the rendering thread is destroyed and used as signal to tear down the Vulkan logic. + * Invoked when the render thread is in the process of shutting down. */ - fun onVkDestroy() { + fun onRenderThreadExiting() { + Log.d(TAG, "Destroying Godot Engine") + GodotLib.ondestroy() } } diff --git a/platform/android/java/lib/src/org/godotengine/godot/vulkan/VkSurfaceView.kt b/platform/android/java/lib/src/org/godotengine/godot/vulkan/VkSurfaceView.kt index 791b425444..9e30de6a15 100644 --- a/platform/android/java/lib/src/org/godotengine/godot/vulkan/VkSurfaceView.kt +++ b/platform/android/java/lib/src/org/godotengine/godot/vulkan/VkSurfaceView.kt @@ -113,12 +113,10 @@ open internal class VkSurfaceView(context: Context) : SurfaceView(context), Surf } /** - * Tear down the rendering thread. - * - * Must not be called before a [VkRenderer] has been set. + * Requests the render thread to exit and block until it does. */ - fun onDestroy() { - vkThread.blockingExit() + fun requestRenderThreadExitAndWait() { + vkThread.requestExitAndWait() } override fun surfaceChanged(holder: SurfaceHolder, format: Int, width: Int, height: Int) { diff --git a/platform/android/java/lib/src/org/godotengine/godot/vulkan/VkThread.kt b/platform/android/java/lib/src/org/godotengine/godot/vulkan/VkThread.kt index 8c0065b31e..c7cb97d911 100644 --- a/platform/android/java/lib/src/org/godotengine/godot/vulkan/VkThread.kt +++ b/platform/android/java/lib/src/org/godotengine/godot/vulkan/VkThread.kt @@ -75,6 +75,9 @@ internal class VkThread(private val vkSurfaceView: VkSurfaceView, private val vk private fun threadExiting() { lock.withLock { + Log.d(TAG, "Exiting render thread") + vkRenderer.onRenderThreadExiting() + exited = true lockCondition.signalAll() } @@ -93,7 +96,7 @@ internal class VkThread(private val vkSurfaceView: VkSurfaceView, private val vk /** * Request the thread to exit and block until it's done. */ - fun blockingExit() { + fun requestExitAndWait() { lock.withLock { shouldExit = true lockCondition.signalAll() @@ -171,7 +174,6 @@ internal class VkThread(private val vkSurfaceView: VkSurfaceView, private val vk while (true) { // Code path for exiting the thread loop. if (shouldExit) { - vkRenderer.onVkDestroy() return } diff --git a/platform/android/java_godot_lib_jni.cpp b/platform/android/java_godot_lib_jni.cpp index 40068745d6..1114969de8 100644 --- a/platform/android/java_godot_lib_jni.cpp +++ b/platform/android/java_godot_lib_jni.cpp @@ -51,6 +51,7 @@ #include "core/config/project_settings.h" #include "core/input/input.h" #include "main/main.h" +#include "servers/xr_server.h" #ifdef TOOLS_ENABLED #include "editor/editor_settings.h" @@ -67,6 +68,13 @@ static AndroidInputHandler *input_handler = nullptr; static GodotJavaWrapper *godot_java = nullptr; static GodotIOJavaWrapper *godot_io_java = nullptr; +enum StartupStep { + STEP_TERMINATED = -1, + STEP_SETUP, + STEP_SHOW_LOGO, + STEP_STARTED +}; + static SafeNumeric<int> step; // Shared between UI and render threads static Size2 new_size; @@ -76,7 +84,11 @@ static Vector3 magnetometer; static Vector3 gyroscope; static void _terminate(JNIEnv *env, bool p_restart = false) { - step.set(-1); // Ensure no further steps are attempted and no further events are sent + if (step.get() == STEP_TERMINATED) { + return; + } + + step.set(STEP_TERMINATED); // Ensure no further steps are attempted and no further events are sent // lets cleanup // Unregister android plugins @@ -107,6 +119,7 @@ static void _terminate(JNIEnv *env, bool p_restart = false) { NetSocketAndroid::terminate(); if (godot_java) { + godot_java->on_godot_terminating(env); if (!restart_on_cleanup) { if (p_restart) { godot_java->restart(env); @@ -203,7 +216,7 @@ JNIEXPORT void JNICALL Java_org_godotengine_godot_GodotLib_resize(JNIEnv *env, j os_android->set_display_size(Size2i(p_width, p_height)); // No need to reset the surface during startup - if (step.get() > 0) { + if (step.get() > STEP_SETUP) { if (p_surface) { ANativeWindow *native_window = ANativeWindow_fromSurface(env, p_surface); os_android->set_native_window(native_window); @@ -216,7 +229,7 @@ JNIEXPORT void JNICALL Java_org_godotengine_godot_GodotLib_resize(JNIEnv *env, j JNIEXPORT void JNICALL Java_org_godotengine_godot_GodotLib_newcontext(JNIEnv *env, jclass clazz, jobject p_surface) { if (os_android) { - if (step.get() == 0) { + if (step.get() == STEP_SETUP) { // During startup if (p_surface) { ANativeWindow *native_window = ANativeWindow_fromSurface(env, p_surface); @@ -230,7 +243,7 @@ JNIEXPORT void JNICALL Java_org_godotengine_godot_GodotLib_newcontext(JNIEnv *en } JNIEXPORT void JNICALL Java_org_godotengine_godot_GodotLib_back(JNIEnv *env, jclass clazz) { - if (step.get() == 0) { + if (step.get() <= STEP_SETUP) { return; } @@ -244,20 +257,37 @@ JNIEXPORT void JNICALL Java_org_godotengine_godot_GodotLib_ttsCallback(JNIEnv *e } JNIEXPORT jboolean JNICALL Java_org_godotengine_godot_GodotLib_step(JNIEnv *env, jclass clazz) { - if (step.get() == -1) { + if (step.get() == STEP_TERMINATED) { return true; } - if (step.get() == 0) { + if (step.get() == STEP_SETUP) { // Since Godot is initialized on the UI thread, main_thread_id was set to that thread's id, // but for Godot purposes, the main thread is the one running the game loop - Main::setup2(); + Main::setup2(false); // The logo is shown in the next frame otherwise we run into rendering issues input_handler = new AndroidInputHandler(); step.increment(); return true; } - if (step.get() == 1) { + if (step.get() == STEP_SHOW_LOGO) { + bool xr_enabled; + if (XRServer::get_xr_mode() == XRServer::XRMODE_DEFAULT) { + xr_enabled = GLOBAL_GET("xr/shaders/enabled"); + } else { + xr_enabled = XRServer::get_xr_mode() == XRServer::XRMODE_ON; + } + // Unlike PCVR, there's no additional 2D screen onto which to render the boot logo, + // so we skip this step if xr is enabled. + if (!xr_enabled) { + Main::setup_boot_logo(); + } + + step.increment(); + return true; + } + + if (step.get() == STEP_STARTED) { if (Main::start() != EXIT_SUCCESS) { return true; // should exit instead and print the error } @@ -283,7 +313,7 @@ JNIEXPORT jboolean JNICALL Java_org_godotengine_godot_GodotLib_step(JNIEnv *env, // Called on the UI thread JNIEXPORT void JNICALL Java_org_godotengine_godot_GodotLib_dispatchMouseEvent(JNIEnv *env, jclass clazz, jint p_event_type, jint p_button_mask, jfloat p_x, jfloat p_y, jfloat p_delta_x, jfloat p_delta_y, jboolean p_double_click, jboolean p_source_mouse_relative, jfloat p_pressure, jfloat p_tilt_x, jfloat p_tilt_y) { - if (step.get() <= 0) { + if (step.get() <= STEP_SETUP) { return; } @@ -292,7 +322,7 @@ JNIEXPORT void JNICALL Java_org_godotengine_godot_GodotLib_dispatchMouseEvent(JN // Called on the UI thread JNIEXPORT void JNICALL Java_org_godotengine_godot_GodotLib_dispatchTouchEvent(JNIEnv *env, jclass clazz, jint ev, jint pointer, jint pointer_count, jfloatArray position, jboolean p_double_tap) { - if (step.get() <= 0) { + if (step.get() <= STEP_SETUP) { return; } @@ -313,7 +343,7 @@ JNIEXPORT void JNICALL Java_org_godotengine_godot_GodotLib_dispatchTouchEvent(JN // Called on the UI thread JNIEXPORT void JNICALL Java_org_godotengine_godot_GodotLib_magnify(JNIEnv *env, jclass clazz, jfloat p_x, jfloat p_y, jfloat p_factor) { - if (step.get() <= 0) { + if (step.get() <= STEP_SETUP) { return; } input_handler->process_magnify(Point2(p_x, p_y), p_factor); @@ -321,7 +351,7 @@ JNIEXPORT void JNICALL Java_org_godotengine_godot_GodotLib_magnify(JNIEnv *env, // Called on the UI thread JNIEXPORT void JNICALL Java_org_godotengine_godot_GodotLib_pan(JNIEnv *env, jclass clazz, jfloat p_x, jfloat p_y, jfloat p_delta_x, jfloat p_delta_y) { - if (step.get() <= 0) { + if (step.get() <= STEP_SETUP) { return; } input_handler->process_pan(Point2(p_x, p_y), Vector2(p_delta_x, p_delta_y)); @@ -329,7 +359,7 @@ JNIEXPORT void JNICALL Java_org_godotengine_godot_GodotLib_pan(JNIEnv *env, jcla // Called on the UI thread JNIEXPORT void JNICALL Java_org_godotengine_godot_GodotLib_joybutton(JNIEnv *env, jclass clazz, jint p_device, jint p_button, jboolean p_pressed) { - if (step.get() <= 0) { + if (step.get() <= STEP_SETUP) { return; } @@ -344,7 +374,7 @@ JNIEXPORT void JNICALL Java_org_godotengine_godot_GodotLib_joybutton(JNIEnv *env // Called on the UI thread JNIEXPORT void JNICALL Java_org_godotengine_godot_GodotLib_joyaxis(JNIEnv *env, jclass clazz, jint p_device, jint p_axis, jfloat p_value) { - if (step.get() <= 0) { + if (step.get() <= STEP_SETUP) { return; } @@ -359,7 +389,7 @@ JNIEXPORT void JNICALL Java_org_godotengine_godot_GodotLib_joyaxis(JNIEnv *env, // Called on the UI thread JNIEXPORT void JNICALL Java_org_godotengine_godot_GodotLib_joyhat(JNIEnv *env, jclass clazz, jint p_device, jint p_hat_x, jint p_hat_y) { - if (step.get() <= 0) { + if (step.get() <= STEP_SETUP) { return; } @@ -396,7 +426,7 @@ JNIEXPORT void JNICALL Java_org_godotengine_godot_GodotLib_joyconnectionchanged( // Called on the UI thread JNIEXPORT void JNICALL Java_org_godotengine_godot_GodotLib_key(JNIEnv *env, jclass clazz, jint p_physical_keycode, jint p_unicode, jint p_key_label, jboolean p_pressed, jboolean p_echo) { - if (step.get() <= 0) { + if (step.get() <= STEP_SETUP) { return; } input_handler->process_key_event(p_physical_keycode, p_unicode, p_key_label, p_pressed, p_echo); @@ -419,7 +449,7 @@ JNIEXPORT void JNICALL Java_org_godotengine_godot_GodotLib_gyroscope(JNIEnv *env } JNIEXPORT void JNICALL Java_org_godotengine_godot_GodotLib_focusin(JNIEnv *env, jclass clazz) { - if (step.get() <= 0) { + if (step.get() <= STEP_SETUP) { return; } @@ -427,7 +457,7 @@ JNIEXPORT void JNICALL Java_org_godotengine_godot_GodotLib_focusin(JNIEnv *env, } JNIEXPORT void JNICALL Java_org_godotengine_godot_GodotLib_focusout(JNIEnv *env, jclass clazz) { - if (step.get() <= 0) { + if (step.get() <= STEP_SETUP) { return; } @@ -516,7 +546,7 @@ JNIEXPORT void JNICALL Java_org_godotengine_godot_GodotLib_requestPermissionResu } JNIEXPORT void JNICALL Java_org_godotengine_godot_GodotLib_onRendererResumed(JNIEnv *env, jclass clazz) { - if (step.get() <= 0) { + if (step.get() <= STEP_SETUP) { return; } @@ -528,7 +558,7 @@ JNIEXPORT void JNICALL Java_org_godotengine_godot_GodotLib_onRendererResumed(JNI } JNIEXPORT void JNICALL Java_org_godotengine_godot_GodotLib_onRendererPaused(JNIEnv *env, jclass clazz) { - if (step.get() <= 0) { + if (step.get() <= STEP_SETUP) { return; } @@ -536,4 +566,17 @@ JNIEXPORT void JNICALL Java_org_godotengine_godot_GodotLib_onRendererPaused(JNIE os_android->get_main_loop()->notification(MainLoop::NOTIFICATION_APPLICATION_PAUSED); } } + +JNIEXPORT jboolean JNICALL Java_org_godotengine_godot_GodotLib_shouldDispatchInputToRenderThread(JNIEnv *env, jclass clazz) { + Input *input = Input::get_singleton(); + if (input) { + return !input->is_agile_input_event_flushing(); + } + return false; +} + +JNIEXPORT jstring JNICALL Java_org_godotengine_godot_GodotLib_getProjectResourceDir(JNIEnv *env, jclass clazz) { + const String resource_dir = OS::get_singleton()->get_resource_dir(); + return env->NewStringUTF(resource_dir.utf8().get_data()); +} } diff --git a/platform/android/java_godot_lib_jni.h b/platform/android/java_godot_lib_jni.h index f32ffc291a..2165ce264b 100644 --- a/platform/android/java_godot_lib_jni.h +++ b/platform/android/java_godot_lib_jni.h @@ -69,6 +69,8 @@ JNIEXPORT void JNICALL Java_org_godotengine_godot_GodotLib_requestPermissionResu JNIEXPORT void JNICALL Java_org_godotengine_godot_GodotLib_onNightModeChanged(JNIEnv *env, jclass clazz); JNIEXPORT void JNICALL Java_org_godotengine_godot_GodotLib_onRendererResumed(JNIEnv *env, jclass clazz); JNIEXPORT void JNICALL Java_org_godotengine_godot_GodotLib_onRendererPaused(JNIEnv *env, jclass clazz); +JNIEXPORT jboolean JNICALL Java_org_godotengine_godot_GodotLib_shouldDispatchInputToRenderThread(JNIEnv *env, jclass clazz); +JNIEXPORT jstring JNICALL Java_org_godotengine_godot_GodotLib_getProjectResourceDir(JNIEnv *env, jclass clazz); } #endif // JAVA_GODOT_LIB_JNI_H diff --git a/platform/android/java_godot_wrapper.cpp b/platform/android/java_godot_wrapper.cpp index 6e7f5ef5a1..f1759af54a 100644 --- a/platform/android/java_godot_wrapper.cpp +++ b/platform/android/java_godot_wrapper.cpp @@ -76,6 +76,7 @@ GodotJavaWrapper::GodotJavaWrapper(JNIEnv *p_env, jobject p_activity, jobject p_ _get_input_fallback_mapping = p_env->GetMethodID(godot_class, "getInputFallbackMapping", "()Ljava/lang/String;"); _on_godot_setup_completed = p_env->GetMethodID(godot_class, "onGodotSetupCompleted", "()V"); _on_godot_main_loop_started = p_env->GetMethodID(godot_class, "onGodotMainLoopStarted", "()V"); + _on_godot_terminating = p_env->GetMethodID(godot_class, "onGodotTerminating", "()V"); _create_new_godot_instance = p_env->GetMethodID(godot_class, "createNewGodotInstance", "([Ljava/lang/String;)I"); _get_render_view = p_env->GetMethodID(godot_class, "getRenderView", "()Lorg/godotengine/godot/GodotRenderView;"); _begin_benchmark_measure = p_env->GetMethodID(godot_class, "nativeBeginBenchmarkMeasure", "(Ljava/lang/String;Ljava/lang/String;)V"); @@ -83,6 +84,8 @@ GodotJavaWrapper::GodotJavaWrapper(JNIEnv *p_env, jobject p_activity, jobject p_ _dump_benchmark = p_env->GetMethodID(godot_class, "nativeDumpBenchmark", "(Ljava/lang/String;)V"); _get_gdextension_list_config_file = p_env->GetMethodID(godot_class, "getGDExtensionConfigFiles", "()[Ljava/lang/String;"); _has_feature = p_env->GetMethodID(godot_class, "hasFeature", "(Ljava/lang/String;)Z"); + _sign_apk = p_env->GetMethodID(godot_class, "nativeSignApk", "(Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)I"); + _verify_apk = p_env->GetMethodID(godot_class, "nativeVerifyApk", "(Ljava/lang/String;)I"); } GodotJavaWrapper::~GodotJavaWrapper() { @@ -136,6 +139,16 @@ void GodotJavaWrapper::on_godot_main_loop_started(JNIEnv *p_env) { } } +void GodotJavaWrapper::on_godot_terminating(JNIEnv *p_env) { + if (_on_godot_terminating) { + if (p_env == nullptr) { + p_env = get_jni_env(); + } + ERR_FAIL_NULL(p_env); + p_env->CallVoidMethod(godot_instance, _on_godot_terminating); + } +} + void GodotJavaWrapper::restart(JNIEnv *p_env) { if (_restart) { if (p_env == nullptr) { @@ -202,25 +215,27 @@ bool GodotJavaWrapper::has_get_clipboard() { } String GodotJavaWrapper::get_clipboard() { + String clipboard; if (_get_clipboard) { JNIEnv *env = get_jni_env(); ERR_FAIL_NULL_V(env, String()); jstring s = (jstring)env->CallObjectMethod(godot_instance, _get_clipboard); - return jstring_to_string(s, env); - } else { - return String(); + clipboard = jstring_to_string(s, env); + env->DeleteLocalRef(s); } + return clipboard; } String GodotJavaWrapper::get_input_fallback_mapping() { + String input_fallback_mapping; if (_get_input_fallback_mapping) { JNIEnv *env = get_jni_env(); ERR_FAIL_NULL_V(env, String()); jstring fallback_mapping = (jstring)env->CallObjectMethod(godot_instance, _get_input_fallback_mapping); - return jstring_to_string(fallback_mapping, env); - } else { - return String(); + input_fallback_mapping = jstring_to_string(fallback_mapping, env); + env->DeleteLocalRef(fallback_mapping); } + return input_fallback_mapping; } bool GodotJavaWrapper::has_set_clipboard() { @@ -313,14 +328,15 @@ Vector<String> GodotJavaWrapper::get_gdextension_list_config_file() const { } String GodotJavaWrapper::get_ca_certificates() const { + String ca_certificates; if (_get_ca_certificates) { JNIEnv *env = get_jni_env(); ERR_FAIL_NULL_V(env, String()); jstring s = (jstring)env->CallObjectMethod(godot_instance, _get_ca_certificates); - return jstring_to_string(s, env); - } else { - return String(); + ca_certificates = jstring_to_string(s, env); + env->DeleteLocalRef(s); } + return ca_certificates; } void GodotJavaWrapper::init_input_devices() { @@ -410,3 +426,42 @@ bool GodotJavaWrapper::has_feature(const String &p_feature) const { return false; } } + +Error GodotJavaWrapper::sign_apk(const String &p_input_path, const String &p_output_path, const String &p_keystore_path, const String &p_keystore_user, const String &p_keystore_password) { + if (_sign_apk) { + JNIEnv *env = get_jni_env(); + ERR_FAIL_NULL_V(env, ERR_UNCONFIGURED); + + jstring j_input_path = env->NewStringUTF(p_input_path.utf8().get_data()); + jstring j_output_path = env->NewStringUTF(p_output_path.utf8().get_data()); + jstring j_keystore_path = env->NewStringUTF(p_keystore_path.utf8().get_data()); + jstring j_keystore_user = env->NewStringUTF(p_keystore_user.utf8().get_data()); + jstring j_keystore_password = env->NewStringUTF(p_keystore_password.utf8().get_data()); + + int result = env->CallIntMethod(godot_instance, _sign_apk, j_input_path, j_output_path, j_keystore_path, j_keystore_user, j_keystore_password); + + env->DeleteLocalRef(j_input_path); + env->DeleteLocalRef(j_output_path); + env->DeleteLocalRef(j_keystore_path); + env->DeleteLocalRef(j_keystore_user); + env->DeleteLocalRef(j_keystore_password); + + return static_cast<Error>(result); + } else { + return ERR_UNCONFIGURED; + } +} + +Error GodotJavaWrapper::verify_apk(const String &p_apk_path) { + if (_verify_apk) { + JNIEnv *env = get_jni_env(); + ERR_FAIL_NULL_V(env, ERR_UNCONFIGURED); + + jstring j_apk_path = env->NewStringUTF(p_apk_path.utf8().get_data()); + int result = env->CallIntMethod(godot_instance, _verify_apk, j_apk_path); + env->DeleteLocalRef(j_apk_path); + return static_cast<Error>(result); + } else { + return ERR_UNCONFIGURED; + } +} diff --git a/platform/android/java_godot_wrapper.h b/platform/android/java_godot_wrapper.h index e86391d4e3..6b66565981 100644 --- a/platform/android/java_godot_wrapper.h +++ b/platform/android/java_godot_wrapper.h @@ -68,12 +68,15 @@ private: jmethodID _get_input_fallback_mapping = nullptr; jmethodID _on_godot_setup_completed = nullptr; jmethodID _on_godot_main_loop_started = nullptr; + jmethodID _on_godot_terminating = nullptr; jmethodID _create_new_godot_instance = nullptr; jmethodID _get_render_view = nullptr; jmethodID _begin_benchmark_measure = nullptr; jmethodID _end_benchmark_measure = nullptr; jmethodID _dump_benchmark = nullptr; jmethodID _has_feature = nullptr; + jmethodID _sign_apk = nullptr; + jmethodID _verify_apk = nullptr; public: GodotJavaWrapper(JNIEnv *p_env, jobject p_activity, jobject p_godot_instance); @@ -85,6 +88,7 @@ public: void on_godot_setup_completed(JNIEnv *p_env = nullptr); void on_godot_main_loop_started(JNIEnv *p_env = nullptr); + void on_godot_terminating(JNIEnv *p_env = nullptr); void restart(JNIEnv *p_env = nullptr); bool force_quit(JNIEnv *p_env = nullptr, int p_instance_id = 0); void set_keep_screen_on(bool p_enabled); @@ -114,6 +118,10 @@ public: // Return true if the given feature is supported. bool has_feature(const String &p_feature) const; + + // Sign and verify apks + Error sign_apk(const String &p_input_path, const String &p_output_path, const String &p_keystore_path, const String &p_keystore_user, const String &p_keystore_password); + Error verify_apk(const String &p_apk_path); }; #endif // JAVA_GODOT_WRAPPER_H diff --git a/platform/android/os_android.cpp b/platform/android/os_android.cpp index 764959eef3..7b0d3a29e9 100644 --- a/platform/android/os_android.cpp +++ b/platform/android/os_android.cpp @@ -775,6 +775,16 @@ void OS_Android::benchmark_dump() { #endif } +#ifdef TOOLS_ENABLED +Error OS_Android::sign_apk(const String &p_input_path, const String &p_output_path, const String &p_keystore_path, const String &p_keystore_user, const String &p_keystore_password) { + return godot_java->sign_apk(p_input_path, p_output_path, p_keystore_path, p_keystore_user, p_keystore_password); +} + +Error OS_Android::verify_apk(const String &p_apk_path) { + return godot_java->verify_apk(p_apk_path); +} +#endif + bool OS_Android::_check_internal_feature_support(const String &p_feature) { if (p_feature == "macos" || p_feature == "web_ios" || p_feature == "web_macos" || p_feature == "windows") { return false; diff --git a/platform/android/os_android.h b/platform/android/os_android.h index b150ef4f61..fb3cdf0d4c 100644 --- a/platform/android/os_android.h +++ b/platform/android/os_android.h @@ -91,6 +91,11 @@ public: static const int DEFAULT_WINDOW_WIDTH = 800; static const int DEFAULT_WINDOW_HEIGHT = 600; +#ifdef TOOLS_ENABLED + Error sign_apk(const String &p_input_path, const String &p_output_path, const String &p_keystore_path, const String &p_keystore_user, const String &p_keystore_password); + Error verify_apk(const String &p_apk_path); +#endif + virtual void initialize_core() override; virtual void initialize() override; diff --git a/platform/android/rendering_context_driver_vulkan_android.cpp b/platform/android/rendering_context_driver_vulkan_android.cpp index 9232126b04..a306a121f8 100644 --- a/platform/android/rendering_context_driver_vulkan_android.cpp +++ b/platform/android/rendering_context_driver_vulkan_android.cpp @@ -50,7 +50,7 @@ RenderingContextDriver::SurfaceID RenderingContextDriverVulkanAndroid::surface_c create_info.window = wpd->window; VkSurfaceKHR vk_surface = VK_NULL_HANDLE; - VkResult err = vkCreateAndroidSurfaceKHR(instance_get(), &create_info, nullptr, &vk_surface); + VkResult err = vkCreateAndroidSurfaceKHR(instance_get(), &create_info, get_allocation_callbacks(VK_OBJECT_TYPE_SURFACE_KHR), &vk_surface); ERR_FAIL_COND_V(err != VK_SUCCESS, SurfaceID()); Surface *surface = memnew(Surface); diff --git a/platform/ios/detect.py b/platform/ios/detect.py index 53b367a0a7..989a7f21f3 100644 --- a/platform/ios/detect.py +++ b/platform/ios/detect.py @@ -51,7 +51,8 @@ def get_flags(): "arch": "arm64", "target": "template_debug", "use_volk": False, - "supported": ["mono"], + "metal": True, + "supported": ["metal", "mono"], "builtin_pcre2_with_jit": False, } @@ -154,8 +155,22 @@ def configure(env: "SConsEnvironment"): env.Prepend(CPPPATH=["#platform/ios"]) env.Append(CPPDEFINES=["IOS_ENABLED", "UNIX_ENABLED", "COREAUDIO_ENABLED"]) + if env["metal"] and env["arch"] != "arm64": + # Only supported on arm64, so skip it for x86_64 builds. + env["metal"] = False + + if env["metal"]: + env.AppendUnique(CPPDEFINES=["METAL_ENABLED", "RD_ENABLED"]) + env.Prepend( + CPPPATH=[ + "$IOS_SDK_PATH/System/Library/Frameworks/Metal.framework/Headers", + "$IOS_SDK_PATH/System/Library/Frameworks/QuartzCore.framework/Headers", + ] + ) + env.Prepend(CPPPATH=["#thirdparty/spirv-cross"]) + if env["vulkan"]: - env.Append(CPPDEFINES=["VULKAN_ENABLED", "RD_ENABLED"]) + env.AppendUnique(CPPDEFINES=["VULKAN_ENABLED", "RD_ENABLED"]) if env["opengl3"]: env.Append(CPPDEFINES=["GLES3_ENABLED", "GLES_SILENCE_DEPRECATION"]) diff --git a/platform/ios/display_server_ios.h b/platform/ios/display_server_ios.h index 4dded5aa29..bbb758074d 100644 --- a/platform/ios/display_server_ios.h +++ b/platform/ios/display_server_ios.h @@ -47,6 +47,10 @@ #include <vulkan/vulkan.h> #endif #endif // VULKAN_ENABLED + +#if defined(METAL_ENABLED) +#include "drivers/metal/rendering_context_driver_metal.h" +#endif // METAL_ENABLED #endif // RD_ENABLED #if defined(GLES3_ENABLED) diff --git a/platform/ios/display_server_ios.mm b/platform/ios/display_server_ios.mm index 802fbefc0d..5a027e0196 100644 --- a/platform/ios/display_server_ios.mm +++ b/platform/ios/display_server_ios.mm @@ -73,6 +73,13 @@ DisplayServerIOS::DisplayServerIOS(const String &p_rendering_driver, WindowMode #ifdef VULKAN_ENABLED RenderingContextDriverVulkanIOS::WindowPlatformData vulkan; #endif +#ifdef METAL_ENABLED +#pragma clang diagnostic push +#pragma clang diagnostic ignored "-Wunguarded-availability" + // Eliminate "RenderingContextDriverMetal is only available on iOS 14.0 or newer". + RenderingContextDriverMetal::WindowPlatformData metal; +#pragma clang diagnostic pop +#endif } wpd; #if defined(VULKAN_ENABLED) @@ -85,7 +92,19 @@ DisplayServerIOS::DisplayServerIOS(const String &p_rendering_driver, WindowMode rendering_context = memnew(RenderingContextDriverVulkanIOS); } #endif - +#ifdef METAL_ENABLED + if (rendering_driver == "metal") { + if (@available(iOS 14.0, *)) { + layer = [AppDelegate.viewController.godotView initializeRenderingForDriver:@"metal"]; + wpd.metal.layer = (CAMetalLayer *)layer; + rendering_context = memnew(RenderingContextDriverMetal); + } else { + OS::get_singleton()->alert("Metal is only supported on iOS 14.0 and later."); + r_error = ERR_UNAVAILABLE; + return; + } + } +#endif if (rendering_context) { if (rendering_context->initialize() != OK) { ERR_PRINT(vformat("Failed to initialize %s context", rendering_driver)); @@ -172,6 +191,11 @@ Vector<String> DisplayServerIOS::get_rendering_drivers_func() { #if defined(VULKAN_ENABLED) drivers.push_back("vulkan"); #endif +#if defined(METAL_ENABLED) + if (@available(ios 14.0, *)) { + drivers.push_back("metal"); + } +#endif #if defined(GLES3_ENABLED) drivers.push_back("opengl3"); #endif diff --git a/platform/ios/doc_classes/EditorExportPlatformIOS.xml b/platform/ios/doc_classes/EditorExportPlatformIOS.xml index 87994ef22b..1d4a944dc4 100644 --- a/platform/ios/doc_classes/EditorExportPlatformIOS.xml +++ b/platform/ios/doc_classes/EditorExportPlatformIOS.xml @@ -151,16 +151,16 @@ Indicates whether your app uses advertising data for tracking. </member> <member name="privacy/collected_data/audio_data/collected" type="bool" setter="" getter=""> - Indicates whether your app collects audio data data. + Indicates whether your app collects audio data. </member> <member name="privacy/collected_data/audio_data/collection_purposes" type="int" setter="" getter=""> The reasons your app collects audio data. See [url=https://developer.apple.com/documentation/bundleresources/privacy_manifest_files/describing_data_use_in_privacy_manifests]Describing data use in privacy manifests[/url]. </member> <member name="privacy/collected_data/audio_data/linked_to_user" type="bool" setter="" getter=""> - Indicates whether your app links audio data data to the user's identity. + Indicates whether your app links audio data to the user's identity. </member> <member name="privacy/collected_data/audio_data/used_for_tracking" type="bool" setter="" getter=""> - Indicates whether your app uses audio data data for tracking. + Indicates whether your app uses audio data for tracking. </member> <member name="privacy/collected_data/browsing_history/collected" type="bool" setter="" getter=""> Indicates whether your app collects browsing history. diff --git a/platform/ios/export/export_plugin.cpp b/platform/ios/export/export_plugin.cpp index 490f73a36d..e4b5392c4e 100644 --- a/platform/ios/export/export_plugin.cpp +++ b/platform/ios/export/export_plugin.cpp @@ -282,6 +282,7 @@ void EditorExportPlatformIOS::get_export_options(List<ExportOption> *r_options) r_options->push_back(ExportOption(PropertyInfo(Variant::STRING, "application/short_version", PROPERTY_HINT_PLACEHOLDER_TEXT, "Leave empty to use project version"), "")); r_options->push_back(ExportOption(PropertyInfo(Variant::STRING, "application/version", PROPERTY_HINT_PLACEHOLDER_TEXT, "Leave empty to use project version"), "")); + // TODO(sgc): set to iOS 14.0 for Metal r_options->push_back(ExportOption(PropertyInfo(Variant::STRING, "application/min_ios_version"), "12.0")); r_options->push_back(ExportOption(PropertyInfo(Variant::STRING, "application/additional_plist_content", PROPERTY_HINT_MULTILINE_TEXT), "")); @@ -1628,7 +1629,7 @@ Error EditorExportPlatformIOS::_copy_asset(const Ref<EditorExportPreset> &p_pres asset_path = asset_path.path_join(framework_name); destination_dir = p_out_dir.path_join(asset_path); - destination = destination_dir.path_join(file_name); + destination = destination_dir; // Convert to framework and copy. Error err = _convert_to_framework(p_asset, destination, p_preset->get("application/bundle_identifier")); @@ -2656,6 +2657,13 @@ bool EditorExportPlatformIOS::has_valid_export_configuration(const Ref<EditorExp } } + if (GLOBAL_GET("rendering/rendering_device/driver.ios") == "metal") { + float version = p_preset->get("application/min_ios_version").operator String().to_float(); + if (version < 14.0) { + err += TTR("Metal renderer require iOS 14+.") + "\n"; + } + } + if (!err.is_empty()) { r_error = err; } @@ -2971,6 +2979,7 @@ void EditorExportPlatformIOS::_update_preset_status() { } else { has_runnable_preset.clear(); } + devices_changed.set(); } #endif diff --git a/platform/ios/godot_app_delegate.h b/platform/ios/godot_app_delegate.h index a9bfcbb0b2..85dc6bb390 100644 --- a/platform/ios/godot_app_delegate.h +++ b/platform/ios/godot_app_delegate.h @@ -32,7 +32,7 @@ typedef NSObject<UIApplicationDelegate> ApplicationDelegateService; -@interface GodotApplicalitionDelegate : NSObject <UIApplicationDelegate> +@interface GodotApplicationDelegate : NSObject <UIApplicationDelegate> @property(class, readonly, strong) NSArray<ApplicationDelegateService *> *services; diff --git a/platform/ios/godot_app_delegate.m b/platform/ios/godot_app_delegate.m index 74e8705bc3..53e53cd0c6 100644 --- a/platform/ios/godot_app_delegate.m +++ b/platform/ios/godot_app_delegate.m @@ -32,11 +32,11 @@ #import "app_delegate.h" -@interface GodotApplicalitionDelegate () +@interface GodotApplicationDelegate () @end -@implementation GodotApplicalitionDelegate +@implementation GodotApplicationDelegate static NSMutableArray<ApplicationDelegateService *> *services = nil; diff --git a/platform/ios/godot_view.mm b/platform/ios/godot_view.mm index 1dddc9306e..552c4c262c 100644 --- a/platform/ios/godot_view.mm +++ b/platform/ios/godot_view.mm @@ -71,7 +71,7 @@ static const float earth_gravity = 9.80665; CALayer<DisplayLayer> *layer; - if ([driverName isEqualToString:@"vulkan"]) { + if ([driverName isEqualToString:@"vulkan"] || [driverName isEqualToString:@"metal"]) { #if defined(TARGET_OS_SIMULATOR) && TARGET_OS_SIMULATOR if (@available(iOS 13, *)) { layer = [GodotMetalLayer layer]; @@ -441,6 +441,9 @@ static const float earth_gravity = 9.80665; UIInterfaceOrientation interfaceOrientation = UIInterfaceOrientationUnknown; +#if __IPHONE_OS_VERSION_MAX_ALLOWED < 140000 + interfaceOrientation = [[UIApplication sharedApplication] statusBarOrientation]; +#else if (@available(iOS 13, *)) { interfaceOrientation = [UIApplication sharedApplication].delegate.window.windowScene.interfaceOrientation; #if !defined(TARGET_OS_SIMULATOR) || !TARGET_OS_SIMULATOR @@ -448,6 +451,7 @@ static const float earth_gravity = 9.80665; interfaceOrientation = [[UIApplication sharedApplication] statusBarOrientation]; #endif } +#endif switch (interfaceOrientation) { case UIInterfaceOrientationLandscapeLeft: { diff --git a/platform/ios/keyboard_input_view.mm b/platform/ios/keyboard_input_view.mm index 8b614662b7..4067701a41 100644 --- a/platform/ios/keyboard_input_view.mm +++ b/platform/ios/keyboard_input_view.mm @@ -149,23 +149,18 @@ return; } + NSString *substringToDelete = nil; if (self.previousSelectedRange.length == 0) { - // We are deleting all text before cursor if no range was selected. - // This way any inserted or changed text will be updated. - NSString *substringToDelete = [self.previousText substringToIndex:self.previousSelectedRange.location]; - [self deleteText:substringToDelete.length]; + // Get previous text to delete. + substringToDelete = [self.previousText substringToIndex:self.previousSelectedRange.location]; } else { - // If text was previously selected - // we are sending only one `backspace`. - // It will remove all text from text input. + // If text was previously selected we are sending only one `backspace`. It will remove all text from text input. [self deleteText:1]; } - NSString *substringToEnter; - + NSString *substringToEnter = nil; if (self.selectedRange.length == 0) { - // If previous cursor had a selection - // we have to calculate an inserted text. + // If previous cursor had a selection we have to calculate an inserted text. if (self.previousSelectedRange.length != 0) { NSInteger rangeEnd = self.selectedRange.location + self.selectedRange.length; NSInteger rangeStart = MIN(self.previousSelectedRange.location, self.selectedRange.location); @@ -187,7 +182,18 @@ substringToEnter = [self.text substringWithRange:self.selectedRange]; } - [self enterText:substringToEnter]; + NSInteger skip = 0; + if (substringToDelete != nil) { + for (NSInteger i = 0; i < MIN([substringToDelete length], [substringToEnter length]); i++) { + if ([substringToDelete characterAtIndex:i] == [substringToEnter characterAtIndex:i]) { + skip++; + } else { + break; + } + } + [self deleteText:[substringToDelete length] - skip]; // Delete changed part of previous text. + } + [self enterText:[substringToEnter substringFromIndex:skip]]; // Enter changed part of new text. self.previousText = self.text; self.previousSelectedRange = self.selectedRange; diff --git a/platform/ios/main.m b/platform/ios/main.m index 33b1034d98..89a00c9ae9 100644 --- a/platform/ios/main.m +++ b/platform/ios/main.m @@ -46,7 +46,7 @@ int main(int argc, char *argv[]) { gargv = argv; @autoreleasepool { - NSString *className = NSStringFromClass([GodotApplicalitionDelegate class]); + NSString *className = NSStringFromClass([GodotApplicationDelegate class]); UIApplicationMain(argc, argv, nil, className); } return 0; diff --git a/platform/ios/rendering_context_driver_vulkan_ios.mm b/platform/ios/rendering_context_driver_vulkan_ios.mm index 6a6af1bc41..8747bfd76a 100644 --- a/platform/ios/rendering_context_driver_vulkan_ios.mm +++ b/platform/ios/rendering_context_driver_vulkan_ios.mm @@ -50,7 +50,7 @@ RenderingContextDriver::SurfaceID RenderingContextDriverVulkanIOS::surface_creat create_info.pLayer = *wpd->layer_ptr; VkSurfaceKHR vk_surface = VK_NULL_HANDLE; - VkResult err = vkCreateMetalSurfaceEXT(instance_get(), &create_info, nullptr, &vk_surface); + VkResult err = vkCreateMetalSurfaceEXT(instance_get(), &create_info, get_allocation_callbacks(VK_OBJECT_TYPE_SURFACE_KHR), &vk_surface); ERR_FAIL_COND_V(err != VK_SUCCESS, SurfaceID()); Surface *surface = memnew(Surface); diff --git a/platform/linuxbsd/detect.py b/platform/linuxbsd/detect.py index 303a88ab26..d1de760f34 100644 --- a/platform/linuxbsd/detect.py +++ b/platform/linuxbsd/detect.py @@ -179,6 +179,8 @@ def configure(env: "SConsEnvironment"): env.Append(CCFLAGS=["-fsanitize-recover=memory"]) env.Append(LINKFLAGS=["-fsanitize=memory"]) + env.Append(CCFLAGS=["-ffp-contract=off"]) + # LTO if env["lto"] == "auto": # Full LTO for production. diff --git a/platform/linuxbsd/export/export_plugin.cpp b/platform/linuxbsd/export/export_plugin.cpp index 936adddda3..0032b898d2 100644 --- a/platform/linuxbsd/export/export_plugin.cpp +++ b/platform/linuxbsd/export/export_plugin.cpp @@ -61,6 +61,20 @@ Error EditorExportPlatformLinuxBSD::_export_debug_script(const Ref<EditorExportP } Error EditorExportPlatformLinuxBSD::export_project(const Ref<EditorExportPreset> &p_preset, bool p_debug, const String &p_path, int p_flags) { + String custom_debug = p_preset->get("custom_template/debug"); + String custom_release = p_preset->get("custom_template/release"); + String arch = p_preset->get("binary_format/architecture"); + + String template_path = p_debug ? custom_debug : custom_release; + template_path = template_path.strip_edges(); + if (!template_path.is_empty()) { + String exe_arch = _get_exe_arch(template_path); + if (arch != exe_arch) { + add_message(EXPORT_MESSAGE_ERROR, TTR("Prepare Templates"), vformat(TTR("Mismatching custom export template executable architecture, found \"%s\", expected \"%s\"."), exe_arch, arch)); + return ERR_CANT_CREATE; + } + } + bool export_as_zip = p_path.ends_with("zip"); String pkg_name; @@ -205,8 +219,76 @@ bool EditorExportPlatformLinuxBSD::is_executable(const String &p_path) const { return is_elf(p_path) || is_shebang(p_path); } +bool EditorExportPlatformLinuxBSD::has_valid_export_configuration(const Ref<EditorExportPreset> &p_preset, String &r_error, bool &r_missing_templates, bool p_debug) const { + String err; + bool valid = EditorExportPlatformPC::has_valid_export_configuration(p_preset, err, r_missing_templates, p_debug); + + String custom_debug = p_preset->get("custom_template/debug").operator String().strip_edges(); + String custom_release = p_preset->get("custom_template/release").operator String().strip_edges(); + String arch = p_preset->get("binary_format/architecture"); + + if (!custom_debug.is_empty() && FileAccess::exists(custom_debug)) { + String exe_arch = _get_exe_arch(custom_debug); + if (arch != exe_arch) { + err += vformat(TTR("Mismatching custom debug export template executable architecture: found \"%s\", expected \"%s\"."), exe_arch, arch) + "\n"; + } + } + if (!custom_release.is_empty() && FileAccess::exists(custom_release)) { + String exe_arch = _get_exe_arch(custom_release); + if (arch != exe_arch) { + err += vformat(TTR("Mismatching custom release export template executable architecture: found \"%s\", expected \"%s\"."), exe_arch, arch) + "\n"; + } + } + + if (!err.is_empty()) { + r_error = err; + } + + return valid; +} + +String EditorExportPlatformLinuxBSD::_get_exe_arch(const String &p_path) const { + Ref<FileAccess> f = FileAccess::open(p_path, FileAccess::READ); + if (f.is_null()) { + return "invalid"; + } + + // Read and check ELF magic number. + { + uint32_t magic = f->get_32(); + if (magic != 0x464c457f) { // 0x7F + "ELF" + return "invalid"; + } + } + + // Process header. + int64_t header_pos = f->get_position(); + f->seek(header_pos + 14); + uint16_t machine = f->get_16(); + f->close(); + + switch (machine) { + case 0x0003: + return "x86_32"; + case 0x003e: + return "x86_64"; + case 0x0014: + return "ppc32"; + case 0x0015: + return "ppc64"; + case 0x0028: + return "arm32"; + case 0x00b7: + return "arm64"; + case 0x00f3: + return "rv64"; + default: + return "unknown"; + } +} + Error EditorExportPlatformLinuxBSD::fixup_embedded_pck(const String &p_path, int64_t p_embedded_start, int64_t p_embedded_size) { - // Patch the header of the "pck" section in the ELF file so that it corresponds to the embedded data + // Patch the header of the "pck" section in the ELF file so that it corresponds to the embedded data. Ref<FileAccess> f = FileAccess::open(p_path, FileAccess::READ_WRITE); if (f.is_null()) { @@ -214,7 +296,7 @@ Error EditorExportPlatformLinuxBSD::fixup_embedded_pck(const String &p_path, int return ERR_CANT_OPEN; } - // Read and check ELF magic number + // Read and check ELF magic number. { uint32_t magic = f->get_32(); if (magic != 0x464c457f) { // 0x7F + "ELF" @@ -223,7 +305,7 @@ Error EditorExportPlatformLinuxBSD::fixup_embedded_pck(const String &p_path, int } } - // Read program architecture bits from class field + // Read program architecture bits from class field. int bits = f->get_8() * 32; @@ -231,7 +313,7 @@ Error EditorExportPlatformLinuxBSD::fixup_embedded_pck(const String &p_path, int add_message(EXPORT_MESSAGE_ERROR, TTR("PCK Embedding"), TTR("32-bit executables cannot have embedded data >= 4 GiB.")); } - // Get info about the section header table + // Get info about the section header table. int64_t section_table_pos; int64_t section_header_size; @@ -249,13 +331,13 @@ Error EditorExportPlatformLinuxBSD::fixup_embedded_pck(const String &p_path, int int num_sections = f->get_16(); int string_section_idx = f->get_16(); - // Load the strings table + // Load the strings table. uint8_t *strings; { - // Jump to the strings section header + // Jump to the strings section header. f->seek(section_table_pos + string_section_idx * section_header_size); - // Read strings data size and offset + // Read strings data size and offset. int64_t string_data_pos; int64_t string_data_size; if (bits == 32) { @@ -268,7 +350,7 @@ Error EditorExportPlatformLinuxBSD::fixup_embedded_pck(const String &p_path, int string_data_size = f->get_64(); } - // Read strings data + // Read strings data. f->seek(string_data_pos); strings = (uint8_t *)memalloc(string_data_size); if (!strings) { @@ -277,7 +359,7 @@ Error EditorExportPlatformLinuxBSD::fixup_embedded_pck(const String &p_path, int f->get_buffer(strings, string_data_size); } - // Search for the "pck" section + // Search for the "pck" section. bool found = false; for (int i = 0; i < num_sections; ++i) { diff --git a/platform/linuxbsd/export/export_plugin.h b/platform/linuxbsd/export/export_plugin.h index 21bd81ed2f..bbc55b82ce 100644 --- a/platform/linuxbsd/export/export_plugin.h +++ b/platform/linuxbsd/export/export_plugin.h @@ -69,11 +69,13 @@ class EditorExportPlatformLinuxBSD : public EditorExportPlatformPC { bool is_shebang(const String &p_path) const; Error _export_debug_script(const Ref<EditorExportPreset> &p_preset, const String &p_app_name, const String &p_pkg_name, const String &p_path); + String _get_exe_arch(const String &p_path) const; public: virtual void get_export_options(List<ExportOption> *r_options) const override; virtual List<String> get_binary_extensions(const Ref<EditorExportPreset> &p_preset) const override; virtual bool get_export_option_visibility(const EditorExportPreset *p_preset, const String &p_option) const override; + virtual bool has_valid_export_configuration(const Ref<EditorExportPreset> &p_preset, String &r_error, bool &r_missing_templates, bool p_debug = false) const override; virtual Error export_project(const Ref<EditorExportPreset> &p_preset, bool p_debug, const String &p_path, int p_flags = 0) override; virtual String get_template_file_name(const String &p_target, const String &p_arch) const override; virtual Error fixup_embedded_pck(const String &p_path, int64_t p_embedded_start, int64_t p_embedded_size) override; diff --git a/platform/linuxbsd/joypad_linux.cpp b/platform/linuxbsd/joypad_linux.cpp index 3534c1afee..a67428b9a4 100644 --- a/platform/linuxbsd/joypad_linux.cpp +++ b/platform/linuxbsd/joypad_linux.cpp @@ -374,6 +374,12 @@ void JoypadLinux::open_joypad(const char *p_path) { name = namebuf; } + for (const String &word : name.to_lower().split(" ")) { + if (banned_words.has(word)) { + return; + } + } + if (ioctl(fd, EVIOCGID, &inpid) < 0) { close(fd); return; diff --git a/platform/linuxbsd/joypad_linux.h b/platform/linuxbsd/joypad_linux.h index 26a9908d4e..bf24d8e5a5 100644 --- a/platform/linuxbsd/joypad_linux.h +++ b/platform/linuxbsd/joypad_linux.h @@ -94,6 +94,21 @@ private: Vector<String> attached_devices; + // List of lowercase words that will prevent the controller from being recognized if its name matches. + // This is done to prevent trackpads, graphics tablets and motherboard LED controllers from being + // recognized as controllers (and taking up controller ID slots as a result). + // Only whole words are matched within the controller name string. The match is case-insensitive. + const Vector<String> banned_words = { + "touchpad", // Matches e.g. "SynPS/2 Synaptics TouchPad", "Sony Interactive Entertainment DualSense Wireless Controller Touchpad" + "trackpad", + "clickpad", + "keyboard", // Matches e.g. "PG-90215 Keyboard", "Usb Keyboard Usb Keyboard Consumer Control" + "mouse", // Matches e.g. "Mouse passthrough" + "pen", // Matches e.g. "Wacom One by Wacom S Pen" + "finger", // Matches e.g. "Wacom HID 495F Finger" + "led", // Matches e.g. "ASRock LED Controller" + }; + static void monitor_joypads_thread_func(void *p_user); void monitor_joypads_thread_run(); diff --git a/platform/linuxbsd/wayland/display_server_wayland.cpp b/platform/linuxbsd/wayland/display_server_wayland.cpp index adc9beed66..93096fcdcc 100644 --- a/platform/linuxbsd/wayland/display_server_wayland.cpp +++ b/platform/linuxbsd/wayland/display_server_wayland.cpp @@ -1238,7 +1238,7 @@ void DisplayServerWayland::process_events() { } else { try_suspend(); } - } else if (wayland_thread.get_reset_frame()) { + } else if (!wayland_thread.is_suspended() || wayland_thread.get_reset_frame()) { // At last, a sign of life! We're no longer suspended. suspended = false; } diff --git a/platform/linuxbsd/wayland/rendering_context_driver_vulkan_wayland.cpp b/platform/linuxbsd/wayland/rendering_context_driver_vulkan_wayland.cpp index c874c45a8a..0417ba95eb 100644 --- a/platform/linuxbsd/wayland/rendering_context_driver_vulkan_wayland.cpp +++ b/platform/linuxbsd/wayland/rendering_context_driver_vulkan_wayland.cpp @@ -51,7 +51,7 @@ RenderingContextDriver::SurfaceID RenderingContextDriverVulkanWayland::surface_c create_info.surface = wpd->surface; VkSurfaceKHR vk_surface = VK_NULL_HANDLE; - VkResult err = vkCreateWaylandSurfaceKHR(instance_get(), &create_info, nullptr, &vk_surface); + VkResult err = vkCreateWaylandSurfaceKHR(instance_get(), &create_info, get_allocation_callbacks(VK_OBJECT_TYPE_SURFACE_KHR), &vk_surface); ERR_FAIL_COND_V(err != VK_SUCCESS, SurfaceID()); Surface *surface = memnew(Surface); diff --git a/platform/linuxbsd/wayland/wayland_thread.cpp b/platform/linuxbsd/wayland/wayland_thread.cpp index 341cc517e3..ab13105d18 100644 --- a/platform/linuxbsd/wayland/wayland_thread.cpp +++ b/platform/linuxbsd/wayland/wayland_thread.cpp @@ -1263,23 +1263,25 @@ void WaylandThread::_wl_seat_on_capabilities(void *data, struct wl_seat *wl_seat // Pointer handling. if (capabilities & WL_SEAT_CAPABILITY_POINTER) { - ss->cursor_surface = wl_compositor_create_surface(ss->registry->wl_compositor); - wl_surface_commit(ss->cursor_surface); + if (!ss->wl_pointer) { + ss->cursor_surface = wl_compositor_create_surface(ss->registry->wl_compositor); + wl_surface_commit(ss->cursor_surface); - ss->wl_pointer = wl_seat_get_pointer(wl_seat); - wl_pointer_add_listener(ss->wl_pointer, &wl_pointer_listener, ss); + ss->wl_pointer = wl_seat_get_pointer(wl_seat); + wl_pointer_add_listener(ss->wl_pointer, &wl_pointer_listener, ss); - if (ss->registry->wp_relative_pointer_manager) { - ss->wp_relative_pointer = zwp_relative_pointer_manager_v1_get_relative_pointer(ss->registry->wp_relative_pointer_manager, ss->wl_pointer); - zwp_relative_pointer_v1_add_listener(ss->wp_relative_pointer, &wp_relative_pointer_listener, ss); - } + if (ss->registry->wp_relative_pointer_manager) { + ss->wp_relative_pointer = zwp_relative_pointer_manager_v1_get_relative_pointer(ss->registry->wp_relative_pointer_manager, ss->wl_pointer); + zwp_relative_pointer_v1_add_listener(ss->wp_relative_pointer, &wp_relative_pointer_listener, ss); + } - if (ss->registry->wp_pointer_gestures) { - ss->wp_pointer_gesture_pinch = zwp_pointer_gestures_v1_get_pinch_gesture(ss->registry->wp_pointer_gestures, ss->wl_pointer); - zwp_pointer_gesture_pinch_v1_add_listener(ss->wp_pointer_gesture_pinch, &wp_pointer_gesture_pinch_listener, ss); - } + if (ss->registry->wp_pointer_gestures) { + ss->wp_pointer_gesture_pinch = zwp_pointer_gestures_v1_get_pinch_gesture(ss->registry->wp_pointer_gestures, ss->wl_pointer); + zwp_pointer_gesture_pinch_v1_add_listener(ss->wp_pointer_gesture_pinch, &wp_pointer_gesture_pinch_listener, ss); + } - // TODO: Constrain new pointers if the global mouse mode is constrained. + // TODO: Constrain new pointers if the global mouse mode is constrained. + } } else { if (ss->cursor_frame_callback) { // Just in case. I got bitten by weird race-like conditions already. @@ -1317,11 +1319,13 @@ void WaylandThread::_wl_seat_on_capabilities(void *data, struct wl_seat *wl_seat // Keyboard handling. if (capabilities & WL_SEAT_CAPABILITY_KEYBOARD) { - ss->xkb_context = xkb_context_new(XKB_CONTEXT_NO_FLAGS); - ERR_FAIL_NULL(ss->xkb_context); + if (!ss->wl_keyboard) { + ss->xkb_context = xkb_context_new(XKB_CONTEXT_NO_FLAGS); + ERR_FAIL_NULL(ss->xkb_context); - ss->wl_keyboard = wl_seat_get_keyboard(wl_seat); - wl_keyboard_add_listener(ss->wl_keyboard, &wl_keyboard_listener, ss); + ss->wl_keyboard = wl_seat_get_keyboard(wl_seat); + wl_keyboard_add_listener(ss->wl_keyboard, &wl_keyboard_listener, ss); + } } else { if (ss->xkb_context) { xkb_context_unref(ss->xkb_context); @@ -1412,10 +1416,10 @@ void WaylandThread::_wl_pointer_on_motion(void *data, struct wl_pointer *wl_poin PointerData &pd = ss->pointer_data_buffer; // TODO: Scale only when sending the Wayland message. - pd.position.x = wl_fixed_to_int(surface_x); - pd.position.y = wl_fixed_to_int(surface_y); + pd.position.x = wl_fixed_to_double(surface_x); + pd.position.y = wl_fixed_to_double(surface_y); - pd.position = scale_vector2i(pd.position, window_state_get_scale_factor(ws)); + pd.position *= window_state_get_scale_factor(ws); pd.motion_time = time; } @@ -1528,7 +1532,7 @@ void WaylandThread::_wl_pointer_on_frame(void *data, struct wl_pointer *wl_point mm->set_position(pd.position); mm->set_global_position(pd.position); - Vector2i pos_delta = pd.position - old_pd.position; + Vector2 pos_delta = pd.position - old_pd.position; if (old_pd.relative_motion_time != pd.relative_motion_time) { uint32_t time_delta = pd.relative_motion_time - old_pd.relative_motion_time; @@ -1645,7 +1649,7 @@ void WaylandThread::_wl_pointer_on_frame(void *data, struct wl_pointer *wl_point // We have to set the last position pressed here as we can't take for // granted what the individual events might have seen due to them not having - // a garaunteed order. + // a guaranteed order. if (mb->is_pressed()) { pd.last_pressed_position = pd.position; } @@ -2047,11 +2051,21 @@ void WaylandThread::_wp_relative_pointer_on_relative_motion(void *data, struct z SeatState *ss = (SeatState *)data; ERR_FAIL_NULL(ss); + if (!ss->pointed_surface) { + // We're probably on a decoration or some other third-party thing. + return; + } + PointerData &pd = ss->pointer_data_buffer; + WindowState *ws = wl_surface_get_window_state(ss->pointed_surface); + ERR_FAIL_NULL(ws); + pd.relative_motion.x = wl_fixed_to_double(dx); pd.relative_motion.y = wl_fixed_to_double(dy); + pd.relative_motion *= window_state_get_scale_factor(ws); + pd.relative_motion_time = uptime_lo; } @@ -2244,13 +2258,11 @@ void WaylandThread::_wp_tablet_tool_on_done(void *data, struct zwp_tablet_tool_v void WaylandThread::_wp_tablet_tool_on_removed(void *data, struct zwp_tablet_tool_v2 *wp_tablet_tool_v2) { TabletToolState *ts = wp_tablet_tool_get_state(wp_tablet_tool_v2); - if (!ts) { return; } SeatState *ss = wl_seat_get_seat_state(ts->wl_seat); - if (!ss) { return; } @@ -2270,14 +2282,17 @@ void WaylandThread::_wp_tablet_tool_on_removed(void *data, struct zwp_tablet_too } void WaylandThread::_wp_tablet_tool_on_proximity_in(void *data, struct zwp_tablet_tool_v2 *wp_tablet_tool_v2, uint32_t serial, struct zwp_tablet_v2 *tablet, struct wl_surface *surface) { - TabletToolState *ts = wp_tablet_tool_get_state(wp_tablet_tool_v2); + if (!surface || !wl_proxy_is_godot((struct wl_proxy *)surface)) { + // We're probably on a decoration or something. + return; + } + TabletToolState *ts = wp_tablet_tool_get_state(wp_tablet_tool_v2); if (!ts) { return; } SeatState *ss = wl_seat_get_seat_state(ts->wl_seat); - if (!ss) { return; } @@ -2299,13 +2314,12 @@ void WaylandThread::_wp_tablet_tool_on_proximity_in(void *data, struct zwp_table void WaylandThread::_wp_tablet_tool_on_proximity_out(void *data, struct zwp_tablet_tool_v2 *wp_tablet_tool_v2) { TabletToolState *ts = wp_tablet_tool_get_state(wp_tablet_tool_v2); - - if (!ts) { + if (!ts || !ts->data_pending.proximal_surface) { + // Not our stuff, we don't care. return; } SeatState *ss = wl_seat_get_seat_state(ts->wl_seat); - if (!ss) { return; } @@ -2326,7 +2340,6 @@ void WaylandThread::_wp_tablet_tool_on_proximity_out(void *data, struct zwp_tabl void WaylandThread::_wp_tablet_tool_on_down(void *data, struct zwp_tablet_tool_v2 *wp_tablet_tool_v2, uint32_t serial) { TabletToolState *ts = wp_tablet_tool_get_state(wp_tablet_tool_v2); - if (!ts) { return; } @@ -2344,7 +2357,6 @@ void WaylandThread::_wp_tablet_tool_on_down(void *data, struct zwp_tablet_tool_v void WaylandThread::_wp_tablet_tool_on_up(void *data, struct zwp_tablet_tool_v2 *wp_tablet_tool_v2) { TabletToolState *ts = wp_tablet_tool_get_state(wp_tablet_tool_v2); - if (!ts) { return; } @@ -2360,11 +2372,15 @@ void WaylandThread::_wp_tablet_tool_on_up(void *data, struct zwp_tablet_tool_v2 void WaylandThread::_wp_tablet_tool_on_motion(void *data, struct zwp_tablet_tool_v2 *wp_tablet_tool_v2, wl_fixed_t x, wl_fixed_t y) { TabletToolState *ts = wp_tablet_tool_get_state(wp_tablet_tool_v2); - if (!ts) { return; } + if (!ts->data_pending.proximal_surface) { + // We're probably on a decoration or some other third-party thing. + return; + } + WindowState *ws = wl_surface_get_window_state(ts->data_pending.proximal_surface); ERR_FAIL_NULL(ws); @@ -2372,16 +2388,15 @@ void WaylandThread::_wp_tablet_tool_on_motion(void *data, struct zwp_tablet_tool double scale_factor = window_state_get_scale_factor(ws); - td.position.x = wl_fixed_to_int(x); - td.position.y = wl_fixed_to_int(y); - td.position = scale_vector2i(td.position, scale_factor); + td.position.x = wl_fixed_to_double(x); + td.position.y = wl_fixed_to_double(y); + td.position *= scale_factor; td.motion_time = OS::get_singleton()->get_ticks_msec(); } void WaylandThread::_wp_tablet_tool_on_pressure(void *data, struct zwp_tablet_tool_v2 *wp_tablet_tool_v2, uint32_t pressure) { TabletToolState *ts = wp_tablet_tool_get_state(wp_tablet_tool_v2); - if (!ts) { return; } @@ -2395,7 +2410,6 @@ void WaylandThread::_wp_tablet_tool_on_distance(void *data, struct zwp_tablet_to void WaylandThread::_wp_tablet_tool_on_tilt(void *data, struct zwp_tablet_tool_v2 *wp_tablet_tool_v2, wl_fixed_t tilt_x, wl_fixed_t tilt_y) { TabletToolState *ts = wp_tablet_tool_get_state(wp_tablet_tool_v2); - if (!ts) { return; } @@ -2420,7 +2434,6 @@ void WaylandThread::_wp_tablet_tool_on_wheel(void *data, struct zwp_tablet_tool_ void WaylandThread::_wp_tablet_tool_on_button(void *data, struct zwp_tablet_tool_v2 *wp_tablet_tool_v2, uint32_t serial, uint32_t button, uint32_t state) { TabletToolState *ts = wp_tablet_tool_get_state(wp_tablet_tool_v2); - if (!ts) { return; } @@ -2456,13 +2469,11 @@ void WaylandThread::_wp_tablet_tool_on_button(void *data, struct zwp_tablet_tool void WaylandThread::_wp_tablet_tool_on_frame(void *data, struct zwp_tablet_tool_v2 *wp_tablet_tool_v2, uint32_t time) { TabletToolState *ts = wp_tablet_tool_get_state(wp_tablet_tool_v2); - if (!ts) { return; } SeatState *ss = wl_seat_get_seat_state(ts->wl_seat); - if (!ss) { return; } @@ -2509,7 +2520,7 @@ void WaylandThread::_wp_tablet_tool_on_frame(void *data, struct zwp_tablet_tool_ mm->set_relative(td.position - old_td.position); mm->set_relative_screen_position(mm->get_relative()); - Vector2i pos_delta = td.position - old_td.position; + Vector2 pos_delta = td.position - old_td.position; uint32_t time_delta = td.motion_time - old_td.motion_time; mm->set_velocity((Vector2)pos_delta / time_delta); @@ -3240,6 +3251,8 @@ void WaylandThread::window_create(DisplayServer::WindowID p_window_id, int p_wid zxdg_exported_v1_add_listener(ws.xdg_exported, &xdg_exported_listener, &ws); } + wl_surface_commit(ws.wl_surface); + // Wait for the surface to be configured before continuing. wl_display_roundtrip(wl_display); } diff --git a/platform/linuxbsd/wayland/wayland_thread.h b/platform/linuxbsd/wayland/wayland_thread.h index 775ca71346..84e9bdc2dc 100644 --- a/platform/linuxbsd/wayland/wayland_thread.h +++ b/platform/linuxbsd/wayland/wayland_thread.h @@ -44,7 +44,7 @@ #include <wayland-client-core.h> #include <wayland-cursor.h> #ifdef GLES3_ENABLED -#include <wayland-egl.h> +#include <wayland-egl-core.h> #endif #include <xkbcommon/xkbcommon.h> #endif // SOWRAP_ENABLED @@ -295,7 +295,7 @@ public: }; struct PointerData { - Point2i position; + Point2 position; uint32_t motion_time = 0; // Relative motion has its own optional event and so needs its own time. @@ -305,7 +305,7 @@ public: BitField<MouseButtonMask> pressed_button_mask; MouseButton last_button_pressed = MouseButton::NONE; - Point2i last_pressed_position; + Point2 last_pressed_position; // This is needed to check for a new double click every time. bool double_click_begun = false; @@ -325,14 +325,14 @@ public: }; struct TabletToolData { - Point2i position; + Point2 position; Vector2 tilt; uint32_t pressure = 0; BitField<MouseButtonMask> pressed_button_mask; MouseButton last_button_pressed = MouseButton::NONE; - Point2i last_pressed_position; + Point2 last_pressed_position; bool double_click_begun = false; diff --git a/platform/linuxbsd/x11/display_server_x11.cpp b/platform/linuxbsd/x11/display_server_x11.cpp index edf3a40ccb..8a2f83be2d 100644 --- a/platform/linuxbsd/x11/display_server_x11.cpp +++ b/platform/linuxbsd/x11/display_server_x11.cpp @@ -1519,7 +1519,7 @@ Color DisplayServerX11::screen_get_pixel(const Point2i &p_position) const { if (image) { XColor c; c.pixel = XGetPixel(image, 0, 0); - XFree(image); + XDestroyImage(image); XQueryColor(x11_display, XDefaultColormap(x11_display, i), &c); color = Color(float(c.red) / 65535.0, float(c.green) / 65535.0, float(c.blue) / 65535.0, 1.0); break; @@ -1637,11 +1637,12 @@ Ref<Image> DisplayServerX11::screen_get_image(int p_screen) const { } } } else { - XFree(image); - ERR_FAIL_V_MSG(Ref<Image>(), vformat("XImage with RGB mask %x %x %x and depth %d is not supported.", (uint64_t)image->red_mask, (uint64_t)image->green_mask, (uint64_t)image->blue_mask, (int64_t)image->bits_per_pixel)); + String msg = vformat("XImage with RGB mask %x %x %x and depth %d is not supported.", (uint64_t)image->red_mask, (uint64_t)image->green_mask, (uint64_t)image->blue_mask, (int64_t)image->bits_per_pixel); + XDestroyImage(image); + ERR_FAIL_V_MSG(Ref<Image>(), msg); } img = Image::create_from_data(width, height, false, Image::FORMAT_RGBA8, img_data); - XFree(image); + XDestroyImage(image); } return img; @@ -1734,7 +1735,7 @@ Vector<DisplayServer::WindowID> DisplayServerX11::get_window_list() const { return ret; } -DisplayServer::WindowID DisplayServerX11::create_sub_window(WindowMode p_mode, VSyncMode p_vsync_mode, uint32_t p_flags, const Rect2i &p_rect) { +DisplayServer::WindowID DisplayServerX11::create_sub_window(WindowMode p_mode, VSyncMode p_vsync_mode, uint32_t p_flags, const Rect2i &p_rect, bool p_exclusive, WindowID p_transient_parent) { _THREAD_SAFE_METHOD_ WindowID id = _create_window(p_mode, p_vsync_mode, p_flags, p_rect); @@ -1748,6 +1749,11 @@ DisplayServer::WindowID DisplayServerX11::create_sub_window(WindowMode p_mode, V rendering_device->screen_create(id); } #endif + + if (p_transient_parent != INVALID_WINDOW_ID) { + window_set_transient(id, p_transient_parent); + } + return id; } @@ -4964,6 +4970,23 @@ void DisplayServerX11::process_events() { pos = Point2i(windows[focused_window_id].size.width / 2, windows[focused_window_id].size.height / 2); } + BitField<MouseButtonMask> last_button_state = 0; + if (event.xmotion.state & Button1Mask) { + last_button_state.set_flag(MouseButtonMask::LEFT); + } + if (event.xmotion.state & Button2Mask) { + last_button_state.set_flag(MouseButtonMask::MIDDLE); + } + if (event.xmotion.state & Button3Mask) { + last_button_state.set_flag(MouseButtonMask::RIGHT); + } + if (event.xmotion.state & Button4Mask) { + last_button_state.set_flag(MouseButtonMask::MB_XBUTTON1); + } + if (event.xmotion.state & Button5Mask) { + last_button_state.set_flag(MouseButtonMask::MB_XBUTTON2); + } + Ref<InputEventMouseMotion> mm; mm.instantiate(); @@ -4971,13 +4994,13 @@ void DisplayServerX11::process_events() { if (xi.pressure_supported) { mm->set_pressure(xi.pressure); } else { - mm->set_pressure(bool(mouse_get_button_state().has_flag(MouseButtonMask::LEFT)) ? 1.0f : 0.0f); + mm->set_pressure(bool(last_button_state.has_flag(MouseButtonMask::LEFT)) ? 1.0f : 0.0f); } mm->set_tilt(xi.tilt); mm->set_pen_inverted(xi.pen_inverted); _get_key_modifier_state(event.xmotion.state, mm); - mm->set_button_mask(mouse_get_button_state()); + mm->set_button_mask(last_button_state); mm->set_position(pos); mm->set_global_position(pos); mm->set_velocity(Input::get_singleton()->get_last_mouse_velocity()); diff --git a/platform/linuxbsd/x11/display_server_x11.h b/platform/linuxbsd/x11/display_server_x11.h index 341ba5f079..0cbfbe51ef 100644 --- a/platform/linuxbsd/x11/display_server_x11.h +++ b/platform/linuxbsd/x11/display_server_x11.h @@ -438,7 +438,7 @@ public: virtual Vector<DisplayServer::WindowID> get_window_list() const override; - virtual WindowID create_sub_window(WindowMode p_mode, VSyncMode p_vsync_mode, uint32_t p_flags, const Rect2i &p_rect = Rect2i()) override; + virtual WindowID create_sub_window(WindowMode p_mode, VSyncMode p_vsync_mode, uint32_t p_flags, const Rect2i &p_rect = Rect2i(), bool p_exclusive = false, WindowID p_transient_parent = INVALID_WINDOW_ID) override; virtual void show_window(WindowID p_id) override; virtual void delete_sub_window(WindowID p_id) override; diff --git a/platform/linuxbsd/x11/rendering_context_driver_vulkan_x11.cpp b/platform/linuxbsd/x11/rendering_context_driver_vulkan_x11.cpp index bf44062266..3f505d000c 100644 --- a/platform/linuxbsd/x11/rendering_context_driver_vulkan_x11.cpp +++ b/platform/linuxbsd/x11/rendering_context_driver_vulkan_x11.cpp @@ -51,7 +51,7 @@ RenderingContextDriver::SurfaceID RenderingContextDriverVulkanX11::surface_creat create_info.window = wpd->window; VkSurfaceKHR vk_surface = VK_NULL_HANDLE; - VkResult err = vkCreateXlibSurfaceKHR(instance_get(), &create_info, nullptr, &vk_surface); + VkResult err = vkCreateXlibSurfaceKHR(instance_get(), &create_info, get_allocation_callbacks(VK_OBJECT_TYPE_SURFACE_KHR), &vk_surface); ERR_FAIL_COND_V(err != VK_SUCCESS, SurfaceID()); Surface *surface = memnew(Surface); diff --git a/platform/macos/SCsub b/platform/macos/SCsub index c965e875c1..a10262c524 100644 --- a/platform/macos/SCsub +++ b/platform/macos/SCsub @@ -23,10 +23,10 @@ def generate_bundle(target, source, env): prefix += ".double" # Lipo editor executable. - target_bin = lipo(bin_dir + "/" + prefix, env.extra_suffix) + target_bin = lipo(bin_dir + "/" + prefix, env.extra_suffix + env.module_version_string) # Assemble .app bundle and update version info. - app_dir = Dir("#bin/" + (prefix + env.extra_suffix).replace(".", "_") + ".app").abspath + app_dir = Dir("#bin/" + (prefix + env.extra_suffix + env.module_version_string).replace(".", "_") + ".app").abspath templ = Dir("#misc/dist/macos_tools.app").abspath if os.path.exists(app_dir): shutil.rmtree(app_dir) @@ -35,6 +35,8 @@ def generate_bundle(target, source, env): os.mkdir(app_dir + "/Contents/MacOS") if target_bin != "": shutil.copy(target_bin, app_dir + "/Contents/MacOS/Godot") + if "mono" in env.module_version_string: + shutil.copytree(Dir("#bin/GodotSharp").abspath, app_dir + "/Contents/Resources/GodotSharp") version = get_build_version(False) short_version = get_build_version(True) with open(Dir("#misc/dist/macos").abspath + "/editor_info_plist.template", "rt", encoding="utf-8") as fin: @@ -76,8 +78,8 @@ def generate_bundle(target, source, env): dbg_prefix += ".double" # Lipo template executables. - rel_target_bin = lipo(bin_dir + "/" + rel_prefix, env.extra_suffix) - dbg_target_bin = lipo(bin_dir + "/" + dbg_prefix, env.extra_suffix) + rel_target_bin = lipo(bin_dir + "/" + rel_prefix, env.extra_suffix + env.module_version_string) + dbg_target_bin = lipo(bin_dir + "/" + dbg_prefix, env.extra_suffix + env.module_version_string) # Assemble .app bundle. app_dir = Dir("#bin/macos_template.app").abspath @@ -93,7 +95,7 @@ def generate_bundle(target, source, env): shutil.copy(dbg_target_bin, app_dir + "/Contents/MacOS/godot_macos_debug.universal") # ZIP .app bundle. - zip_dir = Dir("#bin/" + (app_prefix + env.extra_suffix).replace(".", "_")).abspath + zip_dir = Dir("#bin/" + (app_prefix + env.extra_suffix + env.module_version_string).replace(".", "_")).abspath shutil.make_archive(zip_dir, "zip", root_dir=bin_dir, base_dir="macos_template.app") shutil.rmtree(app_dir) diff --git a/platform/macos/detect.py b/platform/macos/detect.py index 70cb00c6ff..e35423d41f 100644 --- a/platform/macos/detect.py +++ b/platform/macos/detect.py @@ -56,7 +56,8 @@ def get_flags(): return { "arch": detect_arch(), "use_volk": False, - "supported": ["mono"], + "metal": True, + "supported": ["metal", "mono"], } @@ -96,6 +97,8 @@ def configure(env: "SConsEnvironment"): env.Append(CCFLAGS=["-arch", "x86_64", "-mmacosx-version-min=10.13"]) env.Append(LINKFLAGS=["-arch", "x86_64", "-mmacosx-version-min=10.13"]) + env.Append(CCFLAGS=["-ffp-contract=off"]) + cc_version = get_compiler_version(env) cc_version_major = cc_version["apple_major"] cc_version_minor = cc_version["apple_minor"] @@ -237,9 +240,22 @@ def configure(env: "SConsEnvironment"): env.Append(LINKFLAGS=["-rpath", "@executable_path/../Frameworks", "-rpath", "@executable_path"]) + if env["metal"] and env["arch"] != "arm64": + # Only supported on arm64, so skip it for x86_64 builds. + env["metal"] = False + + extra_frameworks = set() + + if env["metal"]: + env.AppendUnique(CPPDEFINES=["METAL_ENABLED", "RD_ENABLED"]) + extra_frameworks.add("Metal") + extra_frameworks.add("MetalKit") + env.Prepend(CPPPATH=["#thirdparty/spirv-cross"]) + if env["vulkan"]: - env.Append(CPPDEFINES=["VULKAN_ENABLED", "RD_ENABLED"]) - env.Append(LINKFLAGS=["-framework", "Metal", "-framework", "IOSurface"]) + env.AppendUnique(CPPDEFINES=["VULKAN_ENABLED", "RD_ENABLED"]) + extra_frameworks.add("Metal") + extra_frameworks.add("IOSurface") if not env["use_volk"]: env.Append(LINKFLAGS=["-lMoltenVK"]) @@ -258,3 +274,7 @@ def configure(env: "SConsEnvironment"): "MoltenVK SDK installation directory not found, use 'vulkan_sdk_path' SCons parameter to specify SDK path." ) sys.exit(255) + + if len(extra_frameworks) > 0: + frameworks = [item for key in extra_frameworks for item in ["-framework", key]] + env.Append(LINKFLAGS=frameworks) diff --git a/platform/macos/display_server_macos.h b/platform/macos/display_server_macos.h index b4741dc08f..97af6d0a5a 100644 --- a/platform/macos/display_server_macos.h +++ b/platform/macos/display_server_macos.h @@ -47,6 +47,9 @@ #if defined(VULKAN_ENABLED) #include "rendering_context_driver_vulkan_macos.h" #endif // VULKAN_ENABLED +#if defined(METAL_ENABLED) +#include "drivers/metal/rendering_context_driver_metal.h" +#endif #endif // RD_ENABLED #define BitMap _QDBitMap // Suppress deprecated QuickDraw definition. @@ -326,7 +329,7 @@ public: virtual Vector<int> get_window_list() const override; - virtual WindowID create_sub_window(WindowMode p_mode, VSyncMode p_vsync_mode, uint32_t p_flags, const Rect2i &p_rect = Rect2i()) override; + virtual WindowID create_sub_window(WindowMode p_mode, VSyncMode p_vsync_mode, uint32_t p_flags, const Rect2i &p_rect = Rect2i(), bool p_exclusive = false, WindowID p_transient_parent = INVALID_WINDOW_ID) override; virtual void show_window(WindowID p_id) override; virtual void delete_sub_window(WindowID p_id) override; diff --git a/platform/macos/display_server_macos.mm b/platform/macos/display_server_macos.mm index a1a91345ac..989a9dcf6c 100644 --- a/platform/macos/display_server_macos.mm +++ b/platform/macos/display_server_macos.mm @@ -139,12 +139,20 @@ DisplayServerMacOS::WindowID DisplayServerMacOS::_create_window(WindowMode p_mod #ifdef VULKAN_ENABLED RenderingContextDriverVulkanMacOS::WindowPlatformData vulkan; #endif +#ifdef METAL_ENABLED + RenderingContextDriverMetal::WindowPlatformData metal; +#endif } wpd; #ifdef VULKAN_ENABLED if (rendering_driver == "vulkan") { wpd.vulkan.layer_ptr = (CAMetalLayer *const *)&layer; } #endif +#ifdef METAL_ENABLED + if (rendering_driver == "metal") { + wpd.metal.layer = (CAMetalLayer *)layer; + } +#endif Error err = rendering_context->window_create(window_id_counter, &wpd); ERR_FAIL_COND_V_MSG(err != OK, INVALID_WINDOW_ID, vformat("Can't create a %s context", rendering_driver)); @@ -568,23 +576,7 @@ void DisplayServerMacOS::menu_callback(id p_sender) { } GodotMenuItem *value = [p_sender representedObject]; - if (value) { - if (value->max_states > 0) { - value->state++; - if (value->state >= value->max_states) { - value->state = 0; - } - } - - if (value->checkable_type == CHECKABLE_TYPE_CHECK_BOX) { - if ([p_sender state] == NSControlStateValueOff) { - [p_sender setState:NSControlStateValueOn]; - } else { - [p_sender setState:NSControlStateValueOff]; - } - } - if (value->callback.is_valid()) { MenuCall mc; mc.tag = value->meta; @@ -1730,7 +1722,7 @@ Vector<DisplayServer::WindowID> DisplayServerMacOS::get_window_list() const { return ret; } -DisplayServer::WindowID DisplayServerMacOS::create_sub_window(WindowMode p_mode, VSyncMode p_vsync_mode, uint32_t p_flags, const Rect2i &p_rect) { +DisplayServer::WindowID DisplayServerMacOS::create_sub_window(WindowMode p_mode, VSyncMode p_vsync_mode, uint32_t p_flags, const Rect2i &p_rect, bool p_exclusive, WindowID p_transient_parent) { _THREAD_SAFE_METHOD_ WindowID id = _create_window(p_mode, p_vsync_mode, p_rect); @@ -1744,6 +1736,12 @@ DisplayServer::WindowID DisplayServerMacOS::create_sub_window(WindowMode p_mode, rendering_device->screen_create(id); } #endif + + window_set_exclusive(id, p_exclusive); + if (p_transient_parent != INVALID_WINDOW_ID) { + window_set_transient(id, p_transient_parent); + } + return id; } @@ -2342,7 +2340,7 @@ void DisplayServerMacOS::window_set_window_buttons_offset(const Vector2i &p_offs wd.wb_offset = p_offset / scale; wd.wb_offset = wd.wb_offset.maxi(12); if (wd.window_button_view) { - [wd.window_button_view setOffset:NSMakePoint(wd.wb_offset.x, wd.wb_offset.y)]; + [(GodotButtonView *)wd.window_button_view setOffset:NSMakePoint(wd.wb_offset.x, wd.wb_offset.y)]; } } @@ -2710,7 +2708,7 @@ void DisplayServerMacOS::window_set_vsync_mode(DisplayServer::VSyncMode p_vsync_ gl_manager_legacy->set_use_vsync(p_vsync_mode != DisplayServer::VSYNC_DISABLED); } #endif -#if defined(VULKAN_ENABLED) +#if defined(RD_ENABLED) if (rendering_context) { rendering_context->window_set_vsync_mode(p_window, p_vsync_mode); } @@ -2727,7 +2725,7 @@ DisplayServer::VSyncMode DisplayServerMacOS::window_get_vsync_mode(WindowID p_wi return (gl_manager_legacy->is_using_vsync() ? DisplayServer::VSyncMode::VSYNC_ENABLED : DisplayServer::VSyncMode::VSYNC_DISABLED); } #endif -#if defined(VULKAN_ENABLED) +#if defined(RD_ENABLED) if (rendering_context) { return rendering_context->window_get_vsync_mode(p_window); } @@ -3311,6 +3309,9 @@ Vector<String> DisplayServerMacOS::get_rendering_drivers_func() { #if defined(VULKAN_ENABLED) drivers.push_back("vulkan"); #endif +#if defined(METAL_ENABLED) + drivers.push_back("metal"); +#endif #if defined(GLES3_ENABLED) drivers.push_back("opengl3"); drivers.push_back("opengl3_angle"); @@ -3608,7 +3609,11 @@ DisplayServerMacOS::DisplayServerMacOS(const String &p_rendering_driver, WindowM gl_manager_angle = nullptr; bool fallback = GLOBAL_GET("rendering/gl_compatibility/fallback_to_native"); if (fallback) { - WARN_PRINT("Your video card drivers seem not to support the required Metal version, switching to native OpenGL."); +#ifdef EGL_STATIC + WARN_PRINT("Your video card drivers seem not to support GLES3 / ANGLE, switching to native OpenGL."); +#else + WARN_PRINT("Your video card drivers seem not to support GLES3 / ANGLE or ANGLE dynamic libraries (libEGL.dylib and libGLESv2.dylib) are missing, switching to native OpenGL."); +#endif rendering_driver = "opengl3"; } else { r_error = ERR_UNAVAILABLE; @@ -3633,6 +3638,11 @@ DisplayServerMacOS::DisplayServerMacOS(const String &p_rendering_driver, WindowM rendering_context = memnew(RenderingContextDriverVulkanMacOS); } #endif +#if defined(METAL_ENABLED) + if (rendering_driver == "metal") { + rendering_context = memnew(RenderingContextDriverMetal); + } +#endif if (rendering_context) { if (rendering_context->initialize() != OK) { diff --git a/platform/macos/doc_classes/EditorExportPlatformMacOS.xml b/platform/macos/doc_classes/EditorExportPlatformMacOS.xml index 92ade4b77a..34ad52bbf6 100644 --- a/platform/macos/doc_classes/EditorExportPlatformMacOS.xml +++ b/platform/macos/doc_classes/EditorExportPlatformMacOS.xml @@ -224,16 +224,16 @@ Indicates whether your app uses advertising data for tracking. </member> <member name="privacy/collected_data/audio_data/collected" type="bool" setter="" getter=""> - Indicates whether your app collects audio data data. + Indicates whether your app collects audio data. </member> <member name="privacy/collected_data/audio_data/collection_purposes" type="int" setter="" getter=""> The reasons your app collects audio data. See [url=https://developer.apple.com/documentation/bundleresources/privacy_manifest_files/describing_data_use_in_privacy_manifests]Describing data use in privacy manifests[/url]. </member> <member name="privacy/collected_data/audio_data/linked_to_user" type="bool" setter="" getter=""> - Indicates whether your app links audio data data to the user's identity. + Indicates whether your app links audio data to the user's identity. </member> <member name="privacy/collected_data/audio_data/used_for_tracking" type="bool" setter="" getter=""> - Indicates whether your app uses audio data data for tracking. + Indicates whether your app uses audio data for tracking. </member> <member name="privacy/collected_data/browsing_history/collected" type="bool" setter="" getter=""> Indicates whether your app collects browsing history. diff --git a/platform/macos/export/export_plugin.cpp b/platform/macos/export/export_plugin.cpp index 73e2f2d45b..290b0082fc 100644 --- a/platform/macos/export/export_plugin.cpp +++ b/platform/macos/export/export_plugin.cpp @@ -141,7 +141,7 @@ String EditorExportPlatformMacOS::get_export_option_warning(const EditorExportPr if (p_name == "codesign/codesign") { if (dist_type == 2) { - if (codesign_tool == 2 && Engine::get_singleton()->has_singleton("GodotSharp")) { + if (codesign_tool == 2 && ClassDB::class_exists("CSharpScript")) { return TTR("'rcodesign' doesn't support signing applications with embedded dynamic libraries (GDExtension or .NET)."); } if (codesign_tool == 0) { @@ -333,7 +333,7 @@ bool EditorExportPlatformMacOS::get_export_option_visibility(const EditorExportP } // These entitlements are required to run managed code, and are always enabled in Mono builds. - if (Engine::get_singleton()->has_singleton("GodotSharp")) { + if (ClassDB::class_exists("CSharpScript")) { if (p_option == "codesign/entitlements/allow_jit_code_execution" || p_option == "codesign/entitlements/allow_unsigned_executable_memory" || p_option == "codesign/entitlements/allow_dyld_environment_variables") { return false; } @@ -458,6 +458,7 @@ void EditorExportPlatformMacOS::get_export_options(List<ExportOption> *r_options r_options->push_back(ExportOption(PropertyInfo(Variant::STRING, "application/additional_plist_content", PROPERTY_HINT_MULTILINE_TEXT), "")); r_options->push_back(ExportOption(PropertyInfo(Variant::STRING, "xcode/platform_build"), "14C18")); + // TODO(sgc): Need to set appropriate version when using Metal r_options->push_back(ExportOption(PropertyInfo(Variant::STRING, "xcode/sdk_version"), "13.1")); r_options->push_back(ExportOption(PropertyInfo(Variant::STRING, "xcode/sdk_build"), "22C55")); r_options->push_back(ExportOption(PropertyInfo(Variant::STRING, "xcode/sdk_name"), "macosx13.1")); @@ -1064,7 +1065,7 @@ Error EditorExportPlatformMacOS::_notarize(const Ref<EditorExportPreset> &p_pres return OK; } -Error EditorExportPlatformMacOS::_code_sign(const Ref<EditorExportPreset> &p_preset, const String &p_path, const String &p_ent_path, bool p_warn, bool p_set_id) { +void EditorExportPlatformMacOS::_code_sign(const Ref<EditorExportPreset> &p_preset, const String &p_path, const String &p_ent_path, bool p_warn, bool p_set_id) { int codesign_tool = p_preset->get("codesign/codesign"); switch (codesign_tool) { case 1: { // built-in ad-hoc @@ -1074,7 +1075,7 @@ Error EditorExportPlatformMacOS::_code_sign(const Ref<EditorExportPreset> &p_pre Error err = CodeSign::codesign(false, true, p_path, p_ent_path, error_msg); if (err != OK) { add_message(EXPORT_MESSAGE_WARNING, TTR("Code Signing"), vformat(TTR("Built-in CodeSign failed with error \"%s\"."), error_msg)); - return Error::FAILED; + return; } #else add_message(EXPORT_MESSAGE_WARNING, TTR("Code Signing"), TTR("Built-in CodeSign require regex module.")); @@ -1086,13 +1087,13 @@ Error EditorExportPlatformMacOS::_code_sign(const Ref<EditorExportPreset> &p_pre String rcodesign = EDITOR_GET("export/macos/rcodesign").operator String(); if (rcodesign.is_empty()) { add_message(EXPORT_MESSAGE_ERROR, TTR("Code Signing"), TTR("Xrcodesign path is not set. Configure rcodesign path in the Editor Settings (Export > macOS > rcodesign).")); - return Error::FAILED; + return; } List<String> args; args.push_back("sign"); - if (p_path.get_extension() != "dmg") { + if (!p_ent_path.is_empty()) { args.push_back("--entitlements-xml-path"); args.push_back(p_ent_path); } @@ -1124,13 +1125,13 @@ Error EditorExportPlatformMacOS::_code_sign(const Ref<EditorExportPreset> &p_pre Error err = OS::get_singleton()->execute(rcodesign, args, &str, &exitcode, true); if (err != OK) { add_message(EXPORT_MESSAGE_WARNING, TTR("Code Signing"), TTR("Could not start rcodesign executable.")); - return err; + return; } if (exitcode != 0) { print_line("rcodesign (" + p_path + "):\n" + str); add_message(EXPORT_MESSAGE_WARNING, TTR("Code Signing"), TTR("Code signing failed, see editor log for details.")); - return Error::FAILED; + return; } else { print_verbose("rcodesign (" + p_path + "):\n" + str); } @@ -1141,7 +1142,7 @@ Error EditorExportPlatformMacOS::_code_sign(const Ref<EditorExportPreset> &p_pre if (!FileAccess::exists("/usr/bin/codesign") && !FileAccess::exists("/bin/codesign")) { add_message(EXPORT_MESSAGE_ERROR, TTR("Code Signing"), TTR("Xcode command line tools are not installed.")); - return Error::FAILED; + return; } bool ad_hoc = (p_preset->get("codesign/identity") == "" || p_preset->get("codesign/identity") == "-"); @@ -1153,7 +1154,7 @@ Error EditorExportPlatformMacOS::_code_sign(const Ref<EditorExportPreset> &p_pre args.push_back("runtime"); } - if (p_path.get_extension() != "dmg") { + if (!p_ent_path.is_empty()) { args.push_back("--entitlements"); args.push_back(p_ent_path); } @@ -1190,13 +1191,13 @@ Error EditorExportPlatformMacOS::_code_sign(const Ref<EditorExportPreset> &p_pre Error err = OS::get_singleton()->execute("codesign", args, &str, &exitcode, true); if (err != OK) { add_message(EXPORT_MESSAGE_WARNING, TTR("Code Signing"), TTR("Could not start codesign executable, make sure Xcode command line tools are installed.")); - return err; + return; } if (exitcode != 0) { print_line("codesign (" + p_path + "):\n" + str); add_message(EXPORT_MESSAGE_WARNING, TTR("Code Signing"), TTR("Code signing failed, see editor log for details.")); - return Error::FAILED; + return; } else { print_verbose("codesign (" + p_path + "):\n" + str); } @@ -1205,14 +1206,13 @@ Error EditorExportPlatformMacOS::_code_sign(const Ref<EditorExportPreset> &p_pre default: { }; } - - return OK; } -Error EditorExportPlatformMacOS::_code_sign_directory(const Ref<EditorExportPreset> &p_preset, const String &p_path, +void EditorExportPlatformMacOS::_code_sign_directory(const Ref<EditorExportPreset> &p_preset, const String &p_path, const String &p_ent_path, const String &p_helper_ent_path, bool p_should_error_on_non_code) { static Vector<String> extensions_to_sign; + bool sandbox = p_preset->get("codesign/entitlements/app_sandbox/enabled"); if (extensions_to_sign.is_empty()) { extensions_to_sign.push_back("dylib"); extensions_to_sign.push_back("framework"); @@ -1223,7 +1223,8 @@ Error EditorExportPlatformMacOS::_code_sign_directory(const Ref<EditorExportPres Ref<DirAccess> dir_access{ DirAccess::open(p_path, &dir_access_error) }; if (dir_access_error != OK) { - return dir_access_error; + add_message(EXPORT_MESSAGE_WARNING, TTR("Code Signing"), vformat(TTR("Cannot sign directory %s."), p_path)); + return; } dir_access->list_dir_begin(); @@ -1237,44 +1238,35 @@ Error EditorExportPlatformMacOS::_code_sign_directory(const Ref<EditorExportPres } if (extensions_to_sign.has(current_file.get_extension())) { - String ent_path = p_ent_path; + String ent_path; bool set_bundle_id = false; - if (FileAccess::exists(current_file_path)) { + if (sandbox && FileAccess::exists(current_file_path)) { int ftype = MachO::get_filetype(current_file_path); if (ftype == 2 || ftype == 5) { ent_path = p_helper_ent_path; set_bundle_id = true; } } - Error code_sign_error{ _code_sign(p_preset, current_file_path, ent_path, false, set_bundle_id) }; - if (code_sign_error != OK) { - return code_sign_error; - } + _code_sign(p_preset, current_file_path, ent_path, false, set_bundle_id); if (is_executable(current_file_path)) { // chmod with 0755 if the file is executable. FileAccess::set_unix_permissions(current_file_path, 0755); } } else if (dir_access->current_is_dir()) { - Error code_sign_error{ _code_sign_directory(p_preset, current_file_path, p_ent_path, p_helper_ent_path, p_should_error_on_non_code) }; - if (code_sign_error != OK) { - return code_sign_error; - } + _code_sign_directory(p_preset, current_file_path, p_ent_path, p_helper_ent_path, p_should_error_on_non_code); } else if (p_should_error_on_non_code) { add_message(EXPORT_MESSAGE_WARNING, TTR("Code Signing"), vformat(TTR("Cannot sign file %s."), current_file)); - return Error::FAILED; } current_file = dir_access->get_next(); } - - return OK; } Error EditorExportPlatformMacOS::_copy_and_sign_files(Ref<DirAccess> &dir_access, const String &p_src_path, const String &p_in_app_path, bool p_sign_enabled, const Ref<EditorExportPreset> &p_preset, const String &p_ent_path, const String &p_helper_ent_path, - bool p_should_error_on_non_code_sign) { + bool p_should_error_on_non_code_sign, bool p_sandbox) { static Vector<String> extensions_to_sign; if (extensions_to_sign.is_empty()) { @@ -1363,19 +1355,19 @@ Error EditorExportPlatformMacOS::_copy_and_sign_files(Ref<DirAccess> &dir_access if (err == OK && p_sign_enabled) { if (dir_access->dir_exists(p_src_path) && p_src_path.get_extension().is_empty()) { // If it is a directory, find and sign all dynamic libraries. - err = _code_sign_directory(p_preset, p_in_app_path, p_ent_path, p_helper_ent_path, p_should_error_on_non_code_sign); + _code_sign_directory(p_preset, p_in_app_path, p_ent_path, p_helper_ent_path, p_should_error_on_non_code_sign); } else { if (extensions_to_sign.has(p_in_app_path.get_extension())) { - String ent_path = p_ent_path; + String ent_path; bool set_bundle_id = false; - if (FileAccess::exists(p_in_app_path)) { + if (p_sandbox && FileAccess::exists(p_in_app_path)) { int ftype = MachO::get_filetype(p_in_app_path); if (ftype == 2 || ftype == 5) { ent_path = p_helper_ent_path; set_bundle_id = true; } } - err = _code_sign(p_preset, p_in_app_path, ent_path, false, set_bundle_id); + _code_sign(p_preset, p_in_app_path, ent_path, false, set_bundle_id); } if (dir_access->file_exists(p_in_app_path) && is_executable(p_in_app_path)) { // chmod with 0755 if the file is executable. @@ -1389,13 +1381,13 @@ Error EditorExportPlatformMacOS::_copy_and_sign_files(Ref<DirAccess> &dir_access Error EditorExportPlatformMacOS::_export_macos_plugins_for(Ref<EditorExportPlugin> p_editor_export_plugin, const String &p_app_path_name, Ref<DirAccess> &dir_access, bool p_sign_enabled, const Ref<EditorExportPreset> &p_preset, - const String &p_ent_path, const String &p_helper_ent_path) { + const String &p_ent_path, const String &p_helper_ent_path, bool p_sandbox) { Error error{ OK }; const Vector<String> &macos_plugins{ p_editor_export_plugin->get_macos_plugin_files() }; for (int i = 0; i < macos_plugins.size(); ++i) { String src_path{ ProjectSettings::get_singleton()->globalize_path(macos_plugins[i]) }; String path_in_app{ p_app_path_name + "/Contents/PlugIns/" + src_path.get_file() }; - error = _copy_and_sign_files(dir_access, src_path, path_in_app, p_sign_enabled, p_preset, p_ent_path, p_helper_ent_path, false); + error = _copy_and_sign_files(dir_access, src_path, path_in_app, p_sign_enabled, p_preset, p_ent_path, p_helper_ent_path, false, p_sandbox); if (error != OK) { break; } @@ -1988,7 +1980,7 @@ Error EditorExportPlatformMacOS::export_project(const Ref<EditorExportPreset> &p ent_f->store_line("<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">"); ent_f->store_line("<plist version=\"1.0\">"); ent_f->store_line("<dict>"); - if (Engine::get_singleton()->has_singleton("GodotSharp")) { + if (ClassDB::class_exists("CSharpScript")) { // These entitlements are required to run managed code, and are always enabled in Mono builds. ent_f->store_line("<key>com.apple.security.cs.allow-jit</key>"); ent_f->store_line("<true/>"); @@ -2156,7 +2148,7 @@ Error EditorExportPlatformMacOS::export_project(const Ref<EditorExportPreset> &p String hlp_path = helpers[i]; err = da->copy(hlp_path, tmp_app_path_name + "/Contents/Helpers/" + hlp_path.get_file()); if (err == OK && sign_enabled) { - err = _code_sign(p_preset, tmp_app_path_name + "/Contents/Helpers/" + hlp_path.get_file(), hlp_ent_path, false, true); + _code_sign(p_preset, tmp_app_path_name + "/Contents/Helpers/" + hlp_path.get_file(), hlp_ent_path, false, true); } FileAccess::set_unix_permissions(tmp_app_path_name + "/Contents/Helpers/" + hlp_path.get_file(), 0755); } @@ -2168,11 +2160,11 @@ Error EditorExportPlatformMacOS::export_project(const Ref<EditorExportPreset> &p String src_path = ProjectSettings::get_singleton()->globalize_path(shared_objects[i].path); if (shared_objects[i].target.is_empty()) { String path_in_app = tmp_app_path_name + "/Contents/Frameworks/" + src_path.get_file(); - err = _copy_and_sign_files(da, src_path, path_in_app, sign_enabled, p_preset, ent_path, hlp_ent_path, true); + err = _copy_and_sign_files(da, src_path, path_in_app, sign_enabled, p_preset, ent_path, hlp_ent_path, true, sandbox); } else { String path_in_app = tmp_app_path_name.path_join(shared_objects[i].target); tmp_app_dir->make_dir_recursive(path_in_app); - err = _copy_and_sign_files(da, src_path, path_in_app.path_join(src_path.get_file()), sign_enabled, p_preset, ent_path, hlp_ent_path, false); + err = _copy_and_sign_files(da, src_path, path_in_app.path_join(src_path.get_file()), sign_enabled, p_preset, ent_path, hlp_ent_path, false, sandbox); } if (err != OK) { break; @@ -2181,7 +2173,7 @@ Error EditorExportPlatformMacOS::export_project(const Ref<EditorExportPreset> &p Vector<Ref<EditorExportPlugin>> export_plugins{ EditorExport::get_singleton()->get_export_plugins() }; for (int i = 0; i < export_plugins.size(); ++i) { - err = _export_macos_plugins_for(export_plugins[i], tmp_app_path_name, da, sign_enabled, p_preset, ent_path, hlp_ent_path); + err = _export_macos_plugins_for(export_plugins[i], tmp_app_path_name, da, sign_enabled, p_preset, ent_path, hlp_ent_path, sandbox); if (err != OK) { break; } @@ -2201,7 +2193,7 @@ Error EditorExportPlatformMacOS::export_project(const Ref<EditorExportPreset> &p if (ep.step(TTR("Code signing bundle"), 2)) { return ERR_SKIP; } - err = _code_sign(p_preset, tmp_app_path_name, ent_path, true, false); + _code_sign(p_preset, tmp_app_path_name, ent_path, true, false); } String noto_path = p_path; @@ -2219,7 +2211,7 @@ Error EditorExportPlatformMacOS::export_project(const Ref<EditorExportPreset> &p if (ep.step(TTR("Code signing DMG"), 3)) { return ERR_SKIP; } - err = _code_sign(p_preset, p_path, ent_path, false, false); + _code_sign(p_preset, p_path, ent_path, false, false); } } else if (export_format == "pkg") { // Create a Installer. diff --git a/platform/macos/export/export_plugin.h b/platform/macos/export/export_plugin.h index 6134d756b9..062a2e5f95 100644 --- a/platform/macos/export/export_plugin.h +++ b/platform/macos/export/export_plugin.h @@ -90,14 +90,14 @@ class EditorExportPlatformMacOS : public EditorExportPlatform { void _make_icon(const Ref<EditorExportPreset> &p_preset, const Ref<Image> &p_icon, Vector<uint8_t> &p_data); Error _notarize(const Ref<EditorExportPreset> &p_preset, const String &p_path); - Error _code_sign(const Ref<EditorExportPreset> &p_preset, const String &p_path, const String &p_ent_path, bool p_warn = true, bool p_set_id = false); - Error _code_sign_directory(const Ref<EditorExportPreset> &p_preset, const String &p_path, const String &p_ent_path, const String &p_helper_ent_path, bool p_should_error_on_non_code = true); + void _code_sign(const Ref<EditorExportPreset> &p_preset, const String &p_path, const String &p_ent_path, bool p_warn = true, bool p_set_id = false); + void _code_sign_directory(const Ref<EditorExportPreset> &p_preset, const String &p_path, const String &p_ent_path, const String &p_helper_ent_path, bool p_should_error_on_non_code = true); Error _copy_and_sign_files(Ref<DirAccess> &dir_access, const String &p_src_path, const String &p_in_app_path, bool p_sign_enabled, const Ref<EditorExportPreset> &p_preset, const String &p_ent_path, const String &p_helper_ent_path, - bool p_should_error_on_non_code_sign); + bool p_should_error_on_non_code_sign, bool p_sandbox); Error _export_macos_plugins_for(Ref<EditorExportPlugin> p_editor_export_plugin, const String &p_app_path_name, Ref<DirAccess> &dir_access, bool p_sign_enabled, const Ref<EditorExportPreset> &p_preset, - const String &p_ent_path, const String &p_helper_ent_path); + const String &p_ent_path, const String &p_helper_ent_path, bool p_sandbox); Error _create_dmg(const String &p_dmg_path, const String &p_pkg_name, const String &p_app_path_name); Error _create_pkg(const Ref<EditorExportPreset> &p_preset, const String &p_pkg_path, const String &p_app_path_name); Error _export_debug_script(const Ref<EditorExportPreset> &p_preset, const String &p_app_name, const String &p_pkg_name, const String &p_path); diff --git a/platform/macos/gl_manager_macos_legacy.h b/platform/macos/gl_manager_macos_legacy.h index af9be8f5ba..383c5c3306 100644 --- a/platform/macos/gl_manager_macos_legacy.h +++ b/platform/macos/gl_manager_macos_legacy.h @@ -62,6 +62,7 @@ class GLManagerLegacy_MacOS { Error create_context(GLWindow &win); + bool framework_loaded = false; bool use_vsync = false; CGLEnablePtr CGLEnable = nullptr; CGLSetParameterPtr CGLSetParameter = nullptr; diff --git a/platform/macos/gl_manager_macos_legacy.mm b/platform/macos/gl_manager_macos_legacy.mm index 6ce3831d9c..a0d037144e 100644 --- a/platform/macos/gl_manager_macos_legacy.mm +++ b/platform/macos/gl_manager_macos_legacy.mm @@ -32,6 +32,7 @@ #if defined(MACOS_ENABLED) && defined(GLES3_ENABLED) +#include <dlfcn.h> #include <stdio.h> #include <stdlib.h> @@ -156,7 +157,7 @@ void GLManagerLegacy_MacOS::window_set_per_pixel_transparency_enabled(DisplaySer } Error GLManagerLegacy_MacOS::initialize() { - return OK; + return framework_loaded ? OK : ERR_CANT_CREATE; } void GLManagerLegacy_MacOS::set_use_vsync(bool p_use) { @@ -186,12 +187,17 @@ NSOpenGLContext *GLManagerLegacy_MacOS::get_context(DisplayServer::WindowID p_wi } GLManagerLegacy_MacOS::GLManagerLegacy_MacOS() { - CFBundleRef framework = CFBundleGetBundleWithIdentifier(CFSTR("com.apple.opengl")); - CFBundleLoadExecutable(framework); - - CGLEnable = (CGLEnablePtr)CFBundleGetFunctionPointerForName(framework, CFSTR("CGLEnable")); - CGLSetParameter = (CGLSetParameterPtr)CFBundleGetFunctionPointerForName(framework, CFSTR("CGLSetParameter")); - CGLGetCurrentContext = (CGLGetCurrentContextPtr)CFBundleGetFunctionPointerForName(framework, CFSTR("CGLGetCurrentContext")); + NSBundle *framework = [NSBundle bundleWithPath:@"/System/Library/Frameworks/OpenGL.framework"]; + if (framework) { + void *library_handle = dlopen([framework.executablePath UTF8String], RTLD_NOW); + if (library_handle) { + CGLEnable = (CGLEnablePtr)dlsym(library_handle, "CGLEnable"); + CGLSetParameter = (CGLSetParameterPtr)dlsym(library_handle, "CGLSetParameter"); + CGLGetCurrentContext = (CGLGetCurrentContextPtr)dlsym(library_handle, "CGLGetCurrentContext"); + + framework_loaded = CGLEnable && CGLSetParameter && CGLGetCurrentContext; + } + } } GLManagerLegacy_MacOS::~GLManagerLegacy_MacOS() { diff --git a/platform/macos/godot_content_view.mm b/platform/macos/godot_content_view.mm index 77f3a28ae7..7d43ac9fe6 100644 --- a/platform/macos/godot_content_view.mm +++ b/platform/macos/godot_content_view.mm @@ -329,8 +329,9 @@ Callable::CallError ce; wd.drop_files_callback.callp((const Variant **)&v_args, 1, ret, ce); if (ce.error != Callable::CallError::CALL_OK) { - ERR_PRINT(vformat("Failed to execute drop files callback: %s.", Variant::get_callable_error_text(wd.drop_files_callback, v_args, 1, ce))); + ERR_FAIL_V_MSG(NO, vformat("Failed to execute drop files callback: %s.", Variant::get_callable_error_text(wd.drop_files_callback, v_args, 1, ce))); } + return YES; } return NO; diff --git a/platform/macos/godot_main_macos.mm b/platform/macos/godot_main_macos.mm index 942c351ac0..eebaed0eaf 100644 --- a/platform/macos/godot_main_macos.mm +++ b/platform/macos/godot_main_macos.mm @@ -41,8 +41,8 @@ int main(int argc, char **argv) { #if defined(VULKAN_ENABLED) - // MoltenVK - enable full component swizzling support. - setenv("MVK_CONFIG_FULL_IMAGE_VIEW_SWIZZLE", "1", 1); + setenv("MVK_CONFIG_FULL_IMAGE_VIEW_SWIZZLE", "1", 1); // MoltenVK - enable full component swizzling support. + setenv("MVK_CONFIG_SWAPCHAIN_MIN_MAG_FILTER_USE_NEAREST", "0", 1); // MoltenVK - use linear surface scaling. TODO: remove when full DPI scaling is implemented. #endif #if defined(SANITIZERS_ENABLED) diff --git a/platform/macos/godot_menu_item.h b/platform/macos/godot_menu_item.h index b6e2d41c08..e1af317259 100644 --- a/platform/macos/godot_menu_item.h +++ b/platform/macos/godot_menu_item.h @@ -52,6 +52,7 @@ enum GlobalMenuCheckType { Callable hover_callback; Variant meta; GlobalMenuCheckType checkable_type; + bool checked; int max_states; int state; Ref<Image> img; diff --git a/platform/macos/godot_menu_item.mm b/platform/macos/godot_menu_item.mm index 30dac9be9b..479542113a 100644 --- a/platform/macos/godot_menu_item.mm +++ b/platform/macos/godot_menu_item.mm @@ -31,4 +31,18 @@ #include "godot_menu_item.h" @implementation GodotMenuItem + +- (id)init { + self = [super init]; + + self->callback = Callable(); + self->key_callback = Callable(); + self->checkable_type = GlobalMenuCheckType::CHECKABLE_TYPE_NONE; + self->checked = false; + self->max_states = 0; + self->state = 0; + + return self; +} + @end diff --git a/platform/macos/joypad_macos.mm b/platform/macos/joypad_macos.mm index 8cd5cdd9f2..beb32d9129 100644 --- a/platform/macos/joypad_macos.mm +++ b/platform/macos/joypad_macos.mm @@ -228,7 +228,7 @@ void JoypadMacOS::joypad_vibration_stop(Joypad *p_joypad, uint64_t p_timestamp) @property(assign, nonatomic) BOOL isObserving; @property(assign, nonatomic) BOOL isProcessing; @property(strong, nonatomic) NSMutableDictionary<NSNumber *, Joypad *> *connectedJoypads; -@property(strong, nonatomic) NSMutableArray<Joypad *> *joypadsQueue; +@property(strong, nonatomic) NSMutableArray<GCController *> *joypadsQueue; @end @@ -364,8 +364,7 @@ void JoypadMacOS::joypad_vibration_stop(Joypad *p_joypad, uint64_t p_timestamp) if ([[self getAllKeysForController:controller] count] > 0) { print_verbose("Controller is already registered."); } else if (!self.isProcessing) { - Joypad *joypad = [[Joypad alloc] init:controller]; - [self.joypadsQueue addObject:joypad]; + [self.joypadsQueue addObject:controller]; } else { [self addMacOSJoypad:controller]; } diff --git a/platform/macos/native_menu_macos.mm b/platform/macos/native_menu_macos.mm index 1ae1137ca0..802d58dc26 100644 --- a/platform/macos/native_menu_macos.mm +++ b/platform/macos/native_menu_macos.mm @@ -373,12 +373,7 @@ int NativeMenuMacOS::add_submenu_item(const RID &p_rid, const String &p_label, c menu_item = [md->menu insertItemWithTitle:[NSString stringWithUTF8String:p_label.utf8().get_data()] action:nil keyEquivalent:@"" atIndex:p_index]; GodotMenuItem *obj = [[GodotMenuItem alloc] init]; - obj->callback = Callable(); - obj->key_callback = Callable(); obj->meta = p_tag; - obj->checkable_type = CHECKABLE_TYPE_NONE; - obj->max_states = 0; - obj->state = 0; [menu_item setRepresentedObject:obj]; [md_sub->menu setTitle:[NSString stringWithUTF8String:p_label.utf8().get_data()]]; @@ -417,9 +412,6 @@ int NativeMenuMacOS::add_item(const RID &p_rid, const String &p_label, const Cal obj->callback = p_callback; obj->key_callback = p_key_callback; obj->meta = p_tag; - obj->checkable_type = CHECKABLE_TYPE_NONE; - obj->max_states = 0; - obj->state = 0; [menu_item setKeyEquivalentModifierMask:KeyMappingMacOS::keycode_get_native_mask(p_accel)]; [menu_item setRepresentedObject:obj]; } @@ -438,8 +430,6 @@ int NativeMenuMacOS::add_check_item(const RID &p_rid, const String &p_label, con obj->key_callback = p_key_callback; obj->meta = p_tag; obj->checkable_type = CHECKABLE_TYPE_CHECK_BOX; - obj->max_states = 0; - obj->state = 0; [menu_item setKeyEquivalentModifierMask:KeyMappingMacOS::keycode_get_native_mask(p_accel)]; [menu_item setRepresentedObject:obj]; } @@ -457,9 +447,6 @@ int NativeMenuMacOS::add_icon_item(const RID &p_rid, const Ref<Texture2D> &p_ico obj->callback = p_callback; obj->key_callback = p_key_callback; obj->meta = p_tag; - obj->checkable_type = CHECKABLE_TYPE_NONE; - obj->max_states = 0; - obj->state = 0; DisplayServerMacOS *ds = (DisplayServerMacOS *)DisplayServer::get_singleton(); if (ds && p_icon.is_valid() && p_icon->get_width() > 0 && p_icon->get_height() > 0 && p_icon->get_image().is_valid()) { obj->img = p_icon->get_image(); @@ -489,8 +476,6 @@ int NativeMenuMacOS::add_icon_check_item(const RID &p_rid, const Ref<Texture2D> obj->key_callback = p_key_callback; obj->meta = p_tag; obj->checkable_type = CHECKABLE_TYPE_CHECK_BOX; - obj->max_states = 0; - obj->state = 0; DisplayServerMacOS *ds = (DisplayServerMacOS *)DisplayServer::get_singleton(); if (ds && p_icon.is_valid() && p_icon->get_width() > 0 && p_icon->get_height() > 0 && p_icon->get_image().is_valid()) { obj->img = p_icon->get_image(); @@ -520,8 +505,6 @@ int NativeMenuMacOS::add_radio_check_item(const RID &p_rid, const String &p_labe obj->key_callback = p_key_callback; obj->meta = p_tag; obj->checkable_type = CHECKABLE_TYPE_RADIO_BUTTON; - obj->max_states = 0; - obj->state = 0; [menu_item setKeyEquivalentModifierMask:KeyMappingMacOS::keycode_get_native_mask(p_accel)]; [menu_item setRepresentedObject:obj]; } @@ -540,8 +523,6 @@ int NativeMenuMacOS::add_icon_radio_check_item(const RID &p_rid, const Ref<Textu obj->key_callback = p_key_callback; obj->meta = p_tag; obj->checkable_type = CHECKABLE_TYPE_RADIO_BUTTON; - obj->max_states = 0; - obj->state = 0; DisplayServerMacOS *ds = (DisplayServerMacOS *)DisplayServer::get_singleton(); if (ds && p_icon.is_valid() && p_icon->get_width() > 0 && p_icon->get_height() > 0 && p_icon->get_image().is_valid()) { obj->img = p_icon->get_image(); @@ -570,7 +551,6 @@ int NativeMenuMacOS::add_multistate_item(const RID &p_rid, const String &p_label obj->callback = p_callback; obj->key_callback = p_key_callback; obj->meta = p_tag; - obj->checkable_type = CHECKABLE_TYPE_NONE; obj->max_states = p_max_states; obj->state = p_default_state; [menu_item setKeyEquivalentModifierMask:KeyMappingMacOS::keycode_get_native_mask(p_accel)]; @@ -640,7 +620,10 @@ bool NativeMenuMacOS::is_item_checked(const RID &p_rid, int p_idx) const { ERR_FAIL_COND_V(p_idx >= item_start + item_count, false); const NSMenuItem *menu_item = [md->menu itemAtIndex:p_idx]; if (menu_item) { - return ([menu_item state] == NSControlStateValueOn); + const GodotMenuItem *obj = [menu_item representedObject]; + if (obj) { + return obj->checked; + } } return false; } @@ -958,10 +941,14 @@ void NativeMenuMacOS::set_item_checked(const RID &p_rid, int p_idx, bool p_check ERR_FAIL_COND(p_idx >= item_start + item_count); NSMenuItem *menu_item = [md->menu itemAtIndex:p_idx]; if (menu_item) { - if (p_checked) { - [menu_item setState:NSControlStateValueOn]; - } else { - [menu_item setState:NSControlStateValueOff]; + GodotMenuItem *obj = [menu_item representedObject]; + if (obj) { + obj->checked = p_checked; + if (p_checked) { + [menu_item setState:NSControlStateValueOn]; + } else { + [menu_item setState:NSControlStateValueOff]; + } } } } diff --git a/platform/macos/os_macos.h b/platform/macos/os_macos.h index 912a682a6b..303fc112bf 100644 --- a/platform/macos/os_macos.h +++ b/platform/macos/os_macos.h @@ -109,6 +109,7 @@ public: virtual String get_executable_path() const override; virtual Error create_process(const String &p_path, const List<String> &p_arguments, ProcessID *r_child_id = nullptr, bool p_open_console = false) override; virtual Error create_instance(const List<String> &p_arguments, ProcessID *r_child_id = nullptr) override; + virtual bool is_process_running(const ProcessID &p_pid) const override; virtual String get_unique_id() const override; virtual String get_processor_name() const override; diff --git a/platform/macos/os_macos.mm b/platform/macos/os_macos.mm index 9f0bea5951..3a82514766 100644 --- a/platform/macos/os_macos.mm +++ b/platform/macos/os_macos.mm @@ -666,6 +666,15 @@ Error OS_MacOS::create_instance(const List<String> &p_arguments, ProcessID *r_ch } } +bool OS_MacOS::is_process_running(const ProcessID &p_pid) const { + NSRunningApplication *app = [NSRunningApplication runningApplicationWithProcessIdentifier:(pid_t)p_pid]; + if (!app) { + return OS_Unix::is_process_running(p_pid); + } + + return ![app isTerminated]; +} + String OS_MacOS::get_unique_id() const { static String serial_number; diff --git a/platform/macos/rendering_context_driver_vulkan_macos.mm b/platform/macos/rendering_context_driver_vulkan_macos.mm index afefe5a6f7..b617cb8f26 100644 --- a/platform/macos/rendering_context_driver_vulkan_macos.mm +++ b/platform/macos/rendering_context_driver_vulkan_macos.mm @@ -50,7 +50,7 @@ RenderingContextDriver::SurfaceID RenderingContextDriverVulkanMacOS::surface_cre create_info.pLayer = *wpd->layer_ptr; VkSurfaceKHR vk_surface = VK_NULL_HANDLE; - VkResult err = vkCreateMetalSurfaceEXT(instance_get(), &create_info, nullptr, &vk_surface); + VkResult err = vkCreateMetalSurfaceEXT(instance_get(), &create_info, get_allocation_callbacks(VK_OBJECT_TYPE_SURFACE_KHR), &vk_surface); ERR_FAIL_COND_V(err != VK_SUCCESS, SurfaceID()); Surface *surface = memnew(Surface); diff --git a/platform/web/audio_driver_web.cpp b/platform/web/audio_driver_web.cpp index dd986e650c..0108f40726 100644 --- a/platform/web/audio_driver_web.cpp +++ b/platform/web/audio_driver_web.cpp @@ -33,6 +33,8 @@ #include "godot_audio.h" #include "core/config/project_settings.h" +#include "core/object/object.h" +#include "scene/main/node.h" #include "servers/audio/audio_stream.h" #include <emscripten.h> @@ -51,6 +53,21 @@ void AudioDriverWeb::_latency_update_callback(float p_latency) { AudioDriverWeb::audio_context.output_latency = p_latency; } +void AudioDriverWeb::_sample_playback_finished_callback(const char *p_playback_object_id) { + const ObjectID playback_id = ObjectID(String::to_int(p_playback_object_id)); + + Object *playback_object = ObjectDB::get_instance(playback_id); + if (playback_object == nullptr) { + return; + } + Ref<AudioSamplePlayback> playback = Object::cast_to<AudioSamplePlayback>(playback_object); + if (playback.is_null()) { + return; + } + + AudioServer::get_singleton()->stop_sample_playback(playback); +} + void AudioDriverWeb::_audio_driver_process(int p_from, int p_samples) { int32_t *stream_buffer = reinterpret_cast<int32_t *>(output_rb); const int max_samples = memarr_len(output_rb); @@ -132,6 +149,9 @@ Error AudioDriverWeb::init() { if (!input_rb) { return ERR_OUT_OF_MEMORY; } + + godot_audio_sample_set_finished_callback(&_sample_playback_finished_callback); + return OK; } @@ -274,6 +294,7 @@ void AudioDriverWeb::start_sample_playback(const Ref<AudioSamplePlayback> &p_pla itos(p_playback->stream->get_instance_id()).utf8().get_data(), AudioServer::get_singleton()->get_bus_index(p_playback->bus), p_playback->offset, + p_playback->pitch_scale, volume_ptrw); } @@ -292,6 +313,11 @@ bool AudioDriverWeb::is_sample_playback_active(const Ref<AudioSamplePlayback> &p return godot_audio_sample_is_active(itos(p_playback->get_instance_id()).utf8().get_data()) != 0; } +double AudioDriverWeb::get_sample_playback_position(const Ref<AudioSamplePlayback> &p_playback) { + ERR_FAIL_COND_V_MSG(p_playback.is_null(), false, "Parameter p_playback is null."); + return godot_audio_get_sample_playback_position(itos(p_playback->get_instance_id()).utf8().get_data()); +} + void AudioDriverWeb::update_sample_playback_pitch_scale(const Ref<AudioSamplePlayback> &p_playback, float p_pitch_scale) { ERR_FAIL_COND_MSG(p_playback.is_null(), "Parameter p_playback is null."); godot_audio_sample_update_pitch_scale( diff --git a/platform/web/audio_driver_web.h b/platform/web/audio_driver_web.h index 298ad90fae..d352fa4692 100644 --- a/platform/web/audio_driver_web.h +++ b/platform/web/audio_driver_web.h @@ -58,6 +58,7 @@ private: WASM_EXPORT static void _state_change_callback(int p_state); WASM_EXPORT static void _latency_update_callback(float p_latency); + WASM_EXPORT static void _sample_playback_finished_callback(const char *p_playback_object_id); static AudioDriverWeb *singleton; @@ -95,6 +96,7 @@ public: virtual void stop_sample_playback(const Ref<AudioSamplePlayback> &p_playback) override; virtual void set_sample_playback_pause(const Ref<AudioSamplePlayback> &p_playback, bool p_paused) override; virtual bool is_sample_playback_active(const Ref<AudioSamplePlayback> &p_playback) override; + virtual double get_sample_playback_position(const Ref<AudioSamplePlayback> &p_playback) override; virtual void update_sample_playback_pitch_scale(const Ref<AudioSamplePlayback> &p_playback, float p_pitch_scale = 0.0f) override; virtual void set_sample_playback_bus_volumes_linear(const Ref<AudioSamplePlayback> &p_playback, const HashMap<StringName, Vector<AudioFrame>> &p_bus_volumes) override; diff --git a/platform/web/detect.py b/platform/web/detect.py index cb4dac1125..bf75c2f9fc 100644 --- a/platform/web/detect.py +++ b/platform/web/detect.py @@ -78,6 +78,7 @@ def get_flags(): # -Os reduces file size by around 5 MiB over -O3. -Oz only saves about # 100 KiB over -Os, which does not justify the negative impact on # run-time performance. + # Note that this overrides the "auto" behavior for target/dev_build. "optimize": "size", } @@ -226,6 +227,11 @@ def configure(env: "SConsEnvironment"): env.Append(LINKFLAGS=["-sDEFAULT_PTHREAD_STACK_SIZE=%sKB" % env["default_pthread_stack_size"]]) env.Append(LINKFLAGS=["-sPTHREAD_POOL_SIZE=8"]) env.Append(LINKFLAGS=["-sWASM_MEM_MAX=2048MB"]) + if not env["dlink_enabled"]: + # Workaround https://github.com/emscripten-core/emscripten/issues/21844#issuecomment-2116936414. + # Not needed (and potentially dangerous) when dlink_enabled=yes, since we set EXPORT_ALL=1 in that case. + env.Append(LINKFLAGS=["-sEXPORTED_FUNCTIONS=['__emscripten_thread_crashed','_main']"]) + elif env["proxy_to_pthread"]: print_warning('"threads=no" support requires "proxy_to_pthread=no", disabling proxy to pthread.') env["proxy_to_pthread"] = False diff --git a/platform/web/display_server_web.cpp b/platform/web/display_server_web.cpp index 40de4e523b..4e55cc137a 100644 --- a/platform/web/display_server_web.cpp +++ b/platform/web/display_server_web.cpp @@ -902,8 +902,10 @@ void DisplayServerWeb::process_joypads() { for (int b = 0; b < s_btns_num; b++) { // Buttons 6 and 7 in the standard mapping need to be // axis to be handled as JoyAxis::TRIGGER by Godot. - if (s_standard && (b == 6 || b == 7)) { - input->joy_axis(idx, (JoyAxis)b, s_btns[b]); + if (s_standard && (b == 6)) { + input->joy_axis(idx, JoyAxis::TRIGGER_LEFT, s_btns[b]); + } else if (s_standard && (b == 7)) { + input->joy_axis(idx, JoyAxis::TRIGGER_RIGHT, s_btns[b]); } else { input->joy_button(idx, (JoyButton)b, s_btns[b]); } diff --git a/platform/web/emscripten_helpers.py b/platform/web/emscripten_helpers.py index 2cee3e8110..8fcabb21c7 100644 --- a/platform/web/emscripten_helpers.py +++ b/platform/web/emscripten_helpers.py @@ -51,11 +51,13 @@ def create_template_zip(env, js, wasm, worker, side): js, wasm, "#platform/web/js/libs/audio.worklet.js", + "#platform/web/js/libs/audio.position.worklet.js", ] out_files = [ zip_dir.File(binary_name + ".js"), zip_dir.File(binary_name + ".wasm"), zip_dir.File(binary_name + ".audio.worklet.js"), + zip_dir.File(binary_name + ".audio.position.worklet.js"), ] if env["threads"]: in_files.append(worker) @@ -74,6 +76,7 @@ def create_template_zip(env, js, wasm, worker, side): "offline.html", "godot.editor.js", "godot.editor.audio.worklet.js", + "godot.editor.audio.position.worklet.js", "logo.svg", "favicon.png", ] diff --git a/platform/web/export/export.cpp b/platform/web/export/export.cpp index 168310c078..306ec624a0 100644 --- a/platform/web/export/export.cpp +++ b/platform/web/export/export.cpp @@ -40,7 +40,6 @@ void register_web_exporter_types() { } void register_web_exporter() { -#ifndef ANDROID_ENABLED EDITOR_DEF("export/web/http_host", "localhost"); EDITOR_DEF("export/web/http_port", 8060); EDITOR_DEF("export/web/use_tls", false); @@ -49,7 +48,6 @@ void register_web_exporter() { EditorSettings::get_singleton()->add_property_hint(PropertyInfo(Variant::INT, "export/web/http_port", PROPERTY_HINT_RANGE, "1,65535,1")); EditorSettings::get_singleton()->add_property_hint(PropertyInfo(Variant::STRING, "export/web/tls_key", PROPERTY_HINT_GLOBAL_FILE, "*.key")); EditorSettings::get_singleton()->add_property_hint(PropertyInfo(Variant::STRING, "export/web/tls_certificate", PROPERTY_HINT_GLOBAL_FILE, "*.crt,*.pem")); -#endif Ref<EditorExportPlatformWeb> platform; platform.instantiate(); diff --git a/platform/web/export/export_plugin.cpp b/platform/web/export/export_plugin.cpp index d83e465e8e..d8c1b6033d 100644 --- a/platform/web/export/export_plugin.cpp +++ b/platform/web/export/export_plugin.cpp @@ -242,6 +242,7 @@ Error EditorExportPlatformWeb::_build_pwa(const Ref<EditorExportPreset> &p_prese } cache_files.push_back(name + ".worker.js"); cache_files.push_back(name + ".audio.worklet.js"); + cache_files.push_back(name + ".audio.position.worklet.js"); replaces["___GODOT_CACHE___"] = Variant(cache_files).to_json_string(); // Heavy files that are cached on demand. @@ -835,6 +836,7 @@ Error EditorExportPlatformWeb::_export_project(const Ref<EditorExportPreset> &p_ DirAccess::remove_file_or_error(basepath + ".js"); DirAccess::remove_file_or_error(basepath + ".worker.js"); DirAccess::remove_file_or_error(basepath + ".audio.worklet.js"); + DirAccess::remove_file_or_error(basepath + ".audio.position.worklet.js"); DirAccess::remove_file_or_error(basepath + ".service.worker.js"); DirAccess::remove_file_or_error(basepath + ".pck"); DirAccess::remove_file_or_error(basepath + ".png"); diff --git a/platform/web/godot_audio.h b/platform/web/godot_audio.h index 8bebbcf7de..f5a2a85605 100644 --- a/platform/web/godot_audio.h +++ b/platform/web/godot_audio.h @@ -51,12 +51,14 @@ extern void godot_audio_input_stop(); extern int godot_audio_sample_stream_is_registered(const char *p_stream_object_id); extern void godot_audio_sample_register_stream(const char *p_stream_object_id, float *p_frames_buf, int p_frames_total, const char *p_loop_mode, int p_loop_begin, int p_loop_end); extern void godot_audio_sample_unregister_stream(const char *p_stream_object_id); -extern void godot_audio_sample_start(const char *p_playback_object_id, const char *p_stream_object_id, int p_bus_index, float p_offset, float *p_volume_ptr); +extern void godot_audio_sample_start(const char *p_playback_object_id, const char *p_stream_object_id, int p_bus_index, float p_offset, float p_pitch_scale, float *p_volume_ptr); extern void godot_audio_sample_stop(const char *p_playback_object_id); extern void godot_audio_sample_set_pause(const char *p_playback_object_id, bool p_pause); extern int godot_audio_sample_is_active(const char *p_playback_object_id); +extern double godot_audio_get_sample_playback_position(const char *p_playback_object_id); extern void godot_audio_sample_update_pitch_scale(const char *p_playback_object_id, float p_pitch_scale); extern void godot_audio_sample_set_volumes_linear(const char *p_playback_object_id, int *p_buses_buf, int p_buses_size, float *p_volumes_buf, int p_volumes_size); +extern void godot_audio_sample_set_finished_callback(void (*p_callback)(const char *)); extern void godot_audio_sample_bus_set_count(int p_count); extern void godot_audio_sample_bus_remove(int p_index); diff --git a/platform/web/http_client_web.cpp b/platform/web/http_client_web.cpp index ea9226a5a4..80257dc295 100644 --- a/platform/web/http_client_web.cpp +++ b/platform/web/http_client_web.cpp @@ -266,11 +266,11 @@ Error HTTPClientWeb::poll() { return OK; } -HTTPClient *HTTPClientWeb::_create_func() { - return memnew(HTTPClientWeb); +HTTPClient *HTTPClientWeb::_create_func(bool p_notify_postinitialize) { + return static_cast<HTTPClient *>(ClassDB::creator<HTTPClientWeb>(p_notify_postinitialize)); } -HTTPClient *(*HTTPClient::_create)() = HTTPClientWeb::_create_func; +HTTPClient *(*HTTPClient::_create)(bool p_notify_postinitialize) = HTTPClientWeb::_create_func; HTTPClientWeb::HTTPClientWeb() { } diff --git a/platform/web/http_client_web.h b/platform/web/http_client_web.h index 4d3c457a7d..f696c5a5b0 100644 --- a/platform/web/http_client_web.h +++ b/platform/web/http_client_web.h @@ -81,7 +81,7 @@ private: static void _parse_headers(int p_len, const char **p_headers, void *p_ref); public: - static HTTPClient *_create_func(); + static HTTPClient *_create_func(bool p_notify_postinitialize); Error request(Method p_method, const String &p_url, const Vector<String> &p_headers, const uint8_t *p_body, int p_body_size) override; diff --git a/platform/web/js/engine/config.js b/platform/web/js/engine/config.js index 8c4e1b1b24..61b488cf81 100644 --- a/platform/web/js/engine/config.js +++ b/platform/web/js/engine/config.js @@ -299,6 +299,8 @@ const InternalConfig = function (initConfig) { // eslint-disable-line no-unused- return `${loadPath}.worker.js`; } else if (path.endsWith('.audio.worklet.js')) { return `${loadPath}.audio.worklet.js`; + } else if (path.endsWith('.audio.position.worklet.js')) { + return `${loadPath}.audio.position.worklet.js`; } else if (path.endsWith('.js')) { return `${loadPath}.js`; } else if (path in gdext) { diff --git a/platform/android/java/lib/src/org/godotengine/godot/io/file/FileErrors.kt b/platform/web/js/libs/audio.position.worklet.js index 2df0195de7..bf3ac4ae2d 100644 --- a/platform/android/java/lib/src/org/godotengine/godot/io/file/FileErrors.kt +++ b/platform/web/js/libs/audio.position.worklet.js @@ -1,5 +1,5 @@ /**************************************************************************/ -/* FileErrors.kt */ +/* godot.audio.position.worklet.js */ /**************************************************************************/ /* This file is part of: */ /* GODOT ENGINE */ @@ -28,26 +28,23 @@ /* SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ /**************************************************************************/ -package org.godotengine.godot.io.file - -/** - * Set of errors that may occur when performing data access. - */ -internal enum class FileErrors(val nativeValue: Int) { - OK(0), - FAILED(-1), - FILE_NOT_FOUND(-2), - FILE_CANT_OPEN(-3), - INVALID_PARAMETER(-4); +class GodotPositionReportingProcessor extends AudioWorkletProcessor { + constructor() { + super(); + this.position = 0; + } - companion object { - fun fromNativeError(error: Int): FileErrors? { - for (fileError in entries) { - if (fileError.nativeValue == error) { - return fileError - } + process(inputs, _outputs, _parameters) { + if (inputs.length > 0) { + const input = inputs[0]; + if (input.length > 0) { + this.position += input[0].length; + this.port.postMessage({ 'type': 'position', 'data': this.position }); + return true; } - return null } + return true; } } + +registerProcessor('godot-position-reporting-processor', GodotPositionReportingProcessor); diff --git a/platform/web/js/libs/library_godot_audio.js b/platform/web/js/libs/library_godot_audio.js index 531dbdaeab..40fb0c356c 100644 --- a/platform/web/js/libs/library_godot_audio.js +++ b/platform/web/js/libs/library_godot_audio.js @@ -77,7 +77,7 @@ class Sample { * Creates a `Sample` based on the params. Will register it to the * `GodotAudio.samples` registry. * @param {SampleParams} params Base params - * @param {SampleOptions} [options={}] Optional params + * @param {SampleOptions} [options={{}}] Optional params * @returns {Sample} */ static create(params, options = {}) { @@ -98,8 +98,7 @@ class Sample { /** * `Sample` constructor. * @param {SampleParams} params Base params - * @param {SampleOptions} [options={}] Optional params - * @constructor + * @param {SampleOptions} [options={{}}] Optional params */ constructor(params, options = {}) { /** @type {string} */ @@ -154,7 +153,7 @@ class Sample { if (this._audioBuffer == null) { throw new Error('couldn\'t duplicate a null audioBuffer'); } - /** @type {Float32Array[]} */ + /** @type {Array<Float32Array>} */ const channels = new Array(this._audioBuffer.numberOfChannels); for (let i = 0; i < this._audioBuffer.numberOfChannels; i++) { const channel = new Float32Array(this._audioBuffer.getChannelData(i)); @@ -189,7 +188,6 @@ class SampleNodeBus { /** * `SampleNodeBus` constructor. * @param {Bus} bus The bus related to the new `SampleNodeBus`. - * @constructor */ constructor(bus) { const NUMBER_OF_WEB_CHANNELS = 6; @@ -330,8 +328,10 @@ class SampleNodeBus { * offset?: number * playbackRate?: number * startTime?: number + * pitchScale?: number * loopMode?: LoopMode * volume?: Float32Array + * start?: boolean * }} SampleNodeOptions */ @@ -413,8 +413,7 @@ class SampleNode { /** * @param {SampleNodeParams} params Base params - * @param {SampleNodeOptions} [options={}] Optional params - * @constructor + * @param {SampleNodeOptions} [options={{}}] Optional params */ constructor(params, options = {}) { /** @type {string} */ @@ -424,9 +423,15 @@ class SampleNode { /** @type {number} */ this.offset = options.offset ?? 0; /** @type {number} */ + this._playbackPosition = options.offset; + /** @type {number} */ this.startTime = options.startTime ?? 0; /** @type {boolean} */ this.isPaused = false; + /** @type {boolean} */ + this.isStarted = false; + /** @type {boolean} */ + this.isCanceled = false; /** @type {number} */ this.pauseTime = 0; /** @type {number} */ @@ -434,15 +439,17 @@ class SampleNode { /** @type {LoopMode} */ this.loopMode = options.loopMode ?? this.getSample().loopMode ?? 'disabled'; /** @type {number} */ - this._pitchScale = 1; + this._pitchScale = options.pitchScale ?? 1; /** @type {number} */ this._sourceStartTime = 0; /** @type {Map<Bus, SampleNodeBus>} */ this._sampleNodeBuses = new Map(); /** @type {AudioBufferSourceNode | null} */ this._source = GodotAudio.ctx.createBufferSource(); - /** @type {AudioBufferSourceNode["onended"]} */ + this._onended = null; + /** @type {AudioWorkletNode | null} */ + this._positionWorklet = null; this.setPlaybackRate(options.playbackRate ?? 44100); this._source.buffer = this.getSample().getAudioBuffer(); @@ -452,6 +459,8 @@ class SampleNode { const bus = GodotAudio.Bus.getBus(params.busIndex); const sampleNodeBus = this.getSampleNodeBus(bus); sampleNodeBus.setVolume(options.volume); + + this.connectPositionWorklet(options.start); } /** @@ -463,6 +472,14 @@ class SampleNode { } /** + * Gets the playback position. + * @returns {number} + */ + getPlaybackPosition() { + return this._playbackPosition; + } + + /** * Sets the playback rate. * @param {number} val Value to set. * @returns {void} @@ -511,8 +528,12 @@ class SampleNode { * @returns {void} */ start() { + if (this.isStarted) { + return; + } this._resetSourceStartTime(); this._source.start(this.startTime, this.offset); + this.isStarted = true; } /** @@ -558,7 +579,7 @@ class SampleNode { /** * Sets the volumes of the `SampleNode` for each buses passed in parameters. - * @param {Bus[]} buses + * @param {Array<Bus>} buses * @param {Float32Array} volumes */ setVolumes(buses, volumes) { @@ -588,17 +609,73 @@ class SampleNode { } /** + * Sets up and connects the source to the GodotPositionReportingProcessor + * If the worklet module is not loaded in, it will be added + */ + connectPositionWorklet(start) { + try { + this._positionWorklet = this.createPositionWorklet(); + this._source.connect(this._positionWorklet); + if (start) { + this.start(); + } + } catch (error) { + if (error?.name !== 'InvalidStateError') { + throw error; + } + const path = GodotConfig.locate_file('godot.audio.position.worklet.js'); + GodotAudio.ctx.audioWorklet + .addModule(path) + .then(() => { + if (!this.isCanceled) { + this._positionWorklet = this.createPositionWorklet(); + this._source.connect(this._positionWorklet); + if (start) { + this.start(); + } + } + }).catch((addModuleError) => { + GodotRuntime.error('Failed to create PositionWorklet.', addModuleError); + }); + } + } + + /** + * Creates the AudioWorkletProcessor used to track playback position. + * @returns {AudioWorkletNode} + */ + createPositionWorklet() { + const worklet = new AudioWorkletNode( + GodotAudio.ctx, + 'godot-position-reporting-processor' + ); + worklet.port.onmessage = (event) => { + switch (event.data['type']) { + case 'position': + this._playbackPosition = (parseInt(event.data.data, 10) / this.getSample().sampleRate) + this.offset; + break; + default: + // Do nothing. + } + }; + return worklet; + } + + /** * Clears the `SampleNode`. * @returns {void} */ clear() { + this.isCanceled = true; this.isPaused = false; this.pauseTime = 0; if (this._source != null) { this._source.removeEventListener('ended', this._onended); this._onended = null; - this._source.stop(); + if (this.isStarted) { + this._source.stop(); + } this._source.disconnect(); this._source = null; } @@ -608,6 +685,12 @@ class SampleNode { } this._sampleNodeBuses.clear(); + if (this._positionWorklet) { + this._positionWorklet.disconnect(); + this._positionWorklet.port.onmessage = null; + this._positionWorklet = null; + } + GodotAudio.SampleNode.delete(this.id); } @@ -633,7 +716,9 @@ class SampleNode { * @returns {void} */ _restart() { - this._source.disconnect(); + if (this._source != null) { + this._source.disconnect(); + } this._source = GodotAudio.ctx.createBufferSource(); this._source.buffer = this.getSample().getAudioBuffer(); @@ -646,7 +731,9 @@ class SampleNode { const pauseTime = this.isPaused ? this.pauseTime : 0; + this.connectPositionWorklet(); this._source.start(this.startTime, this.offset + pauseTime); + this.isStarted = true; } /** @@ -687,9 +774,15 @@ class SampleNode { } switch (self.getSample().loopMode) { - case 'disabled': + case 'disabled': { + const id = this.id; self.stop(); - break; + if (GodotAudio.sampleFinishedCallback != null) { + const idCharPtr = GodotRuntime.allocString(id); + GodotAudio.sampleFinishedCallback(idCharPtr); + GodotRuntime.free(idCharPtr); + } + } break; case 'forward': case 'backward': self.restart(); @@ -812,7 +905,6 @@ class Bus { /** * `Bus` constructor. - * @constructor */ constructor() { /** @type {Set<SampleNode>} */ @@ -856,7 +948,10 @@ class Bus { * @returns {void} */ setVolumeDb(val) { - this._gainNode.gain.value = GodotAudio.db_to_linear(val); + const linear = GodotAudio.db_to_linear(val); + if (isFinite(linear)) { + this._gainNode.gain.value = linear; + } } /** @@ -979,7 +1074,6 @@ class Bus { GodotAudio.buses = GodotAudio.buses.filter((v) => v !== this); } - /** @type {Bus["prototype"]["_syncSampleNodes"]} */ _syncSampleNodes() { const sampleNodes = Array.from(this._sampleNodes); for (let i = 0; i < sampleNodes.length; i++) { @@ -1080,7 +1174,7 @@ const _GodotAudio = { // `Bus` class /** * Registry of `Bus`es. - * @type {Bus[]} + * @type {Array<Bus>} */ buses: null, /** @@ -1090,6 +1184,12 @@ const _GodotAudio = { busSolo: null, Bus, + /** + * Callback to signal that a sample has finished. + * @type {(playbackObjectIdPtr: number) => void | null} + */ + sampleFinishedCallback: null, + /** @type {AudioContext} */ ctx: null, input: null, @@ -1250,7 +1350,7 @@ const _GodotAudio = { startOptions ) { GodotAudio.SampleNode.stopSampleNode(playbackObjectId); - const sampleNode = GodotAudio.SampleNode.create( + GodotAudio.SampleNode.create( { busIndex, id: playbackObjectId, @@ -1258,7 +1358,6 @@ const _GodotAudio = { }, startOptions ); - sampleNode.start(); }, /** @@ -1297,7 +1396,7 @@ const _GodotAudio = { /** * Triggered when a sample node volumes need to be updated. * @param {string} playbackObjectId Id of the sample playback - * @param {number[]} busIndexes Indexes of the buses that need to be updated + * @param {Array<number>} busIndexes Indexes of the buses that need to be updated * @param {Float32Array} volumes Array of the volumes * @returns {void} */ @@ -1550,13 +1649,14 @@ const _GodotAudio = { }, godot_audio_sample_start__proxy: 'sync', - godot_audio_sample_start__sig: 'viiiii', + godot_audio_sample_start__sig: 'viiiifi', /** * Starts a sample. * @param {number} playbackObjectIdStrPtr Playback object id pointer * @param {number} streamObjectIdStrPtr Stream object id pointer * @param {number} busIndex Bus index * @param {number} offset Sample offset + * @param {number} pitchScale Pitch scale * @param {number} volumePtr Volume pointer * @returns {void} */ @@ -1565,6 +1665,7 @@ const _GodotAudio = { streamObjectIdStrPtr, busIndex, offset, + pitchScale, volumePtr ) { /** @type {string} */ @@ -1573,11 +1674,13 @@ const _GodotAudio = { const streamObjectId = GodotRuntime.parseString(streamObjectIdStrPtr); /** @type {Float32Array} */ const volume = GodotRuntime.heapSub(HEAPF32, volumePtr, 8); - /** @type {SampleNodeConstructorOptions} */ + /** @type {SampleNodeOptions} */ const startOptions = { offset, volume, playbackRate: 1, + pitchScale, + start: true, }; GodotAudio.start_sample( playbackObjectId, @@ -1623,6 +1726,22 @@ const _GodotAudio = { return Number(GodotAudio.sampleNodes.has(playbackObjectId)); }, + godot_audio_get_sample_playback_position__proxy: 'sync', + godot_audio_get_sample_playback_position__sig: 'di', + /** + * Returns the position of the playback position. + * @param {number} playbackObjectIdStrPtr Playback object id pointer + * @returns {number} + */ + godot_audio_get_sample_playback_position: function (playbackObjectIdStrPtr) { + const playbackObjectId = GodotRuntime.parseString(playbackObjectIdStrPtr); + const sampleNode = GodotAudio.SampleNode.getSampleNodeOrNull(playbackObjectId); + if (sampleNode == null) { + return 0; + } + return sampleNode.getPlaybackPosition(); + }, + godot_audio_sample_update_pitch_scale__proxy: 'sync', godot_audio_sample_update_pitch_scale__sig: 'vii', /** @@ -1764,6 +1883,17 @@ const _GodotAudio = { godot_audio_sample_bus_set_mute: function (bus, enable) { GodotAudio.set_sample_bus_mute(bus, Boolean(enable)); }, + + godot_audio_sample_set_finished_callback__proxy: 'sync', + godot_audio_sample_set_finished_callback__sig: 'vi', + /** + * Sets the finished callback + * @param {Number} callbackPtr Finished callback pointer + * @returns {void} + */ + godot_audio_sample_set_finished_callback: function (callbackPtr) { + GodotAudio.sampleFinishedCallback = GodotRuntime.get_func(callbackPtr); + }, }; autoAddDeps(_GodotAudio, '$GodotAudio'); diff --git a/platform/web/js/libs/library_godot_input.js b/platform/web/js/libs/library_godot_input.js index 7ea89d553f..6e3b97023d 100644 --- a/platform/web/js/libs/library_godot_input.js +++ b/platform/web/js/libs/library_godot_input.js @@ -112,6 +112,7 @@ const GodotIME = { ime.style.top = '0px'; ime.style.width = '100%'; ime.style.height = '40px'; + ime.style.pointerEvents = 'none'; ime.style.display = 'none'; ime.contentEditable = 'true'; diff --git a/platform/web/js/libs/library_godot_javascript_singleton.js b/platform/web/js/libs/library_godot_javascript_singleton.js index b17fde1544..6bb69bca95 100644 --- a/platform/web/js/libs/library_godot_javascript_singleton.js +++ b/platform/web/js/libs/library_godot_javascript_singleton.js @@ -81,11 +81,16 @@ const GodotJSWrapper = { case 0: return null; case 1: - return !!GodotRuntime.getHeapValue(val, 'i64'); - case 2: - return GodotRuntime.getHeapValue(val, 'i64'); + return Boolean(GodotRuntime.getHeapValue(val, 'i64')); + case 2: { + // `heap_value` may be a bigint. + const heap_value = GodotRuntime.getHeapValue(val, 'i64'); + return heap_value >= Number.MIN_SAFE_INTEGER && heap_value <= Number.MAX_SAFE_INTEGER + ? Number(heap_value) + : heap_value; + } case 3: - return GodotRuntime.getHeapValue(val, 'double'); + return Number(GodotRuntime.getHeapValue(val, 'double')); case 4: return GodotRuntime.parseString(GodotRuntime.getHeapValue(val, '*')); case 24: // OBJECT @@ -110,6 +115,9 @@ const GodotJSWrapper = { } GodotRuntime.setHeapValue(p_exchange, p_val, 'double'); return 3; // FLOAT + } else if (type === 'bigint') { + GodotRuntime.setHeapValue(p_exchange, p_val, 'i64'); + return 2; // INT } else if (type === 'string') { const c_str = GodotRuntime.allocString(p_val); GodotRuntime.setHeapValue(p_exchange, c_str, '*'); diff --git a/platform/web/serve.py b/platform/web/serve.py index f0b0ec9622..4e1521449b 100755 --- a/platform/web/serve.py +++ b/platform/web/serve.py @@ -6,7 +6,7 @@ import os import socket import subprocess import sys -from http.server import HTTPServer, SimpleHTTPRequestHandler, test # type: ignore +from http.server import HTTPServer, SimpleHTTPRequestHandler from pathlib import Path @@ -38,12 +38,24 @@ def shell_open(url): def serve(root, port, run_browser): os.chdir(root) + address = ("", port) + httpd = DualStackServer(address, CORSRequestHandler) + + url = f"http://127.0.0.1:{port}" if run_browser: # Open the served page in the user's default browser. - print("Opening the served URL in the default browser (use `--no-browser` or `-n` to disable this).") - shell_open(f"http://127.0.0.1:{port}") + print(f"Opening the served URL in the default browser (use `--no-browser` or `-n` to disable this): {url}") + shell_open(url) + else: + print(f"Serving at: {url}") - test(CORSRequestHandler, DualStackServer, port=port) + try: + httpd.serve_forever() + except KeyboardInterrupt: + print("\nKeyboard interrupt received, stopping server.") + finally: + # Clean-up server + httpd.server_close() if __name__ == "__main__": diff --git a/platform/web/web_main.cpp b/platform/web/web_main.cpp index 04513f6d57..d0c3bd7c0e 100644 --- a/platform/web/web_main.cpp +++ b/platform/web/web_main.cpp @@ -35,6 +35,8 @@ #include "core/config/engine.h" #include "core/io/resource_loader.h" #include "main/main.h" +#include "scene/main/scene_tree.h" +#include "scene/main/window.h" // SceneTree only forward declares it. #include <emscripten/emscripten.h> #include <stdlib.h> @@ -130,7 +132,7 @@ extern EMSCRIPTEN_KEEPALIVE int godot_web_main(int argc, char *argv[]) { if (Engine::get_singleton()->is_project_manager_hint() && FileAccess::exists("/tmp/preload.zip")) { PackedStringArray ps; ps.push_back("/tmp/preload.zip"); - os->get_main_loop()->emit_signal(SNAME("files_dropped"), ps, -1); + SceneTree::get_singleton()->get_root()->emit_signal(SNAME("files_dropped"), ps); } #endif emscripten_set_main_loop(main_loop_callback, -1, false); diff --git a/platform/windows/SCsub b/platform/windows/SCsub index f2fb8616ae..f8ed8b73f5 100644 --- a/platform/windows/SCsub +++ b/platform/windows/SCsub @@ -108,18 +108,6 @@ if env["d3d12"]: # Used in cases where we can have multiple archs side-by-side. arch_bin_dir = "#bin/" + env["arch"] - # DXC - if env["dxc_path"] != "" and os.path.exists(env["dxc_path"]): - dxc_dll = "dxil.dll" - # Whether this one is loaded from arch-specific directory or not can be determined at runtime. - # Let's copy to both and let the user decide the distribution model. - for v in ["#bin", arch_bin_dir]: - env.Command( - v + "/" + dxc_dll, - env["dxc_path"] + "/bin/" + dxc_arch_subdir + "/" + dxc_dll, - Copy("$TARGET", "$SOURCE"), - ) - # Agility SDK if env["agility_sdk_path"] != "" and os.path.exists(env["agility_sdk_path"]): agility_dlls = ["D3D12Core.dll", "d3d12SDKLayers.dll"] diff --git a/platform/windows/detect.py b/platform/windows/detect.py index fee306a25c..11dd4548f1 100644 --- a/platform/windows/detect.py +++ b/platform/windows/detect.py @@ -214,11 +214,6 @@ def get_opts(): os.path.join(d3d12_deps_folder, "mesa"), ), ( - "dxc_path", - "Path to the DirectX Shader Compiler distribution (required for D3D12)", - os.path.join(d3d12_deps_folder, "dxc"), - ), - ( "agility_sdk_path", "Path to the Agility SDK distribution (optional for D3D12)", os.path.join(d3d12_deps_folder, "agility_sdk"), @@ -252,7 +247,7 @@ def get_flags(): return { "arch": arch, - "supported": ["mono"], + "supported": ["d3d12", "mono", "xaudio2"], } @@ -306,7 +301,6 @@ def setup_msvc_manual(env: "SConsEnvironment"): print("Using VCVARS-determined MSVC, arch %s" % (env_arch)) -# FIXME: Likely overwrites command-line options for the msvc compiler. See #91883. def setup_msvc_auto(env: "SConsEnvironment"): """Set up MSVC using SCons's auto-detection logic""" @@ -339,6 +333,12 @@ def setup_msvc_auto(env: "SConsEnvironment"): env.Tool("msvc") env.Tool("mssdk") # we want the MS SDK + # Re-add potentially overwritten flags. + env.AppendUnique(CCFLAGS=env.get("ccflags", "").split()) + env.AppendUnique(CXXFLAGS=env.get("cxxflags", "").split()) + env.AppendUnique(CFLAGS=env.get("cflags", "").split()) + env.AppendUnique(RCFLAGS=env.get("rcflags", "").split()) + # Note: actual compiler version can be found in env['MSVC_VERSION'], e.g. "14.1" for VS2015 print("Using SCons-detected MSVC version %s, arch %s" % (env["MSVC_VERSION"], env["arch"])) @@ -467,6 +467,8 @@ def configure_msvc(env: "SConsEnvironment", vcvars_msvc_config): if env["arch"] == "x86_32": env["x86_libtheora_opt_vc"] = True + env.Append(CCFLAGS=["/fp:strict"]) + env.AppendUnique(CCFLAGS=["/Gd", "/GR", "/nologo"]) env.AppendUnique(CCFLAGS=["/utf-8"]) # Force to use Unicode encoding. env.AppendUnique(CXXFLAGS=["/TP"]) # assume all sources are C++ @@ -644,7 +646,8 @@ def configure_mingw(env: "SConsEnvironment"): # TODO: Re-evaluate the need for this / streamline with common config. if env["target"] == "template_release": - env.Append(CCFLAGS=["-msse2"]) + if env["arch"] != "arm64": + env.Append(CCFLAGS=["-msse2"]) elif env.dev_build: # Allow big objects. It's supposed not to have drawbacks but seems to break # GCC LTO, so enabling for debug builds only (which are not built with LTO @@ -674,6 +677,8 @@ def configure_mingw(env: "SConsEnvironment"): if env["arch"] in ["x86_32", "x86_64"]: env["x86_libtheora_opt_gcc"] = True + env.Append(CCFLAGS=["-ffp-contract=off"]) + mingw_bin_prefix = get_mingw_bin_prefix(env["mingw_prefix"], env["arch"]) if env["use_llvm"]: diff --git a/platform/windows/display_server_windows.cpp b/platform/windows/display_server_windows.cpp index 8d26a705a9..270112e624 100644 --- a/platform/windows/display_server_windows.cpp +++ b/platform/windows/display_server_windows.cpp @@ -38,6 +38,7 @@ #include "core/version.h" #include "drivers/png/png_driver_common.h" #include "main/main.h" +#include "scene/resources/texture.h" #if defined(VULKAN_ENABLED) #include "rendering_context_driver_vulkan_windows.h" @@ -132,9 +133,17 @@ String DisplayServerWindows::get_name() const { } void DisplayServerWindows::_set_mouse_mode_impl(MouseMode p_mode) { + if (p_mode == MOUSE_MODE_HIDDEN || p_mode == MOUSE_MODE_CAPTURED || p_mode == MOUSE_MODE_CONFINED_HIDDEN) { + // Hide cursor before moving. + if (hCursor == nullptr) { + hCursor = SetCursor(nullptr); + } else { + SetCursor(nullptr); + } + } + if (windows.has(MAIN_WINDOW_ID) && (p_mode == MOUSE_MODE_CAPTURED || p_mode == MOUSE_MODE_CONFINED || p_mode == MOUSE_MODE_CONFINED_HIDDEN)) { // Mouse is grabbed (captured or confined). - WindowID window_id = _get_focused_window_or_popup(); if (!windows.has(window_id)) { window_id = MAIN_WINDOW_ID; @@ -164,13 +173,8 @@ void DisplayServerWindows::_set_mouse_mode_impl(MouseMode p_mode) { _register_raw_input_devices(INVALID_WINDOW_ID); } - if (p_mode == MOUSE_MODE_HIDDEN || p_mode == MOUSE_MODE_CAPTURED || p_mode == MOUSE_MODE_CONFINED_HIDDEN) { - if (hCursor == nullptr) { - hCursor = SetCursor(nullptr); - } else { - SetCursor(nullptr); - } - } else { + if (p_mode == MOUSE_MODE_VISIBLE || p_mode == MOUSE_MODE_CONFINED) { + // Show cursor. CursorShape c = cursor_shape; cursor_shape = CURSOR_MAX; cursor_set_shape(c); @@ -249,6 +253,14 @@ void DisplayServerWindows::tts_stop() { tts->stop(); } +Error DisplayServerWindows::file_dialog_show(const String &p_title, const String &p_current_directory, const String &p_filename, bool p_show_hidden, FileDialogMode p_mode, const Vector<String> &p_filters, const Callable &p_callback) { + return _file_dialog_with_options_show(p_title, p_current_directory, String(), p_filename, p_show_hidden, p_mode, p_filters, TypedArray<Dictionary>(), p_callback, false); +} + +Error DisplayServerWindows::file_dialog_with_options_show(const String &p_title, const String &p_current_directory, const String &p_root, const String &p_filename, bool p_show_hidden, FileDialogMode p_mode, const Vector<String> &p_filters, const TypedArray<Dictionary> &p_options, const Callable &p_callback) { + return _file_dialog_with_options_show(p_title, p_current_directory, p_root, p_filename, p_show_hidden, p_mode, p_filters, p_options, p_callback, true); +} + // Silence warning due to a COM API weirdness. #if defined(__GNUC__) && !defined(__clang__) #pragma GCC diagnostic push @@ -376,22 +388,85 @@ public: #pragma GCC diagnostic pop #endif -Error DisplayServerWindows::file_dialog_show(const String &p_title, const String &p_current_directory, const String &p_filename, bool p_show_hidden, FileDialogMode p_mode, const Vector<String> &p_filters, const Callable &p_callback) { - return _file_dialog_with_options_show(p_title, p_current_directory, String(), p_filename, p_show_hidden, p_mode, p_filters, TypedArray<Dictionary>(), p_callback, false); +LRESULT CALLBACK WndProcFileDialog(HWND hWnd, UINT uMsg, WPARAM wParam, LPARAM lParam) { + DisplayServerWindows *ds_win = static_cast<DisplayServerWindows *>(DisplayServer::get_singleton()); + if (ds_win) { + return ds_win->WndProcFileDialog(hWnd, uMsg, wParam, lParam); + } else { + return DefWindowProcW(hWnd, uMsg, wParam, lParam); + } } -Error DisplayServerWindows::file_dialog_with_options_show(const String &p_title, const String &p_current_directory, const String &p_root, const String &p_filename, bool p_show_hidden, FileDialogMode p_mode, const Vector<String> &p_filters, const TypedArray<Dictionary> &p_options, const Callable &p_callback) { - return _file_dialog_with_options_show(p_title, p_current_directory, p_root, p_filename, p_show_hidden, p_mode, p_filters, p_options, p_callback, true); +LRESULT DisplayServerWindows::WndProcFileDialog(HWND hWnd, UINT uMsg, WPARAM wParam, LPARAM lParam) { + MutexLock lock(file_dialog_mutex); + if (file_dialog_wnd.has(hWnd)) { + if (file_dialog_wnd[hWnd]->close_requested.is_set()) { + IPropertyStore *prop_store; + HRESULT hr = SHGetPropertyStoreForWindow(hWnd, IID_IPropertyStore, (void **)&prop_store); + if (hr == S_OK) { + PROPVARIANT val; + PropVariantInit(&val); + prop_store->SetValue(PKEY_AppUserModel_ID, val); + prop_store->Release(); + } + DestroyWindow(hWnd); + file_dialog_wnd.erase(hWnd); + } + } + return DefWindowProcW(hWnd, uMsg, wParam, lParam); } -Error DisplayServerWindows::_file_dialog_with_options_show(const String &p_title, const String &p_current_directory, const String &p_root, const String &p_filename, bool p_show_hidden, FileDialogMode p_mode, const Vector<String> &p_filters, const TypedArray<Dictionary> &p_options, const Callable &p_callback, bool p_options_in_cb) { - _THREAD_SAFE_METHOD_ +void DisplayServerWindows::_thread_fd_monitor(void *p_ud) { + DisplayServerWindows *ds = static_cast<DisplayServerWindows *>(get_singleton()); + FileDialogData *fd = (FileDialogData *)p_ud; - ERR_FAIL_INDEX_V(int(p_mode), FILE_DIALOG_MODE_SAVE_MAX, FAILED); + if (fd->mode < 0 && fd->mode >= DisplayServer::FILE_DIALOG_MODE_SAVE_MAX) { + fd->finished.set(); + return; + } + CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED); + + int64_t x = fd->wrect.position.x; + int64_t y = fd->wrect.position.y; + int64_t w = fd->wrect.size.x; + int64_t h = fd->wrect.size.y; + + WNDCLASSW wc = {}; + wc.lpfnWndProc = (WNDPROC)::WndProcFileDialog; + wc.hInstance = GetModuleHandle(nullptr); + wc.lpszClassName = L"Engine File Dialog"; + RegisterClassW(&wc); + + HWND hwnd_dialog = CreateWindowExW(WS_EX_APPWINDOW, L"Engine File Dialog", L"", WS_OVERLAPPEDWINDOW, x, y, w, h, nullptr, nullptr, GetModuleHandle(nullptr), nullptr); + if (hwnd_dialog) { + { + MutexLock lock(ds->file_dialog_mutex); + ds->file_dialog_wnd[hwnd_dialog] = fd; + } + + HICON mainwindow_icon = (HICON)SendMessage(fd->hwnd_owner, WM_GETICON, ICON_SMALL, 0); + if (mainwindow_icon) { + SendMessage(hwnd_dialog, WM_SETICON, ICON_SMALL, (LPARAM)mainwindow_icon); + } + mainwindow_icon = (HICON)SendMessage(fd->hwnd_owner, WM_GETICON, ICON_BIG, 0); + if (mainwindow_icon) { + SendMessage(hwnd_dialog, WM_SETICON, ICON_BIG, (LPARAM)mainwindow_icon); + } + IPropertyStore *prop_store; + HRESULT hr = SHGetPropertyStoreForWindow(hwnd_dialog, IID_IPropertyStore, (void **)&prop_store); + if (hr == S_OK) { + PROPVARIANT val; + InitPropVariantFromString((PCWSTR)fd->appid.utf16().get_data(), &val); + prop_store->SetValue(PKEY_AppUserModel_ID, val); + prop_store->Release(); + } + } + + SetCurrentProcessExplicitAppUserModelID((PCWSTR)fd->appid.utf16().get_data()); Vector<Char16String> filter_names; Vector<Char16String> filter_exts; - for (const String &E : p_filters) { + for (const String &E : fd->filters) { Vector<String> tokens = E.split(";"); if (tokens.size() >= 1) { String flt = tokens[0].strip_edges(); @@ -424,11 +499,9 @@ Error DisplayServerWindows::_file_dialog_with_options_show(const String &p_title filters.push_back({ (LPCWSTR)filter_names[i].ptr(), (LPCWSTR)filter_exts[i].ptr() }); } - WindowID prev_focus = last_focused_window; - HRESULT hr = S_OK; IFileDialog *pfd = nullptr; - if (p_mode == FILE_DIALOG_MODE_SAVE_FILE) { + if (fd->mode == DisplayServer::FILE_DIALOG_MODE_SAVE_FILE) { hr = CoCreateInstance(CLSID_FileSaveDialog, nullptr, CLSCTX_INPROC_SERVER, IID_IFileSaveDialog, (void **)&pfd); } else { hr = CoCreateInstance(CLSID_FileOpenDialog, nullptr, CLSCTX_INPROC_SERVER, IID_IFileOpenDialog, (void **)&pfd); @@ -444,40 +517,32 @@ Error DisplayServerWindows::_file_dialog_with_options_show(const String &p_title IFileDialogCustomize *pfdc = nullptr; hr = pfd->QueryInterface(IID_PPV_ARGS(&pfdc)); - for (int i = 0; i < p_options.size(); i++) { - const Dictionary &item = p_options[i]; + for (int i = 0; i < fd->options.size(); i++) { + const Dictionary &item = fd->options[i]; if (!item.has("name") || !item.has("values") || !item.has("default")) { continue; } - const String &name = item["name"]; - const Vector<String> &options = item["values"]; - int default_idx = item["default"]; - - event_handler->add_option(pfdc, name, options, default_idx); + event_handler->add_option(pfdc, item["name"], item["values"], item["default_idx"]); } - event_handler->set_root(p_root); + event_handler->set_root(fd->root); pfdc->Release(); DWORD flags; pfd->GetOptions(&flags); - if (p_mode == FILE_DIALOG_MODE_OPEN_FILES) { + if (fd->mode == DisplayServer::FILE_DIALOG_MODE_OPEN_FILES) { flags |= FOS_ALLOWMULTISELECT; } - if (p_mode == FILE_DIALOG_MODE_OPEN_DIR) { + if (fd->mode == DisplayServer::FILE_DIALOG_MODE_OPEN_DIR) { flags |= FOS_PICKFOLDERS; } - if (p_show_hidden) { + if (fd->show_hidden) { flags |= FOS_FORCESHOWHIDDEN; } pfd->SetOptions(flags | FOS_FORCEFILESYSTEM); - pfd->SetTitle((LPCWSTR)p_title.utf16().ptr()); + pfd->SetTitle((LPCWSTR)fd->title.utf16().ptr()); - String dir = ProjectSettings::get_singleton()->globalize_path(p_current_directory); - if (dir == ".") { - dir = OS::get_singleton()->get_executable_path().get_base_dir(); - } - dir = dir.replace("/", "\\"); + String dir = fd->current_directory.replace("/", "\\"); IShellItem *shellitem = nullptr; hr = SHCreateItemFromParsingName((LPCWSTR)dir.utf16().ptr(), nullptr, IID_IShellItem, (void **)&shellitem); @@ -486,16 +551,11 @@ Error DisplayServerWindows::_file_dialog_with_options_show(const String &p_title pfd->SetFolder(shellitem); } - pfd->SetFileName((LPCWSTR)p_filename.utf16().ptr()); + pfd->SetFileName((LPCWSTR)fd->filename.utf16().ptr()); pfd->SetFileTypes(filters.size(), filters.ptr()); pfd->SetFileTypeIndex(0); - WindowID window_id = _get_focused_window_or_popup(); - if (!windows.has(window_id)) { - window_id = MAIN_WINDOW_ID; - } - - hr = pfd->Show(windows[window_id].hWnd); + hr = pfd->Show(hwnd_dialog); pfd->Unadvise(cookie); Dictionary options = event_handler->get_selected(); @@ -512,7 +572,7 @@ Error DisplayServerWindows::_file_dialog_with_options_show(const String &p_title if (SUCCEEDED(hr)) { Vector<String> file_names; - if (p_mode == FILE_DIALOG_MODE_OPEN_FILES) { + if (fd->mode == DisplayServer::FILE_DIALOG_MODE_OPEN_FILES) { IShellItemArray *results; hr = static_cast<IFileOpenDialog *>(pfd)->GetResults(&results); if (SUCCEEDED(hr)) { @@ -545,73 +605,148 @@ Error DisplayServerWindows::_file_dialog_with_options_show(const String &p_title result->Release(); } } - if (p_callback.is_valid()) { - if (p_options_in_cb) { + if (fd->callback.is_valid()) { + if (fd->options_in_cb) { Variant v_result = true; Variant v_files = file_names; Variant v_index = index; Variant v_opt = options; - Variant ret; - Callable::CallError ce; - const Variant *args[4] = { &v_result, &v_files, &v_index, &v_opt }; + const Variant *cb_args[4] = { &v_result, &v_files, &v_index, &v_opt }; - p_callback.callp(args, 4, ret, ce); - if (ce.error != Callable::CallError::CALL_OK) { - ERR_PRINT(vformat("Failed to execute file dialogs callback: %s.", Variant::get_callable_error_text(p_callback, args, 4, ce))); - } + fd->callback.call_deferredp(cb_args, 4); } else { Variant v_result = true; Variant v_files = file_names; Variant v_index = index; - Variant ret; - Callable::CallError ce; - const Variant *args[3] = { &v_result, &v_files, &v_index }; + const Variant *cb_args[3] = { &v_result, &v_files, &v_index }; - p_callback.callp(args, 3, ret, ce); - if (ce.error != Callable::CallError::CALL_OK) { - ERR_PRINT(vformat("Failed to execute file dialogs callback: %s.", Variant::get_callable_error_text(p_callback, args, 3, ce))); - } + fd->callback.call_deferredp(cb_args, 3); } } } else { - if (p_callback.is_valid()) { - if (p_options_in_cb) { + if (fd->callback.is_valid()) { + if (fd->options_in_cb) { Variant v_result = false; Variant v_files = Vector<String>(); - Variant v_index = index; - Variant v_opt = options; - Variant ret; - Callable::CallError ce; - const Variant *args[4] = { &v_result, &v_files, &v_index, &v_opt }; + Variant v_index = 0; + Variant v_opt = Dictionary(); + const Variant *cb_args[4] = { &v_result, &v_files, &v_index, &v_opt }; - p_callback.callp(args, 4, ret, ce); - if (ce.error != Callable::CallError::CALL_OK) { - ERR_PRINT(vformat("Failed to execute file dialogs callback: %s.", Variant::get_callable_error_text(p_callback, args, 4, ce))); - } + fd->callback.call_deferredp(cb_args, 4); } else { Variant v_result = false; Variant v_files = Vector<String>(); - Variant v_index = index; - Variant ret; - Callable::CallError ce; - const Variant *args[3] = { &v_result, &v_files, &v_index }; + Variant v_index = 0; + const Variant *cb_args[3] = { &v_result, &v_files, &v_index }; - p_callback.callp(args, 3, ret, ce); - if (ce.error != Callable::CallError::CALL_OK) { - ERR_PRINT(vformat("Failed to execute file dialogs callback: %s.", Variant::get_callable_error_text(p_callback, args, 3, ce))); - } + fd->callback.call_deferredp(cb_args, 3); } } } pfd->Release(); - if (prev_focus != INVALID_WINDOW_ID) { - callable_mp(DisplayServer::get_singleton(), &DisplayServer::window_move_to_foreground).call_deferred(prev_focus); + } else { + if (fd->callback.is_valid()) { + if (fd->options_in_cb) { + Variant v_result = false; + Variant v_files = Vector<String>(); + Variant v_index = 0; + Variant v_opt = Dictionary(); + const Variant *cb_args[4] = { &v_result, &v_files, &v_index, &v_opt }; + + fd->callback.call_deferredp(cb_args, 4); + } else { + Variant v_result = false; + Variant v_files = Vector<String>(); + Variant v_index = 0; + const Variant *cb_args[3] = { &v_result, &v_files, &v_index }; + + fd->callback.call_deferredp(cb_args, 3); + } + } + } + { + MutexLock lock(ds->file_dialog_mutex); + if (hwnd_dialog && ds->file_dialog_wnd.has(hwnd_dialog)) { + IPropertyStore *prop_store; + hr = SHGetPropertyStoreForWindow(hwnd_dialog, IID_IPropertyStore, (void **)&prop_store); + if (hr == S_OK) { + PROPVARIANT val; + PropVariantInit(&val); + prop_store->SetValue(PKEY_AppUserModel_ID, val); + prop_store->Release(); + } + DestroyWindow(hwnd_dialog); + ds->file_dialog_wnd.erase(hwnd_dialog); } + } + UnregisterClassW(L"Engine File Dialog", GetModuleHandle(nullptr)); + CoUninitialize(); + + fd->finished.set(); + + if (fd->window_id != INVALID_WINDOW_ID) { + callable_mp(DisplayServer::get_singleton(), &DisplayServer::window_move_to_foreground).call_deferred(fd->window_id); + } +} + +Error DisplayServerWindows::_file_dialog_with_options_show(const String &p_title, const String &p_current_directory, const String &p_root, const String &p_filename, bool p_show_hidden, FileDialogMode p_mode, const Vector<String> &p_filters, const TypedArray<Dictionary> &p_options, const Callable &p_callback, bool p_options_in_cb) { + _THREAD_SAFE_METHOD_ + + ERR_FAIL_INDEX_V(int(p_mode), FILE_DIALOG_MODE_SAVE_MAX, FAILED); + + WindowID window_id = _get_focused_window_or_popup(); + if (!windows.has(window_id)) { + window_id = MAIN_WINDOW_ID; + } + String appname; + if (Engine::get_singleton()->is_editor_hint()) { + appname = "Godot.GodotEditor." + String(VERSION_BRANCH); + } else { + String name = GLOBAL_GET("application/config/name"); + String version = GLOBAL_GET("application/config/version"); + if (version.is_empty()) { + version = "0"; + } + String clean_app_name = name.to_pascal_case(); + for (int i = 0; i < clean_app_name.length(); i++) { + if (!is_ascii_alphanumeric_char(clean_app_name[i]) && clean_app_name[i] != '_' && clean_app_name[i] != '.') { + clean_app_name[i] = '_'; + } + } + clean_app_name = clean_app_name.substr(0, 120 - version.length()).trim_suffix("."); + appname = "Godot." + clean_app_name + "." + version; + } - return OK; + FileDialogData *fd = memnew(FileDialogData); + if (window_id != INVALID_WINDOW_ID) { + fd->hwnd_owner = windows[window_id].hWnd; + RECT crect; + GetWindowRect(fd->hwnd_owner, &crect); + fd->wrect = Rect2i(crect.left, crect.top, crect.right - crect.left, crect.bottom - crect.top); } else { - return ERR_CANT_OPEN; + fd->hwnd_owner = 0; + fd->wrect = Rect2i(CW_USEDEFAULT, CW_USEDEFAULT, CW_USEDEFAULT, CW_USEDEFAULT); } + fd->appid = appname; + fd->title = p_title; + fd->current_directory = p_current_directory; + fd->root = p_root; + fd->filename = p_filename; + fd->show_hidden = p_show_hidden; + fd->mode = p_mode; + fd->window_id = window_id; + fd->filters = p_filters; + fd->options = p_options; + fd->callback = p_callback; + fd->options_in_cb = p_options_in_cb; + fd->finished.clear(); + fd->close_requested.clear(); + + fd->listener_thread.start(DisplayServerWindows::_thread_fd_monitor, fd); + + file_dialogs.push_back(fd); + + return OK; } void DisplayServerWindows::mouse_set_mode(MouseMode p_mode) { @@ -1305,10 +1440,10 @@ DisplayServer::WindowID DisplayServerWindows::get_window_at_screen_position(cons return INVALID_WINDOW_ID; } -DisplayServer::WindowID DisplayServerWindows::create_sub_window(WindowMode p_mode, VSyncMode p_vsync_mode, uint32_t p_flags, const Rect2i &p_rect) { +DisplayServer::WindowID DisplayServerWindows::create_sub_window(WindowMode p_mode, VSyncMode p_vsync_mode, uint32_t p_flags, const Rect2i &p_rect, bool p_exclusive, WindowID p_transient_parent) { _THREAD_SAFE_METHOD_ - WindowID window_id = _create_window(p_mode, p_vsync_mode, p_flags, p_rect); + WindowID window_id = _create_window(p_mode, p_vsync_mode, p_flags, p_rect, p_exclusive, p_transient_parent); ERR_FAIL_COND_V_MSG(window_id == INVALID_WINDOW_ID, INVALID_WINDOW_ID, "Failed to create sub window."); WindowData &wd = windows[window_id]; @@ -1332,13 +1467,15 @@ DisplayServer::WindowID DisplayServerWindows::create_sub_window(WindowMode p_mod wd.is_popup = true; } if (p_flags & WINDOW_FLAG_TRANSPARENT_BIT) { - DWM_BLURBEHIND bb; - ZeroMemory(&bb, sizeof(bb)); - HRGN hRgn = CreateRectRgn(0, 0, -1, -1); - bb.dwFlags = DWM_BB_ENABLE | DWM_BB_BLURREGION; - bb.hRgnBlur = hRgn; - bb.fEnable = TRUE; - DwmEnableBlurBehindWindow(wd.hWnd, &bb); + if (OS::get_singleton()->is_layered_allowed()) { + DWM_BLURBEHIND bb; + ZeroMemory(&bb, sizeof(bb)); + HRGN hRgn = CreateRectRgn(0, 0, -1, -1); + bb.dwFlags = DWM_BB_ENABLE | DWM_BB_BLURREGION; + bb.hRgnBlur = hRgn; + bb.fEnable = TRUE; + DwmEnableBlurBehindWindow(wd.hWnd, &bb); + } wd.layered_window = true; } @@ -2008,7 +2145,7 @@ void DisplayServerWindows::window_set_mode(WindowMode p_mode, WindowID p_window) } if (p_mode == WINDOW_MODE_WINDOWED) { - ShowWindow(wd.hWnd, SW_RESTORE); + ShowWindow(wd.hWnd, SW_NORMAL); wd.maximized = false; wd.minimized = false; } @@ -2118,28 +2255,29 @@ void DisplayServerWindows::window_set_flag(WindowFlags p_flag, bool p_enabled, W } break; case WINDOW_FLAG_TRANSPARENT: { if (p_enabled) { - //enable per-pixel alpha - - DWM_BLURBEHIND bb; - ZeroMemory(&bb, sizeof(bb)); - HRGN hRgn = CreateRectRgn(0, 0, -1, -1); - bb.dwFlags = DWM_BB_ENABLE | DWM_BB_BLURREGION; - bb.hRgnBlur = hRgn; - bb.fEnable = TRUE; - DwmEnableBlurBehindWindow(wd.hWnd, &bb); - + // Enable per-pixel alpha. + if (OS::get_singleton()->is_layered_allowed()) { + DWM_BLURBEHIND bb; + ZeroMemory(&bb, sizeof(bb)); + HRGN hRgn = CreateRectRgn(0, 0, -1, -1); + bb.dwFlags = DWM_BB_ENABLE | DWM_BB_BLURREGION; + bb.hRgnBlur = hRgn; + bb.fEnable = TRUE; + DwmEnableBlurBehindWindow(wd.hWnd, &bb); + } wd.layered_window = true; } else { - //disable per-pixel alpha + // Disable per-pixel alpha. wd.layered_window = false; - - DWM_BLURBEHIND bb; - ZeroMemory(&bb, sizeof(bb)); - HRGN hRgn = CreateRectRgn(0, 0, -1, -1); - bb.dwFlags = DWM_BB_ENABLE | DWM_BB_BLURREGION; - bb.hRgnBlur = hRgn; - bb.fEnable = FALSE; - DwmEnableBlurBehindWindow(wd.hWnd, &bb); + if (OS::get_singleton()->is_layered_allowed()) { + DWM_BLURBEHIND bb; + ZeroMemory(&bb, sizeof(bb)); + HRGN hRgn = CreateRectRgn(0, 0, -1, -1); + bb.dwFlags = DWM_BB_ENABLE | DWM_BB_BLURREGION; + bb.hRgnBlur = hRgn; + bb.fEnable = FALSE; + DwmEnableBlurBehindWindow(wd.hWnd, &bb); + } } } break; case WINDOW_FLAG_NO_FOCUS: { @@ -2910,24 +3048,67 @@ Key DisplayServerWindows::keyboard_get_label_from_physical(Key p_keycode) const return p_keycode; } -String _get_full_layout_name_from_registry(HKL p_layout) { - String id = "SYSTEM\\CurrentControlSet\\Control\\Keyboard Layouts\\" + String::num_int64((int64_t)p_layout, 16, false).lpad(8, "0"); +String DisplayServerWindows::_get_keyboard_layout_display_name(const String &p_klid) const { String ret; + HKEY key; + if (RegOpenKeyW(HKEY_LOCAL_MACHINE, L"SYSTEM\\CurrentControlSet\\Control\\Keyboard Layouts", &key) != ERROR_SUCCESS) { + return String(); + } - HKEY hkey; - WCHAR layout_text[1024]; - memset(layout_text, 0, 1024 * sizeof(WCHAR)); - - if (RegOpenKeyExW(HKEY_LOCAL_MACHINE, (LPCWSTR)(id.utf16().get_data()), 0, KEY_QUERY_VALUE, &hkey) != ERROR_SUCCESS) { - return ret; + WCHAR buffer[MAX_PATH] = {}; + DWORD buffer_size = MAX_PATH; + if (RegGetValueW(key, (LPCWSTR)p_klid.utf16().get_data(), L"Layout Display Name", RRF_RT_REG_SZ, nullptr, buffer, &buffer_size) == ERROR_SUCCESS) { + if (load_indirect_string) { + if (load_indirect_string(buffer, buffer, buffer_size, nullptr) == S_OK) { + ret = String::utf16((const char16_t *)buffer, buffer_size); + } + } + } else { + if (RegGetValueW(key, (LPCWSTR)p_klid.utf16().get_data(), L"Layout Text", RRF_RT_REG_SZ, nullptr, buffer, &buffer_size) == ERROR_SUCCESS) { + ret = String::utf16((const char16_t *)buffer, buffer_size); + } } - DWORD buffer = 1024; - DWORD vtype = REG_SZ; - if (RegQueryValueExW(hkey, L"Layout Text", nullptr, &vtype, (LPBYTE)layout_text, &buffer) == ERROR_SUCCESS) { - ret = String::utf16((const char16_t *)layout_text); + RegCloseKey(key); + return ret; +} + +String DisplayServerWindows::_get_klid(HKL p_hkl) const { + String ret; + + WORD device = HIWORD(p_hkl); + if ((device & 0xf000) == 0xf000) { + WORD layout_id = device & 0x0fff; + + HKEY key; + if (RegOpenKeyW(HKEY_LOCAL_MACHINE, L"SYSTEM\\CurrentControlSet\\Control\\Keyboard Layouts", &key) != ERROR_SUCCESS) { + return String(); + } + + DWORD index = 0; + wchar_t klid_buffer[KL_NAMELENGTH]; + DWORD klid_buffer_size = KL_NAMELENGTH; + while (RegEnumKeyExW(key, index, klid_buffer, &klid_buffer_size, nullptr, nullptr, nullptr, nullptr) == ERROR_SUCCESS) { + wchar_t layout_id_buf[MAX_PATH] = {}; + DWORD layout_id_size = MAX_PATH; + if (RegGetValueW(key, klid_buffer, L"Layout Id", RRF_RT_REG_SZ, nullptr, layout_id_buf, &layout_id_size) == ERROR_SUCCESS) { + if (layout_id == String::utf16((char16_t *)layout_id_buf, layout_id_size).hex_to_int()) { + ret = String::utf16((const char16_t *)klid_buffer, klid_buffer_size).lpad(8, "0"); + break; + } + } + klid_buffer_size = KL_NAMELENGTH; + ++index; + } + + RegCloseKey(key); + } else { + if (device == 0) { + device = LOWORD(p_hkl); + } + ret = (String::num_uint64((uint64_t)device, 16, false)).lpad(8, "0"); } - RegCloseKey(hkey); + return ret; } @@ -2939,7 +3120,7 @@ String DisplayServerWindows::keyboard_get_layout_name(int p_index) const { HKL *layouts = (HKL *)memalloc(layout_count * sizeof(HKL)); GetKeyboardLayoutList(layout_count, layouts); - String ret = _get_full_layout_name_from_registry(layouts[p_index]); // Try reading full name from Windows registry, fallback to locale name if failed (e.g. on Wine). + String ret = _get_keyboard_layout_display_name(_get_klid(layouts[p_index])); // Try reading full name from Windows registry, fallback to locale name if failed (e.g. on Wine). if (ret.is_empty()) { WCHAR buf[LOCALE_NAME_MAX_LENGTH]; memset(buf, 0, LOCALE_NAME_MAX_LENGTH * sizeof(WCHAR)); @@ -2975,6 +3156,21 @@ void DisplayServerWindows::process_events() { _process_key_events(); Input::get_singleton()->flush_buffered_events(); } + + LocalVector<List<FileDialogData *>::Element *> to_remove; + for (List<FileDialogData *>::Element *E = file_dialogs.front(); E; E = E->next()) { + FileDialogData *fd = E->get(); + if (fd->finished.is_set()) { + if (fd->listener_thread.is_started()) { + fd->listener_thread.wait_to_finish(); + } + to_remove.push_back(E); + } + } + for (List<FileDialogData *>::Element *E : to_remove) { + memdelete(E->get()); + E->erase(); + } } void DisplayServerWindows::force_process_and_drop_events() { @@ -3807,9 +4003,9 @@ LRESULT DisplayServerWindows::WndProc(HWND hWnd, UINT uMsg, WPARAM wParam, LPARA case WM_ACTIVATE: { // Activation can happen just after the window has been created, even before the callbacks are set. // Therefore, it's safer to defer the delivery of the event. - if (!windows[window_id].activate_timer_id) { - windows[window_id].activate_timer_id = SetTimer(windows[window_id].hWnd, 1, USER_TIMER_MINIMUM, (TIMERPROC) nullptr); - } + // It's important to set an nIDEvent different from the SetTimer for move_timer_id because + // if the same nIDEvent is passed, the timer is replaced and the same timer_id is returned. + windows[window_id].activate_timer_id = SetTimer(windows[window_id].hWnd, DisplayServerWindows::TIMER_ID_WINDOW_ACTIVATION, USER_TIMER_MINIMUM, (TIMERPROC) nullptr); windows[window_id].activate_state = GET_WM_ACTIVATE_STATE(wParam, lParam); return 0; } break; @@ -4160,6 +4356,7 @@ LRESULT DisplayServerWindows::WndProc(HWND hWnd, UINT uMsg, WPARAM wParam, LPARA mm->set_relative_screen_position(mm->get_relative()); old_x = mm->get_position().x; old_y = mm->get_position().y; + if (windows[window_id].window_focused || window_get_active_popup() == window_id) { Input::get_singleton()->parse_input_event(mm); } @@ -4186,13 +4383,128 @@ LRESULT DisplayServerWindows::WndProc(HWND hWnd, UINT uMsg, WPARAM wParam, LPARA break; } + pointer_button[GET_POINTERID_WPARAM(wParam)] = MouseButton::NONE; windows[window_id].block_mm = true; return 0; } break; case WM_POINTERLEAVE: { + pointer_button[GET_POINTERID_WPARAM(wParam)] = MouseButton::NONE; windows[window_id].block_mm = false; return 0; } break; + case WM_POINTERDOWN: + case WM_POINTERUP: { + if (mouse_mode == MOUSE_MODE_CAPTURED && use_raw_input) { + break; + } + + if ((tablet_get_current_driver() != "winink") || !winink_available) { + break; + } + + uint32_t pointer_id = LOWORD(wParam); + POINTER_INPUT_TYPE pointer_type = PT_POINTER; + if (!win8p_GetPointerType(pointer_id, &pointer_type)) { + break; + } + + if (pointer_type != PT_PEN) { + break; + } + + Ref<InputEventMouseButton> mb; + mb.instantiate(); + mb->set_window_id(window_id); + + BitField<MouseButtonMask> last_button_state = 0; + if (IS_POINTER_FIRSTBUTTON_WPARAM(wParam)) { + last_button_state.set_flag(MouseButtonMask::LEFT); + mb->set_button_index(MouseButton::LEFT); + } + if (IS_POINTER_SECONDBUTTON_WPARAM(wParam)) { + last_button_state.set_flag(MouseButtonMask::RIGHT); + mb->set_button_index(MouseButton::RIGHT); + } + if (IS_POINTER_THIRDBUTTON_WPARAM(wParam)) { + last_button_state.set_flag(MouseButtonMask::MIDDLE); + mb->set_button_index(MouseButton::MIDDLE); + } + if (IS_POINTER_FOURTHBUTTON_WPARAM(wParam)) { + last_button_state.set_flag(MouseButtonMask::MB_XBUTTON1); + mb->set_button_index(MouseButton::MB_XBUTTON1); + } + if (IS_POINTER_FIFTHBUTTON_WPARAM(wParam)) { + last_button_state.set_flag(MouseButtonMask::MB_XBUTTON2); + mb->set_button_index(MouseButton::MB_XBUTTON2); + } + mb->set_button_mask(last_button_state); + + const BitField<WinKeyModifierMask> &mods = _get_mods(); + mb->set_ctrl_pressed(mods.has_flag(WinKeyModifierMask::CTRL)); + mb->set_shift_pressed(mods.has_flag(WinKeyModifierMask::SHIFT)); + mb->set_alt_pressed(mods.has_flag(WinKeyModifierMask::ALT)); + mb->set_meta_pressed(mods.has_flag(WinKeyModifierMask::META)); + + POINT coords; // Client coords. + coords.x = GET_X_LPARAM(lParam); + coords.y = GET_Y_LPARAM(lParam); + + // Note: Handle popup closing here, since mouse event is not emulated and hook will not be called. + uint64_t delta = OS::get_singleton()->get_ticks_msec() - time_since_popup; + if (delta > 250) { + Point2i pos = Point2i(coords.x, coords.y) - _get_screens_origin(); + List<WindowID>::Element *C = nullptr; + List<WindowID>::Element *E = popup_list.back(); + // Find top popup to close. + while (E) { + // Popup window area. + Rect2i win_rect = Rect2i(window_get_position_with_decorations(E->get()), window_get_size_with_decorations(E->get())); + // Area of the parent window, which responsible for opening sub-menu. + Rect2i safe_rect = window_get_popup_safe_rect(E->get()); + if (win_rect.has_point(pos)) { + break; + } else if (safe_rect != Rect2i() && safe_rect.has_point(pos)) { + break; + } else { + C = E; + E = E->prev(); + } + } + if (C) { + _send_window_event(windows[C->get()], DisplayServerWindows::WINDOW_EVENT_CLOSE_REQUEST); + } + } + + int64_t pen_id = GET_POINTERID_WPARAM(wParam); + if (uMsg == WM_POINTERDOWN) { + mb->set_pressed(true); + if (pointer_down_time.has(pen_id) && (pointer_prev_button[pen_id] == mb->get_button_index()) && (ABS(coords.y - pointer_last_pos[pen_id].y) < GetSystemMetrics(SM_CYDOUBLECLK)) && GetMessageTime() - pointer_down_time[pen_id] < (LONG)GetDoubleClickTime()) { + mb->set_double_click(true); + pointer_down_time[pen_id] = 0; + } else { + pointer_down_time[pen_id] = GetMessageTime(); + pointer_prev_button[pen_id] = mb->get_button_index(); + pointer_last_pos[pen_id] = Vector2(coords.x, coords.y); + } + pointer_button[pen_id] = mb->get_button_index(); + } else { + if (!pointer_button.has(pen_id)) { + return 0; + } + mb->set_pressed(false); + mb->set_button_index(pointer_button[pen_id]); + pointer_button[pen_id] = MouseButton::NONE; + } + + ScreenToClient(windows[window_id].hWnd, &coords); + + mb->set_position(Vector2(coords.x, coords.y)); + mb->set_global_position(Vector2(coords.x, coords.y)); + + Input::get_singleton()->parse_input_event(mb); + + return 0; + } break; case WM_POINTERUPDATE: { if (mouse_mode == MOUSE_MODE_CAPTURED && use_raw_input) { break; @@ -4270,7 +4582,23 @@ LRESULT DisplayServerWindows::WndProc(HWND hWnd, UINT uMsg, WPARAM wParam, LPARA mm->set_alt_pressed(mods.has_flag(WinKeyModifierMask::ALT)); mm->set_meta_pressed(mods.has_flag(WinKeyModifierMask::META)); - mm->set_button_mask(mouse_get_button_state()); + BitField<MouseButtonMask> last_button_state = 0; + if (IS_POINTER_FIRSTBUTTON_WPARAM(wParam)) { + last_button_state.set_flag(MouseButtonMask::LEFT); + } + if (IS_POINTER_SECONDBUTTON_WPARAM(wParam)) { + last_button_state.set_flag(MouseButtonMask::RIGHT); + } + if (IS_POINTER_THIRDBUTTON_WPARAM(wParam)) { + last_button_state.set_flag(MouseButtonMask::MIDDLE); + } + if (IS_POINTER_FOURTHBUTTON_WPARAM(wParam)) { + last_button_state.set_flag(MouseButtonMask::MB_XBUTTON1); + } + if (IS_POINTER_FIFTHBUTTON_WPARAM(wParam)) { + last_button_state.set_flag(MouseButtonMask::MB_XBUTTON2); + } + mm->set_button_mask(last_button_state); POINT coords; // Client coords. coords.x = GET_X_LPARAM(lParam); @@ -4441,6 +4769,7 @@ LRESULT DisplayServerWindows::WndProc(HWND hWnd, UINT uMsg, WPARAM wParam, LPARA mm->set_position(mm->get_position() - window_get_position(receiving_window_id) + window_get_position(window_id)); mm->set_global_position(mm->get_position()); } + Input::get_singleton()->parse_input_event(mm); } break; @@ -4625,6 +4954,16 @@ LRESULT DisplayServerWindows::WndProc(HWND hWnd, UINT uMsg, WPARAM wParam, LPARA Input::get_singleton()->parse_input_event(mbd); } + // Propagate the button up event to the window on which the button down + // event was triggered. This is needed for drag & drop to work between windows, + // because the engine expects events to keep being processed + // on the same window dragging started. + if (mb->is_pressed()) { + last_mouse_button_down_window = window_id; + } else if (last_mouse_button_down_window != INVALID_WINDOW_ID) { + mb->set_window_id(last_mouse_button_down_window); + last_mouse_button_down_window = INVALID_WINDOW_ID; + } } break; case WM_WINDOWPOSCHANGED: { @@ -4685,16 +5024,16 @@ LRESULT DisplayServerWindows::WndProc(HWND hWnd, UINT uMsg, WPARAM wParam, LPARA rect_changed = true; } #if defined(RD_ENABLED) - if (rendering_context && window.context_created) { + if (window.create_completed && rendering_context && window.context_created) { // Note: Trigger resize event to update swapchains when window is minimized/restored, even if size is not changed. rendering_context->window_set_size(window_id, window.width, window.height); } #endif #if defined(GLES3_ENABLED) - if (gl_manager_native) { + if (window.create_completed && gl_manager_native) { gl_manager_native->window_resize(window_id, window.width, window.height); } - if (gl_manager_angle) { + if (window.create_completed && gl_manager_angle) { gl_manager_angle->window_resize(window_id, window.width, window.height); } #endif @@ -4727,7 +5066,7 @@ LRESULT DisplayServerWindows::WndProc(HWND hWnd, UINT uMsg, WPARAM wParam, LPARA case WM_ENTERSIZEMOVE: { Input::get_singleton()->release_pressed_events(); - windows[window_id].move_timer_id = SetTimer(windows[window_id].hWnd, 1, USER_TIMER_MINIMUM, (TIMERPROC) nullptr); + windows[window_id].move_timer_id = SetTimer(windows[window_id].hWnd, DisplayServerWindows::TIMER_ID_MOVE_REDRAW, USER_TIMER_MINIMUM, (TIMERPROC) nullptr); } break; case WM_EXITSIZEMOVE: { KillTimer(windows[window_id].hWnd, windows[window_id].move_timer_id); @@ -5159,7 +5498,7 @@ void DisplayServerWindows::_update_tablet_ctx(const String &p_old_driver, const } } -DisplayServer::WindowID DisplayServerWindows::_create_window(WindowMode p_mode, VSyncMode p_vsync_mode, uint32_t p_flags, const Rect2i &p_rect) { +DisplayServer::WindowID DisplayServerWindows::_create_window(WindowMode p_mode, VSyncMode p_vsync_mode, uint32_t p_flags, const Rect2i &p_rect, bool p_exclusive, WindowID p_transient_parent) { DWORD dwExStyle; DWORD dwStyle; @@ -5209,6 +5548,20 @@ DisplayServer::WindowID DisplayServerWindows::_create_window(WindowMode p_mode, WindowID id = window_id_counter; { + WindowData *wd_transient_parent = nullptr; + HWND owner_hwnd = nullptr; + if (p_transient_parent != INVALID_WINDOW_ID) { + if (!windows.has(p_transient_parent)) { + ERR_PRINT("Condition \"!windows.has(p_transient_parent)\" is true."); + p_transient_parent = INVALID_WINDOW_ID; + } else { + wd_transient_parent = &windows[p_transient_parent]; + if (p_exclusive) { + owner_hwnd = wd_transient_parent->hWnd; + } + } + } + WindowData &wd = windows[id]; wd.hWnd = CreateWindowExW( @@ -5219,7 +5572,7 @@ DisplayServer::WindowID DisplayServerWindows::_create_window(WindowMode p_mode, WindowRect.top, WindowRect.right - WindowRect.left, WindowRect.bottom - WindowRect.top, - nullptr, + owner_hwnd, nullptr, hInstance, // tunnel the WindowData we need to handle creation message @@ -5241,11 +5594,20 @@ DisplayServer::WindowID DisplayServerWindows::_create_window(WindowMode p_mode, wd.pre_fs_valid = true; } + wd.exclusive = p_exclusive; + if (wd_transient_parent) { + wd.transient_parent = p_transient_parent; + wd_transient_parent->transient_children.insert(id); + } + if (is_dark_mode_supported() && dark_title_available) { BOOL value = is_dark_mode(); ::DwmSetWindowAttribute(wd.hWnd, use_legacy_dark_mode_before_20H1 ? DWMWA_USE_IMMERSIVE_DARK_MODE_BEFORE_20H1 : DWMWA_USE_IMMERSIVE_DARK_MODE, &value, sizeof(value)); } + RECT real_client_rect; + GetClientRect(wd.hWnd, &real_client_rect); + #ifdef RD_ENABLED if (rendering_context) { union { @@ -5275,7 +5637,7 @@ DisplayServer::WindowID DisplayServerWindows::_create_window(WindowMode p_mode, return INVALID_WINDOW_ID; } - rendering_context->window_set_size(id, WindowRect.right - WindowRect.left, WindowRect.bottom - WindowRect.top); + rendering_context->window_set_size(id, real_client_rect.right - real_client_rect.left, real_client_rect.bottom - real_client_rect.top); rendering_context->window_set_vsync_mode(id, p_vsync_mode); wd.context_created = true; } @@ -5283,7 +5645,7 @@ DisplayServer::WindowID DisplayServerWindows::_create_window(WindowMode p_mode, #ifdef GLES3_ENABLED if (gl_manager_native) { - if (gl_manager_native->window_create(id, wd.hWnd, hInstance, WindowRect.right - WindowRect.left, WindowRect.bottom - WindowRect.top) != OK) { + if (gl_manager_native->window_create(id, wd.hWnd, hInstance, real_client_rect.right - real_client_rect.left, real_client_rect.bottom - real_client_rect.top) != OK) { memdelete(gl_manager_native); gl_manager_native = nullptr; windows.erase(id); @@ -5293,7 +5655,7 @@ DisplayServer::WindowID DisplayServerWindows::_create_window(WindowMode p_mode, } if (gl_manager_angle) { - if (gl_manager_angle->window_create(id, nullptr, wd.hWnd, WindowRect.right - WindowRect.left, WindowRect.bottom - WindowRect.top) != OK) { + if (gl_manager_angle->window_create(id, nullptr, wd.hWnd, real_client_rect.right - real_client_rect.left, real_client_rect.bottom - real_client_rect.top) != OK) { memdelete(gl_manager_angle); gl_manager_angle = nullptr; windows.erase(id); @@ -5355,7 +5717,7 @@ DisplayServer::WindowID DisplayServerWindows::_create_window(WindowMode p_mode, PROPVARIANT val; String appname; if (Engine::get_singleton()->is_editor_hint()) { - appname = "Godot.GodotEditor." + String(VERSION_BRANCH); + appname = "Godot.GodotEditor." + String(VERSION_FULL_CONFIG); } else { String name = GLOBAL_GET("application/config/name"); String version = GLOBAL_GET("application/config/version"); @@ -5402,12 +5764,15 @@ DisplayServer::WindowID DisplayServerWindows::_create_window(WindowMode p_mode, SetWindowPos(wd.hWnd, HWND_TOP, srect.position.x, srect.position.y, srect.size.width, srect.size.height, SWP_NOZORDER | SWP_NOACTIVATE); } + wd.create_completed = true; window_id_counter++; } return id; } +BitField<DisplayServerWindows::DriverID> DisplayServerWindows::tested_drivers = 0; + // WinTab API. bool DisplayServerWindows::wintab_available = false; WTOpenPtr DisplayServerWindows::wintab_WTOpen = nullptr; @@ -5432,6 +5797,9 @@ GetPointerPenInfoPtr DisplayServerWindows::win8p_GetPointerPenInfo = nullptr; LogicalToPhysicalPointForPerMonitorDPIPtr DisplayServerWindows::win81p_LogicalToPhysicalPointForPerMonitorDPI = nullptr; PhysicalToLogicalPointForPerMonitorDPIPtr DisplayServerWindows::win81p_PhysicalToLogicalPointForPerMonitorDPI = nullptr; +// Shell API, +SHLoadIndirectStringPtr DisplayServerWindows::load_indirect_string = nullptr; + Vector2i _get_device_ids(const String &p_device_name) { if (p_device_name.is_empty()) { return Vector2i(); @@ -5494,12 +5862,6 @@ Vector2i _get_device_ids(const String &p_device_name) { return ids; } -typedef enum _SHC_PROCESS_DPI_AWARENESS { - SHC_PROCESS_DPI_UNAWARE = 0, - SHC_PROCESS_SYSTEM_DPI_AWARE = 1, - SHC_PROCESS_PER_MONITOR_DPI_AWARE = 2 -} SHC_PROCESS_DPI_AWARENESS; - bool DisplayServerWindows::is_dark_mode_supported() const { return ux_theme_available; } @@ -5567,6 +5929,8 @@ void DisplayServerWindows::tablet_set_current_driver(const String &p_driver) { DisplayServerWindows::DisplayServerWindows(const String &p_rendering_driver, WindowMode p_mode, VSyncMode p_vsync_mode, uint32_t p_flags, const Vector2i *p_position, const Vector2i &p_resolution, int p_screen, Context p_context, Error &r_error) { KeyMappingWindows::initialize(); + tested_drivers.clear(); + drop_events = false; key_event_pos = 0; @@ -5605,6 +5969,12 @@ DisplayServerWindows::DisplayServerWindows(const String &p_rendering_driver, Win FreeLibrary(nt_lib); } + // Load Shell API. + HMODULE shellapi_lib = LoadLibraryW(L"shlwapi.dll"); + if (shellapi_lib) { + load_indirect_string = (SHLoadIndirectStringPtr)GetProcAddress(shellapi_lib, "SHLoadIndirectString"); + } + // Load UXTheme, available on Windows 10+ only. if (os_ver.dwBuildNumber >= 10240) { HMODULE ux_theme_lib = LoadLibraryW(L"uxtheme.dll"); @@ -5729,7 +6099,6 @@ DisplayServerWindows::DisplayServerWindows(const String &p_rendering_driver, Win wc.lpszClassName = L"Engine"; if (!RegisterClassExW(&wc)) { - MessageBoxW(nullptr, L"Failed To Register The Window Class.", L"ERROR", MB_OK | MB_ICONEXCLAMATION); r_error = ERR_UNAVAILABLE; return; } @@ -5740,37 +6109,89 @@ DisplayServerWindows::DisplayServerWindows(const String &p_rendering_driver, Win #if defined(VULKAN_ENABLED) if (rendering_driver == "vulkan") { rendering_context = memnew(RenderingContextDriverVulkanWindows); + tested_drivers.set_flag(DRIVER_ID_RD_VULKAN); } #endif #if defined(D3D12_ENABLED) if (rendering_driver == "d3d12") { rendering_context = memnew(RenderingContextDriverD3D12); + tested_drivers.set_flag(DRIVER_ID_RD_D3D12); } #endif if (rendering_context) { if (rendering_context->initialize() != OK) { - memdelete(rendering_context); - rendering_context = nullptr; - r_error = ERR_UNAVAILABLE; - return; + bool failed = true; +#if defined(VULKAN_ENABLED) + bool fallback_to_vulkan = GLOBAL_GET("rendering/rendering_device/fallback_to_vulkan"); + if (failed && fallback_to_vulkan && rendering_driver != "vulkan") { + memdelete(rendering_context); + rendering_context = memnew(RenderingContextDriverVulkanWindows); + tested_drivers.set_flag(DRIVER_ID_RD_VULKAN); + if (rendering_context->initialize() == OK) { + WARN_PRINT("Your video card drivers seem not to support Direct3D 12, switching to Vulkan."); + rendering_driver = "vulkan"; + failed = false; + } + } +#endif +#if defined(D3D12_ENABLED) + bool fallback_to_d3d12 = GLOBAL_GET("rendering/rendering_device/fallback_to_d3d12"); + if (failed && fallback_to_d3d12 && rendering_driver != "d3d12") { + memdelete(rendering_context); + rendering_context = memnew(RenderingContextDriverD3D12); + tested_drivers.set_flag(DRIVER_ID_RD_D3D12); + if (rendering_context->initialize() == OK) { + WARN_PRINT("Your video card drivers seem not to support Vulkan, switching to Direct3D 12."); + rendering_driver = "d3d12"; + failed = false; + } + } +#endif + if (failed) { + memdelete(rendering_context); + rendering_context = nullptr; + r_error = ERR_UNAVAILABLE; + return; + } } } #endif // Init context and rendering device #if defined(GLES3_ENABLED) -#if defined(__arm__) || defined(__aarch64__) || defined(_M_ARM) || defined(_M_ARM64) - // There's no native OpenGL drivers on Windows for ARM, switch to ANGLE over DX. + bool fallback = GLOBAL_GET("rendering/gl_compatibility/fallback_to_angle"); + bool show_warning = true; + if (rendering_driver == "opengl3") { - rendering_driver = "opengl3_angle"; + // There's no native OpenGL drivers on Windows for ARM, always enable fallback. +#if defined(__arm__) || defined(__aarch64__) || defined(_M_ARM) || defined(_M_ARM64) + fallback = true; + show_warning = false; +#else + typedef BOOL(WINAPI * IsWow64Process2Ptr)(HANDLE, USHORT *, USHORT *); + + IsWow64Process2Ptr IsWow64Process2 = (IsWow64Process2Ptr)GetProcAddress(GetModuleHandle(TEXT("kernel32")), "IsWow64Process2"); + if (IsWow64Process2) { + USHORT process_arch = 0; + USHORT machine_arch = 0; + if (!IsWow64Process2(GetCurrentProcess(), &process_arch, &machine_arch)) { + machine_arch = 0; + } + if (machine_arch == 0xAA64) { + fallback = true; + show_warning = false; + } + } +#endif } -#elif defined(EGL_STATIC) - bool fallback = GLOBAL_GET("rendering/gl_compatibility/fallback_to_angle"); + + bool gl_supported = true; if (fallback && (rendering_driver == "opengl3")) { Dictionary gl_info = detect_wgl(); bool force_angle = false; + gl_supported = gl_info["version"].operator int() >= 30003; Vector2i device_id = _get_device_ids(gl_info["name"]); Array device_list = GLOBAL_GET("rendering/gl_compatibility/force_angle_on_devices"); @@ -5792,41 +6213,61 @@ DisplayServerWindows::DisplayServerWindows(const String &p_rendering_driver, Win } if (force_angle || (gl_info["version"].operator int() < 30003)) { - WARN_PRINT("Your video card drivers seem not to support the required OpenGL 3.3 version, switching to ANGLE."); + tested_drivers.set_flag(DRIVER_ID_COMPAT_OPENGL3); + if (show_warning) { + if (gl_info["version"].operator int() < 30003) { + WARN_PRINT("Your video card drivers seem not to support the required OpenGL 3.3 version, switching to ANGLE."); + } else { + WARN_PRINT("Your video card drivers are known to have low quality OpenGL 3.3 support, switching to ANGLE."); + } + } rendering_driver = "opengl3_angle"; } } -#endif + if (rendering_driver == "opengl3_angle") { + gl_manager_angle = memnew(GLManagerANGLE_Windows); + tested_drivers.set_flag(DRIVER_ID_COMPAT_ANGLE_D3D11); + + if (gl_manager_angle->initialize() != OK) { + memdelete(gl_manager_angle); + gl_manager_angle = nullptr; + bool fallback_to_native = GLOBAL_GET("rendering/gl_compatibility/fallback_to_native"); + if (fallback_to_native && gl_supported) { +#ifdef EGL_STATIC + WARN_PRINT("Your video card drivers seem not to support GLES3 / ANGLE, switching to native OpenGL."); +#else + WARN_PRINT("Your video card drivers seem not to support GLES3 / ANGLE or ANGLE dynamic libraries (libEGL.dll and libGLESv2.dll) are missing, switching to native OpenGL."); +#endif + rendering_driver = "opengl3"; + } else { + r_error = ERR_UNAVAILABLE; + ERR_FAIL_MSG("Could not initialize ANGLE OpenGL."); + } + } + } if (rendering_driver == "opengl3") { gl_manager_native = memnew(GLManagerNative_Windows); + tested_drivers.set_flag(DRIVER_ID_COMPAT_OPENGL3); if (gl_manager_native->initialize() != OK) { memdelete(gl_manager_native); gl_manager_native = nullptr; r_error = ERR_UNAVAILABLE; - return; + ERR_FAIL_MSG("Could not initialize native OpenGL."); } + } + if (rendering_driver == "opengl3") { RasterizerGLES3::make_current(true); } if (rendering_driver == "opengl3_angle") { - gl_manager_angle = memnew(GLManagerANGLE_Windows); - - if (gl_manager_angle->initialize() != OK) { - memdelete(gl_manager_angle); - gl_manager_angle = nullptr; - r_error = ERR_UNAVAILABLE; - return; - } - RasterizerGLES3::make_current(false); } #endif - String appname; if (Engine::get_singleton()->is_editor_hint()) { - appname = "Godot.GodotEditor." + String(VERSION_BRANCH); + appname = "Godot.GodotEditor." + String(VERSION_FULL_CONFIG); } else { String name = GLOBAL_GET("application/config/name"); String version = GLOBAL_GET("application/config/version"); @@ -5841,6 +6282,17 @@ DisplayServerWindows::DisplayServerWindows(const String &p_rendering_driver, Win } clean_app_name = clean_app_name.substr(0, 120 - version.length()).trim_suffix("."); appname = "Godot." + clean_app_name + "." + version; + +#ifndef TOOLS_ENABLED + // Set for exported projects only. + HKEY key; + if (RegOpenKeyW(HKEY_CURRENT_USER_LOCAL_SETTINGS, L"Software\\Microsoft\\Windows\\Shell\\MuiCache", &key) == ERROR_SUCCESS) { + Char16String cs_name = name.utf16(); + String value_name = OS::get_singleton()->get_executable_path().replace("/", "\\") + ".FriendlyAppName"; + RegSetValueExW(key, (LPCWSTR)value_name.utf16().get_data(), 0, REG_SZ, (const BYTE *)cs_name.get_data(), cs_name.size() * sizeof(WCHAR)); + RegCloseKey(key); + } +#endif } SetCurrentProcessExplicitAppUserModelID((PCWSTR)appname.utf16().get_data()); @@ -5857,7 +6309,7 @@ DisplayServerWindows::DisplayServerWindows(const String &p_rendering_driver, Win window_position = scr_rect.position + (scr_rect.size - p_resolution) / 2; } - WindowID main_window = _create_window(p_mode, p_vsync_mode, p_flags, Rect2i(window_position, p_resolution)); + WindowID main_window = _create_window(p_mode, p_vsync_mode, p_flags, Rect2i(window_position, p_resolution), false, INVALID_WINDOW_ID); ERR_FAIL_COND_MSG(main_window == INVALID_WINDOW_ID, "Failed to create main window."); joypad = new JoypadWindows(&windows[MAIN_WINDOW_ID].hWnd); @@ -5934,32 +6386,41 @@ Vector<String> DisplayServerWindows::get_rendering_drivers_func() { DisplayServer *DisplayServerWindows::create_func(const String &p_rendering_driver, WindowMode p_mode, VSyncMode p_vsync_mode, uint32_t p_flags, const Vector2i *p_position, const Vector2i &p_resolution, int p_screen, Context p_context, Error &r_error) { DisplayServer *ds = memnew(DisplayServerWindows(p_rendering_driver, p_mode, p_vsync_mode, p_flags, p_position, p_resolution, p_screen, p_context, r_error)); if (r_error != OK) { - if (p_rendering_driver == "vulkan") { - String executable_name = OS::get_singleton()->get_executable_path().get_file(); - OS::get_singleton()->alert( - vformat("Your video card drivers seem not to support the required Vulkan version.\n\n" - "If possible, consider updating your video card drivers or using the OpenGL 3 driver.\n\n" - "You can enable the OpenGL 3 driver by starting the engine from the\n" - "command line with the command:\n\n \"%s\" --rendering-driver opengl3\n\n" - "If you have recently updated your video card drivers, try rebooting.", - executable_name), - "Unable to initialize Vulkan video driver"); - } else if (p_rendering_driver == "d3d12") { + if (tested_drivers == 0) { + OS::get_singleton()->alert("Failed to register the window class.", "Unable to initialize DisplayServer"); + } else if (tested_drivers.has_flag(DRIVER_ID_RD_VULKAN) || tested_drivers.has_flag(DRIVER_ID_RD_D3D12)) { + Vector<String> drivers; + if (tested_drivers.has_flag(DRIVER_ID_RD_VULKAN)) { + drivers.push_back("Vulkan"); + } + if (tested_drivers.has_flag(DRIVER_ID_RD_D3D12)) { + drivers.push_back("Direct3D 12"); + } String executable_name = OS::get_singleton()->get_executable_path().get_file(); OS::get_singleton()->alert( - vformat("Your video card drivers seem not to support the required DirectX 12 version.\n\n" + vformat("Your video card drivers seem not to support the required %s version.\n\n" "If possible, consider updating your video card drivers or using the OpenGL 3 driver.\n\n" "You can enable the OpenGL 3 driver by starting the engine from the\n" "command line with the command:\n\n \"%s\" --rendering-driver opengl3\n\n" "If you have recently updated your video card drivers, try rebooting.", + String(" or ").join(drivers), executable_name), - "Unable to initialize DirectX 12 video driver"); + "Unable to initialize video driver"); } else { + Vector<String> drivers; + if (tested_drivers.has_flag(DRIVER_ID_COMPAT_OPENGL3)) { + drivers.push_back("OpenGL 3.3"); + } + if (tested_drivers.has_flag(DRIVER_ID_COMPAT_ANGLE_D3D11)) { + drivers.push_back("Direct3D 11"); + } OS::get_singleton()->alert( - "Your video card drivers seem not to support the required OpenGL 3.3 version.\n\n" - "If possible, consider updating your video card drivers.\n\n" - "If you have recently updated your video card drivers, try rebooting.", - "Unable to initialize OpenGL video driver"); + vformat( + "Your video card drivers seem not to support the required %s version.\n\n" + "If possible, consider updating your video card drivers.\n\n" + "If you have recently updated your video card drivers, try rebooting.", + String(" or ").join(drivers)), + "Unable to initialize video driver"); } } return ds; @@ -5970,6 +6431,20 @@ void DisplayServerWindows::register_windows_driver() { } DisplayServerWindows::~DisplayServerWindows() { + LocalVector<List<FileDialogData *>::Element *> to_remove; + for (List<FileDialogData *>::Element *E = file_dialogs.front(); E; E = E->next()) { + FileDialogData *fd = E->get(); + if (fd->listener_thread.is_started()) { + fd->close_requested.set(); + fd->listener_thread.wait_to_finish(); + } + to_remove.push_back(E); + } + for (List<FileDialogData *>::Element *E : to_remove) { + memdelete(E->get()); + E->erase(); + } + delete joypad; touch_state.clear(); diff --git a/platform/windows/display_server_windows.h b/platform/windows/display_server_windows.h index 382f18c239..3deb7ac8b0 100644 --- a/platform/windows/display_server_windows.h +++ b/platform/windows/display_server_windows.h @@ -207,6 +207,50 @@ typedef UINT32 PEN_MASK; #define POINTER_MESSAGE_FLAG_FIRSTBUTTON 0x00000010 #endif +#ifndef POINTER_MESSAGE_FLAG_SECONDBUTTON +#define POINTER_MESSAGE_FLAG_SECONDBUTTON 0x00000020 +#endif + +#ifndef POINTER_MESSAGE_FLAG_THIRDBUTTON +#define POINTER_MESSAGE_FLAG_THIRDBUTTON 0x00000040 +#endif + +#ifndef POINTER_MESSAGE_FLAG_FOURTHBUTTON +#define POINTER_MESSAGE_FLAG_FOURTHBUTTON 0x00000080 +#endif + +#ifndef POINTER_MESSAGE_FLAG_FIFTHBUTTON +#define POINTER_MESSAGE_FLAG_FIFTHBUTTON 0x00000100 +#endif + +#ifndef IS_POINTER_FLAG_SET_WPARAM +#define IS_POINTER_FLAG_SET_WPARAM(wParam, flag) (((DWORD)HIWORD(wParam) & (flag)) == (flag)) +#endif + +#ifndef IS_POINTER_FIRSTBUTTON_WPARAM +#define IS_POINTER_FIRSTBUTTON_WPARAM(wParam) IS_POINTER_FLAG_SET_WPARAM(wParam, POINTER_MESSAGE_FLAG_FIRSTBUTTON) +#endif + +#ifndef IS_POINTER_SECONDBUTTON_WPARAM +#define IS_POINTER_SECONDBUTTON_WPARAM(wParam) IS_POINTER_FLAG_SET_WPARAM(wParam, POINTER_MESSAGE_FLAG_SECONDBUTTON) +#endif + +#ifndef IS_POINTER_THIRDBUTTON_WPARAM +#define IS_POINTER_THIRDBUTTON_WPARAM(wParam) IS_POINTER_FLAG_SET_WPARAM(wParam, POINTER_MESSAGE_FLAG_THIRDBUTTON) +#endif + +#ifndef IS_POINTER_FOURTHBUTTON_WPARAM +#define IS_POINTER_FOURTHBUTTON_WPARAM(wParam) IS_POINTER_FLAG_SET_WPARAM(wParam, POINTER_MESSAGE_FLAG_FOURTHBUTTON) +#endif + +#ifndef IS_POINTER_FIFTHBUTTON_WPARAM +#define IS_POINTER_FIFTHBUTTON_WPARAM(wParam) IS_POINTER_FLAG_SET_WPARAM(wParam, POINTER_MESSAGE_FLAG_FIFTHBUTTON) +#endif + +#ifndef GET_POINTERID_WPARAM +#define GET_POINTERID_WPARAM(wParam) (LOWORD(wParam)) +#endif + #if WINVER < 0x0602 enum tagPOINTER_INPUT_TYPE { PT_POINTER = 0x00000001, @@ -274,10 +318,19 @@ typedef struct tagPOINTER_PEN_INFO { #define WM_POINTERLEAVE 0x024A #endif +#ifndef WM_POINTERDOWN +#define WM_POINTERDOWN 0x0246 +#endif + +#ifndef WM_POINTERUP +#define WM_POINTERUP 0x0247 +#endif + typedef BOOL(WINAPI *GetPointerTypePtr)(uint32_t p_id, POINTER_INPUT_TYPE *p_type); typedef BOOL(WINAPI *GetPointerPenInfoPtr)(uint32_t p_id, POINTER_PEN_INFO *p_pen_info); typedef BOOL(WINAPI *LogicalToPhysicalPointForPerMonitorDPIPtr)(HWND hwnd, LPPOINT lpPoint); typedef BOOL(WINAPI *PhysicalToLogicalPointForPerMonitorDPIPtr)(HWND hwnd, LPPOINT lpPoint); +typedef HRESULT(WINAPI *SHLoadIndirectStringPtr)(PCWSTR pszSource, PWSTR pszOutBuf, UINT cchOutBuf, void **ppvReserved); typedef struct { BYTE bWidth; // Width, in pixels, of the image @@ -297,6 +350,12 @@ typedef struct { ICONDIRENTRY idEntries[1]; // An entry for each image (idCount of 'em) } ICONDIR, *LPICONDIR; +typedef enum _SHC_PROCESS_DPI_AWARENESS { + SHC_PROCESS_DPI_UNAWARE = 0, + SHC_PROCESS_SYSTEM_DPI_AWARE = 1, + SHC_PROCESS_PER_MONITOR_DPI_AWARE = 2, +} SHC_PROCESS_DPI_AWARENESS; + class DisplayServerWindows : public DisplayServer { // No need to register with GDCLASS, it's platform-specific and nothing is added. @@ -328,10 +387,26 @@ class DisplayServerWindows : public DisplayServer { static LogicalToPhysicalPointForPerMonitorDPIPtr win81p_LogicalToPhysicalPointForPerMonitorDPI; static PhysicalToLogicalPointForPerMonitorDPIPtr win81p_PhysicalToLogicalPointForPerMonitorDPI; + // Shell API + static SHLoadIndirectStringPtr load_indirect_string; + void _update_tablet_ctx(const String &p_old_driver, const String &p_new_driver); String tablet_driver; Vector<String> tablet_drivers; + enum DriverID { + DRIVER_ID_COMPAT_OPENGL3 = 1 << 0, + DRIVER_ID_COMPAT_ANGLE_D3D11 = 1 << 1, + DRIVER_ID_RD_VULKAN = 1 << 2, + DRIVER_ID_RD_D3D12 = 1 << 3, + }; + static BitField<DriverID> tested_drivers; + + enum TimerID { + TIMER_ID_MOVE_REDRAW = 1, + TIMER_ID_WINDOW_ACTIVATION = 2, + }; + enum { KEY_EVENT_BUFFER_SIZE = 512 }; @@ -380,6 +455,7 @@ class DisplayServerWindows : public DisplayServer { Vector<Vector2> mpath; + bool create_completed = false; bool pre_fs_valid = false; RECT pre_fs_rect; bool maximized = false; @@ -456,12 +532,12 @@ class DisplayServerWindows : public DisplayServer { uint64_t time_since_popup = 0; Ref<Image> icon; - WindowID _create_window(WindowMode p_mode, VSyncMode p_vsync_mode, uint32_t p_flags, const Rect2i &p_rect); + WindowID _create_window(WindowMode p_mode, VSyncMode p_vsync_mode, uint32_t p_flags, const Rect2i &p_rect, bool p_exclusive, WindowID p_transient_parent); WindowID window_id_counter = MAIN_WINDOW_ID; RBMap<WindowID, WindowData> windows; WindowID last_focused_window = INVALID_WINDOW_ID; - + WindowID last_mouse_button_down_window = INVALID_WINDOW_ID; HCURSOR hCursor; WNDPROC user_proc = nullptr; @@ -474,6 +550,36 @@ class DisplayServerWindows : public DisplayServer { IndicatorID indicator_id_counter = 0; HashMap<IndicatorID, IndicatorData> indicators; + struct FileDialogData { + HWND hwnd_owner = 0; + Rect2i wrect; + String appid; + String title; + String current_directory; + String root; + String filename; + bool show_hidden = false; + DisplayServer::FileDialogMode mode = FileDialogMode::FILE_DIALOG_MODE_OPEN_ANY; + Vector<String> filters; + TypedArray<Dictionary> options; + WindowID window_id = DisplayServer::INVALID_WINDOW_ID; + Callable callback; + bool options_in_cb = false; + Thread listener_thread; + SafeFlag close_requested; + SafeFlag finished; + }; + Mutex file_dialog_mutex; + List<FileDialogData *> file_dialogs; + HashMap<HWND, FileDialogData *> file_dialog_wnd; + + static void _thread_fd_monitor(void *p_ud); + + HashMap<int64_t, MouseButton> pointer_prev_button; + HashMap<int64_t, MouseButton> pointer_button; + HashMap<int64_t, LONG> pointer_down_time; + HashMap<int64_t, Vector2> pointer_last_pos; + void _send_window_event(const WindowData &wd, WindowEvent p_event); void _get_window_style(bool p_main_window, bool p_fullscreen, bool p_multiwindow_fs, bool p_borderless, bool p_resizable, bool p_maximized, bool p_maximized_fs, bool p_no_activate_focus, DWORD &r_style, DWORD &r_style_ex); @@ -526,7 +632,11 @@ class DisplayServerWindows : public DisplayServer { Error _file_dialog_with_options_show(const String &p_title, const String &p_current_directory, const String &p_root, const String &p_filename, bool p_show_hidden, FileDialogMode p_mode, const Vector<String> &p_filters, const TypedArray<Dictionary> &p_options, const Callable &p_callback, bool p_options_in_cb); + String _get_keyboard_layout_display_name(const String &p_klid) const; + String _get_klid(HKL p_hkl) const; + public: + LRESULT WndProcFileDialog(HWND hWnd, UINT uMsg, WPARAM wParam, LPARAM lParam); LRESULT WndProc(HWND hWnd, UINT uMsg, WPARAM wParam, LPARAM lParam); LRESULT MouseProc(int code, WPARAM wParam, LPARAM lParam); @@ -583,7 +693,7 @@ public: virtual Vector<DisplayServer::WindowID> get_window_list() const override; - virtual WindowID create_sub_window(WindowMode p_mode, VSyncMode p_vsync_mode, uint32_t p_flags, const Rect2i &p_rect = Rect2i()) override; + virtual WindowID create_sub_window(WindowMode p_mode, VSyncMode p_vsync_mode, uint32_t p_flags, const Rect2i &p_rect = Rect2i(), bool p_exclusive = false, WindowID p_transient_parent = INVALID_WINDOW_ID) override; virtual void show_window(WindowID p_window) override; virtual void delete_sub_window(WindowID p_window) override; diff --git a/platform/windows/doc_classes/EditorExportPlatformWindows.xml b/platform/windows/doc_classes/EditorExportPlatformWindows.xml index 06b272c10e..9e2db756ce 100644 --- a/platform/windows/doc_classes/EditorExportPlatformWindows.xml +++ b/platform/windows/doc_classes/EditorExportPlatformWindows.xml @@ -26,7 +26,7 @@ If set to [code]1[/code], ANGLE libraries are exported with the exported application. If set to [code]0[/code], ANGLE libraries are exported only if [member ProjectSettings.rendering/gl_compatibility/driver] is set to [code]"opengl3_angle"[/code]. </member> <member name="application/export_d3d12" type="int" setter="" getter=""> - If set to [code]1[/code], Direct3D 12 runtime (DXIL, Agility SDK, PIX) libraries are exported with the exported application. If set to [code]0[/code], Direct3D 12 libraries are exported only if [member ProjectSettings.rendering/rendering_device/driver] is set to [code]"d3d12"[/code]. + If set to [code]1[/code], the Direct3D 12 runtime libraries (Agility SDK, PIX) are exported with the exported application. If set to [code]0[/code], Direct3D 12 libraries are exported only if [member ProjectSettings.rendering/rendering_device/driver] is set to [code]"d3d12"[/code]. </member> <member name="application/file_description" type="String" setter="" getter=""> File description to be presented to users. Required. See [url=https://learn.microsoft.com/en-us/windows/win32/menurc/stringfileinfo-block]StringFileInfo[/url]. diff --git a/platform/windows/export/export_plugin.cpp b/platform/windows/export/export_plugin.cpp index 6ce9d27dc5..b465bd4ecd 100644 --- a/platform/windows/export/export_plugin.cpp +++ b/platform/windows/export/export_plugin.cpp @@ -187,6 +187,12 @@ Error EditorExportPlatformWindows::export_project(const Ref<EditorExportPreset> template_path = template_path.strip_edges(); if (template_path.is_empty()) { template_path = find_export_template(get_template_file_name(p_debug ? "debug" : "release", arch)); + } else { + String exe_arch = _get_exe_arch(template_path); + if (arch != exe_arch) { + add_message(EXPORT_MESSAGE_ERROR, TTR("Prepare Templates"), vformat(TTR("Mismatching custom export template executable architecture: found \"%s\", expected \"%s\"."), exe_arch, arch)); + return ERR_CANT_CREATE; + } } int export_angle = p_preset->get("application/export_angle"); @@ -208,18 +214,14 @@ Error EditorExportPlatformWindows::export_project(const Ref<EditorExportPreset> int export_d3d12 = p_preset->get("application/export_d3d12"); bool agility_sdk_multiarch = p_preset->get("application/d3d12_agility_sdk_multiarch"); - bool include_dxil_libs = false; + bool include_d3d12_extra_libs = false; if (export_d3d12 == 0) { - include_dxil_libs = (String(GLOBAL_GET("rendering/rendering_device/driver.windows")) == "d3d12") && (String(GLOBAL_GET("rendering/renderer/rendering_method")) != "gl_compatibility"); + include_d3d12_extra_libs = (String(GLOBAL_GET("rendering/rendering_device/driver.windows")) == "d3d12") && (String(GLOBAL_GET("rendering/renderer/rendering_method")) != "gl_compatibility"); } else if (export_d3d12 == 1) { - include_dxil_libs = true; + include_d3d12_extra_libs = true; } - if (include_dxil_libs) { + if (include_d3d12_extra_libs) { Ref<DirAccess> da = DirAccess::create(DirAccess::ACCESS_FILESYSTEM); - if (da->file_exists(template_path.get_base_dir().path_join("dxil." + arch + ".dll"))) { - da->make_dir_recursive(p_path.get_base_dir().path_join(arch)); - da->copy(template_path.get_base_dir().path_join("dxil." + arch + ".dll"), p_path.get_base_dir().path_join(arch).path_join("dxil.dll"), get_chmod_flags()); - } if (da->file_exists(template_path.get_base_dir().path_join("D3D12Core." + arch + ".dll"))) { if (agility_sdk_multiarch) { da->make_dir_recursive(p_path.get_base_dir().path_join(arch)); @@ -757,9 +759,26 @@ Error EditorExportPlatformWindows::_code_sign(const Ref<EditorExportPreset> &p_p } bool EditorExportPlatformWindows::has_valid_export_configuration(const Ref<EditorExportPreset> &p_preset, String &r_error, bool &r_missing_templates, bool p_debug) const { - String err = ""; + String err; bool valid = EditorExportPlatformPC::has_valid_export_configuration(p_preset, err, r_missing_templates, p_debug); + String custom_debug = p_preset->get("custom_template/debug").operator String().strip_edges(); + String custom_release = p_preset->get("custom_template/release").operator String().strip_edges(); + String arch = p_preset->get("binary_format/architecture"); + + if (!custom_debug.is_empty() && FileAccess::exists(custom_debug)) { + String exe_arch = _get_exe_arch(custom_debug); + if (arch != exe_arch) { + err += vformat(TTR("Mismatching custom debug export template executable architecture: found \"%s\", expected \"%s\"."), exe_arch, arch) + "\n"; + } + } + if (!custom_release.is_empty() && FileAccess::exists(custom_release)) { + String exe_arch = _get_exe_arch(custom_release); + if (arch != exe_arch) { + err += vformat(TTR("Mismatching custom release export template executable architecture: found \"%s\", expected \"%s\"."), exe_arch, arch) + "\n"; + } + } + String rcedit_path = EDITOR_GET("export/windows/rcedit"); if (p_preset->get("application/modify_resources") && rcedit_path.is_empty()) { err += TTR("The rcedit tool must be configured in the Editor Settings (Export > Windows > rcedit) to change the icon or app information data.") + "\n"; @@ -773,7 +792,7 @@ bool EditorExportPlatformWindows::has_valid_export_configuration(const Ref<Edito } bool EditorExportPlatformWindows::has_valid_project_configuration(const Ref<EditorExportPreset> &p_preset, String &r_error) const { - String err = ""; + String err; bool valid = true; List<ExportOption> options; @@ -797,6 +816,43 @@ bool EditorExportPlatformWindows::has_valid_project_configuration(const Ref<Edit return valid; } +String EditorExportPlatformWindows::_get_exe_arch(const String &p_path) const { + Ref<FileAccess> f = FileAccess::open(p_path, FileAccess::READ); + if (f.is_null()) { + return "invalid"; + } + + // Jump to the PE header and check the magic number. + { + f->seek(0x3c); + uint32_t pe_pos = f->get_32(); + + f->seek(pe_pos); + uint32_t magic = f->get_32(); + if (magic != 0x00004550) { + return "invalid"; + } + } + + // Process header. + uint16_t machine = f->get_16(); + f->close(); + + switch (machine) { + case 0x014c: + return "x86_32"; + case 0x8664: + return "x86_64"; + case 0x01c0: + case 0x01c4: + return "arm32"; + case 0xaa64: + return "arm64"; + default: + return "unknown"; + } +} + Error EditorExportPlatformWindows::fixup_embedded_pck(const String &p_path, int64_t p_embedded_start, int64_t p_embedded_size) { // Patch the header of the "pck" section in the PE file so that it corresponds to the embedded data diff --git a/platform/windows/export/export_plugin.h b/platform/windows/export/export_plugin.h index c644b1f9e1..6ccb4a15a7 100644 --- a/platform/windows/export/export_plugin.h +++ b/platform/windows/export/export_plugin.h @@ -73,6 +73,8 @@ class EditorExportPlatformWindows : public EditorExportPlatformPC { Error _rcedit_add_data(const Ref<EditorExportPreset> &p_preset, const String &p_path, bool p_console_icon); Error _code_sign(const Ref<EditorExportPreset> &p_preset, const String &p_path); + String _get_exe_arch(const String &p_path) const; + public: virtual Error export_project(const Ref<EditorExportPreset> &p_preset, bool p_debug, const String &p_path, int p_flags = 0) override; virtual Error modify_template(const Ref<EditorExportPreset> &p_preset, bool p_debug, const String &p_path, int p_flags) override; diff --git a/platform/windows/gl_manager_windows_angle.cpp b/platform/windows/gl_manager_windows_angle.cpp index 3086edc7f2..c52564676f 100644 --- a/platform/windows/gl_manager_windows_angle.cpp +++ b/platform/windows/gl_manager_windows_angle.cpp @@ -67,4 +67,9 @@ Vector<EGLint> GLManagerANGLE_Windows::_get_platform_context_attribs() const { return ret; } +void GLManagerANGLE_Windows::window_resize(DisplayServer::WindowID p_window_id, int p_width, int p_height) { + window_make_current(p_window_id); + eglWaitNative(EGL_CORE_NATIVE_ENGINE); +} + #endif // WINDOWS_ENABLED && GLES3_ENABLED diff --git a/platform/windows/gl_manager_windows_angle.h b/platform/windows/gl_manager_windows_angle.h index d8dc651cfd..f43a6fbe02 100644 --- a/platform/windows/gl_manager_windows_angle.h +++ b/platform/windows/gl_manager_windows_angle.h @@ -50,7 +50,7 @@ private: virtual Vector<EGLint> _get_platform_context_attribs() const override; public: - void window_resize(DisplayServer::WindowID p_window_id, int p_width, int p_height) {} + void window_resize(DisplayServer::WindowID p_window_id, int p_width, int p_height); GLManagerANGLE_Windows(){}; ~GLManagerANGLE_Windows(){}; diff --git a/platform/windows/gl_manager_windows_native.cpp b/platform/windows/gl_manager_windows_native.cpp index c8d7534e26..8590c46d12 100644 --- a/platform/windows/gl_manager_windows_native.cpp +++ b/platform/windows/gl_manager_windows_native.cpp @@ -76,6 +76,8 @@ static String format_error_message(DWORD id) { const int OGL_THREAD_CONTROL_ID = 0x20C1221E; const int OGL_THREAD_CONTROL_DISABLE = 0x00000002; const int OGL_THREAD_CONTROL_ENABLE = 0x00000001; +const int VRR_MODE_ID = 0x1194F158; +const int VRR_MODE_FULLSCREEN_ONLY = 0x1; typedef int(__cdecl *NvAPI_Initialize_t)(); typedef int(__cdecl *NvAPI_Unload_t)(); @@ -104,10 +106,12 @@ static bool nvapi_err_check(const char *msg, int status) { return true; } -// On windows we have to disable threaded optimization when using NVIDIA graphics cards -// to avoid stuttering, see https://stackoverflow.com/questions/36959508/nvidia-graphics-driver-causing-noticeable-frame-stuttering/37632948 -// also see https://github.com/Ryujinx/Ryujinx/blob/master/src/Ryujinx.Common/GraphicsDriver/NVThreadedOptimization.cs -void GLManagerNative_Windows::_nvapi_disable_threaded_optimization() { +// On windows we have to customize the NVIDIA application profile: +// * disable threaded optimization when using NVIDIA cards to avoid stuttering, see +// https://stackoverflow.com/questions/36959508/nvidia-graphics-driver-causing-noticeable-frame-stuttering/37632948 +// https://github.com/Ryujinx/Ryujinx/blob/master/src/Ryujinx.Common/GraphicsDriver/NVThreadedOptimization.cs +// * disable G-SYNC in windowed mode, as it results in unstable editor refresh rates +void GLManagerNative_Windows::_nvapi_setup_profile() { HMODULE nvapi = nullptr; #ifdef _WIN64 nvapi = LoadLibraryA("nvapi64.dll"); @@ -239,21 +243,29 @@ void GLManagerNative_Windows::_nvapi_disable_threaded_optimization() { } } - NVDRS_SETTING setting; - setting.version = NVDRS_SETTING_VER; - setting.settingId = OGL_THREAD_CONTROL_ID; - setting.settingType = NVDRS_DWORD_TYPE; - setting.settingLocation = NVDRS_CURRENT_PROFILE_LOCATION; - setting.isCurrentPredefined = 0; - setting.isPredefinedValid = 0; + NVDRS_SETTING ogl_thread_control_setting = {}; + ogl_thread_control_setting.version = NVDRS_SETTING_VER; + ogl_thread_control_setting.settingId = OGL_THREAD_CONTROL_ID; + ogl_thread_control_setting.settingType = NVDRS_DWORD_TYPE; int thread_control_val = OGL_THREAD_CONTROL_DISABLE; if (!GLOBAL_GET("rendering/gl_compatibility/nvidia_disable_threaded_optimization")) { thread_control_val = OGL_THREAD_CONTROL_ENABLE; } - setting.u32CurrentValue = thread_control_val; - setting.u32PredefinedValue = thread_control_val; + ogl_thread_control_setting.u32CurrentValue = thread_control_val; - if (!nvapi_err_check("NVAPI: Error calling NvAPI_DRS_SetSetting", NvAPI_DRS_SetSetting(session_handle, profile_handle, &setting))) { + if (!nvapi_err_check("NVAPI: Error calling NvAPI_DRS_SetSetting", NvAPI_DRS_SetSetting(session_handle, profile_handle, &ogl_thread_control_setting))) { + NvAPI_DRS_DestroySession(session_handle); + NvAPI_Unload(); + return; + } + + NVDRS_SETTING vrr_mode_setting = {}; + vrr_mode_setting.version = NVDRS_SETTING_VER; + vrr_mode_setting.settingId = VRR_MODE_ID; + vrr_mode_setting.settingType = NVDRS_DWORD_TYPE; + vrr_mode_setting.u32CurrentValue = VRR_MODE_FULLSCREEN_ONLY; + + if (!nvapi_err_check("NVAPI: Error calling NvAPI_DRS_SetSetting", NvAPI_DRS_SetSetting(session_handle, profile_handle, &vrr_mode_setting))) { NvAPI_DRS_DestroySession(session_handle); NvAPI_Unload(); return; @@ -270,6 +282,7 @@ void GLManagerNative_Windows::_nvapi_disable_threaded_optimization() { } else { print_verbose("NVAPI: Enabled OpenGL threaded optimization successfully"); } + print_verbose("NVAPI: Disabled G-SYNC for windowed mode successfully"); NvAPI_DRS_DestroySession(session_handle); } @@ -495,7 +508,7 @@ void GLManagerNative_Windows::swap_buffers() { } Error GLManagerNative_Windows::initialize() { - _nvapi_disable_threaded_optimization(); + _nvapi_setup_profile(); return OK; } diff --git a/platform/windows/gl_manager_windows_native.h b/platform/windows/gl_manager_windows_native.h index b4e2a3acdf..532092ae74 100644 --- a/platform/windows/gl_manager_windows_native.h +++ b/platform/windows/gl_manager_windows_native.h @@ -78,7 +78,7 @@ private: int glx_minor, glx_major; private: - void _nvapi_disable_threaded_optimization(); + void _nvapi_setup_profile(); int _find_or_create_display(GLWindow &win); Error _create_context(GLWindow &win, GLDisplay &gl_display); diff --git a/platform/windows/native_menu_windows.cpp b/platform/windows/native_menu_windows.cpp index d9dc28e9d9..fde55918e4 100644 --- a/platform/windows/native_menu_windows.cpp +++ b/platform/windows/native_menu_windows.cpp @@ -81,22 +81,6 @@ void NativeMenuWindows::_menu_activate(HMENU p_menu, int p_index) const { if (GetMenuItemInfoW(md->menu, p_index, true, &item)) { MenuItemData *item_data = (MenuItemData *)item.dwItemData; if (item_data) { - if (item_data->max_states > 0) { - item_data->state++; - if (item_data->state >= item_data->max_states) { - item_data->state = 0; - } - } - - if (item_data->checkable_type == CHECKABLE_TYPE_CHECK_BOX) { - if ((item.fState & MFS_CHECKED) == MFS_CHECKED) { - item.fState &= ~MFS_CHECKED; - } else { - item.fState |= MFS_CHECKED; - } - SetMenuItemInfoW(md->menu, p_index, true, &item); - } - if (item_data->callback.is_valid()) { Variant ret; Callable::CallError ce; @@ -619,9 +603,12 @@ bool NativeMenuWindows::is_item_checked(const RID &p_rid, int p_idx) const { MENUITEMINFOW item; ZeroMemory(&item, sizeof(item)); item.cbSize = sizeof(item); - item.fMask = MIIM_STATE; + item.fMask = MIIM_STATE | MIIM_DATA; if (GetMenuItemInfoW(md->menu, p_idx, true, &item)) { - return (item.fState & MFS_CHECKED) == MFS_CHECKED; + MenuItemData *item_data = (MenuItemData *)item.dwItemData; + if (item_data) { + return item_data->checked; + } } return false; } @@ -861,12 +848,16 @@ void NativeMenuWindows::set_item_checked(const RID &p_rid, int p_idx, bool p_che MENUITEMINFOW item; ZeroMemory(&item, sizeof(item)); item.cbSize = sizeof(item); - item.fMask = MIIM_STATE; + item.fMask = MIIM_STATE | MIIM_DATA; if (GetMenuItemInfoW(md->menu, p_idx, true, &item)) { - if (p_checked) { - item.fState |= MFS_CHECKED; - } else { - item.fState &= ~MFS_CHECKED; + MenuItemData *item_data = (MenuItemData *)item.dwItemData; + if (item_data) { + item_data->checked = p_checked; + if (p_checked) { + item.fState |= MFS_CHECKED; + } else { + item.fState &= ~MFS_CHECKED; + } } SetMenuItemInfoW(md->menu, p_idx, true, &item); } diff --git a/platform/windows/native_menu_windows.h b/platform/windows/native_menu_windows.h index 5c4aaa52c8..235a4b332a 100644 --- a/platform/windows/native_menu_windows.h +++ b/platform/windows/native_menu_windows.h @@ -51,6 +51,7 @@ class NativeMenuWindows : public NativeMenu { Callable callback; Variant meta; GlobalMenuCheckType checkable_type; + bool checked = false; int max_states = 0; int state = 0; Ref<Image> img; diff --git a/platform/windows/os_windows.cpp b/platform/windows/os_windows.cpp index 157702655e..7316992b60 100644 --- a/platform/windows/os_windows.cpp +++ b/platform/windows/os_windows.cpp @@ -166,15 +166,9 @@ void OS_Windows::initialize_debugging() { static void _error_handler(void *p_self, const char *p_func, const char *p_file, int p_line, const char *p_error, const char *p_errorexp, bool p_editor_notify, ErrorHandlerType p_type) { String err_str; if (p_errorexp && p_errorexp[0]) { - err_str = String::utf8(p_errorexp); + err_str = String::utf8(p_errorexp) + "\n"; } else { - err_str = String::utf8(p_file) + ":" + itos(p_line) + " - " + String::utf8(p_error); - } - - if (p_editor_notify) { - err_str += " (User)\n"; - } else { - err_str += "\n"; + err_str = String::utf8(p_file) + ":" + itos(p_line) + " - " + String::utf8(p_error) + "\n"; } OutputDebugStringW((LPCWSTR)err_str.utf16().ptr()); @@ -379,6 +373,8 @@ Error OS_Windows::open_dynamic_library(const String &p_path, void *&p_library_ha //this code exists so gdextension can load .dll files from within the executable path path = get_executable_path().get_base_dir().path_join(p_path.get_file()); } + // Path to load from may be different from original if we make copies. + String load_path = path; ERR_FAIL_COND_V(!FileAccess::exists(path), ERR_FILE_NOT_FOUND); @@ -387,25 +383,22 @@ Error OS_Windows::open_dynamic_library(const String &p_path, void *&p_library_ha if (p_data != nullptr && p_data->generate_temp_files) { // Copy the file to the same directory as the original with a prefix in the name. // This is so relative path to dependencies are satisfied. - String copy_path = path.get_base_dir().path_join("~" + path.get_file()); + load_path = path.get_base_dir().path_join("~" + path.get_file()); // If there's a left-over copy (possibly from a crash) then delete it first. - if (FileAccess::exists(copy_path)) { - DirAccess::remove_absolute(copy_path); + if (FileAccess::exists(load_path)) { + DirAccess::remove_absolute(load_path); } - Error copy_err = DirAccess::copy_absolute(path, copy_path); + Error copy_err = DirAccess::copy_absolute(path, load_path); if (copy_err) { ERR_PRINT("Error copying library: " + path); return ERR_CANT_CREATE; } - FileAccess::set_hidden_attribute(copy_path, true); - - // Save the copied path so it can be deleted later. - path = copy_path; + FileAccess::set_hidden_attribute(load_path, true); - Error pdb_err = WindowsUtils::copy_and_rename_pdb(path); + Error pdb_err = WindowsUtils::copy_and_rename_pdb(load_path); if (pdb_err != OK && pdb_err != ERR_SKIP) { WARN_PRINT(vformat("Failed to rename the PDB file. The original PDB file for '%s' will be loaded.", path)); } @@ -421,21 +414,21 @@ Error OS_Windows::open_dynamic_library(const String &p_path, void *&p_library_ha DLL_DIRECTORY_COOKIE cookie = nullptr; if (p_data != nullptr && p_data->also_set_library_path && has_dll_directory_api) { - cookie = add_dll_directory((LPCWSTR)(path.get_base_dir().utf16().get_data())); + cookie = add_dll_directory((LPCWSTR)(load_path.get_base_dir().utf16().get_data())); } - p_library_handle = (void *)LoadLibraryExW((LPCWSTR)(path.utf16().get_data()), nullptr, (p_data != nullptr && p_data->also_set_library_path && has_dll_directory_api) ? LOAD_LIBRARY_SEARCH_DEFAULT_DIRS : 0); + p_library_handle = (void *)LoadLibraryExW((LPCWSTR)(load_path.utf16().get_data()), nullptr, (p_data != nullptr && p_data->also_set_library_path && has_dll_directory_api) ? LOAD_LIBRARY_SEARCH_DEFAULT_DIRS : 0); if (!p_library_handle) { if (p_data != nullptr && p_data->generate_temp_files) { - DirAccess::remove_absolute(path); + DirAccess::remove_absolute(load_path); } #ifdef DEBUG_ENABLED DWORD err_code = GetLastError(); - HashSet<String> checekd_libs; + HashSet<String> checked_libs; HashSet<String> missing_libs; - debug_dynamic_library_check_dependencies(path, path, checekd_libs, missing_libs); + debug_dynamic_library_check_dependencies(load_path, load_path, checked_libs, missing_libs); if (!missing_libs.is_empty()) { String missing; for (const String &E : missing_libs) { @@ -464,7 +457,8 @@ Error OS_Windows::open_dynamic_library(const String &p_path, void *&p_library_ha } if (p_data != nullptr && p_data->generate_temp_files) { - temp_libraries[p_library_handle] = path; + // Save the copied path so it can be deleted later. + temp_libraries[p_library_handle] = load_path; } return OK; @@ -622,6 +616,72 @@ Vector<String> OS_Windows::get_video_adapter_driver_info() const { return info; } +bool OS_Windows::get_user_prefers_integrated_gpu() const { + // On Windows 10, the preferred GPU configured in Windows Settings is + // stored in the registry under the key + // `HKEY_CURRENT_USER\SOFTWARE\Microsoft\DirectX\UserGpuPreferences` + // with the name being the app ID or EXE path. The value is in the form of + // `GpuPreference=1;`, with the value being 1 for integrated GPU and 2 + // for discrete GPU. On Windows 11, there may be more flags, separated + // by semicolons. + + // If this is a packaged app, use the "application user model ID". + // Otherwise, use the EXE path. + WCHAR value_name[32768]; + bool is_packaged = false; + { + HMODULE kernel32 = GetModuleHandleW(L"kernel32.dll"); + if (kernel32) { + using GetCurrentApplicationUserModelIdPtr = LONG(WINAPI *)(UINT32 * length, PWSTR id); + GetCurrentApplicationUserModelIdPtr GetCurrentApplicationUserModelId = (GetCurrentApplicationUserModelIdPtr)GetProcAddress(kernel32, "GetCurrentApplicationUserModelId"); + + if (GetCurrentApplicationUserModelId) { + UINT32 length = sizeof(value_name) / sizeof(value_name[0]); + LONG result = GetCurrentApplicationUserModelId(&length, value_name); + if (result == ERROR_SUCCESS) { + is_packaged = true; + } + } + } + } + if (!is_packaged && GetModuleFileNameW(nullptr, value_name, sizeof(value_name) / sizeof(value_name[0])) >= sizeof(value_name) / sizeof(value_name[0])) { + // Paths should never be longer than 32767, but just in case. + return false; + } + + LPCWSTR subkey = L"SOFTWARE\\Microsoft\\DirectX\\UserGpuPreferences"; + HKEY hkey = nullptr; + LSTATUS result = RegOpenKeyExW(HKEY_CURRENT_USER, subkey, 0, KEY_READ, &hkey); + if (result != ERROR_SUCCESS) { + return false; + } + + DWORD size = 0; + result = RegGetValueW(hkey, nullptr, value_name, RRF_RT_REG_SZ, nullptr, nullptr, &size); + if (result != ERROR_SUCCESS || size == 0) { + RegCloseKey(hkey); + return false; + } + + Vector<WCHAR> buffer; + buffer.resize(size / sizeof(WCHAR)); + result = RegGetValueW(hkey, nullptr, value_name, RRF_RT_REG_SZ, nullptr, (LPBYTE)buffer.ptrw(), &size); + if (result != ERROR_SUCCESS) { + RegCloseKey(hkey); + return false; + } + + RegCloseKey(hkey); + const String flags = String::utf16((const char16_t *)buffer.ptr(), size / sizeof(WCHAR)); + + for (const String &flag : flags.split(";", false)) { + if (flag == "GpuPreference=1") { + return true; + } + } + return false; +} + OS::DateTime OS_Windows::get_datetime(bool p_utc) const { SYSTEMTIME systemtime; if (p_utc) { @@ -1634,26 +1694,6 @@ String OS_Windows::get_locale() const { return "en"; } -// We need this because GetSystemInfo() is unreliable on WOW64 -// see https://msdn.microsoft.com/en-us/library/windows/desktop/ms724381(v=vs.85).aspx -// Taken from MSDN -typedef BOOL(WINAPI *LPFN_ISWOW64PROCESS)(HANDLE, PBOOL); -LPFN_ISWOW64PROCESS fnIsWow64Process; - -BOOL is_wow64() { - BOOL wow64 = FALSE; - - fnIsWow64Process = (LPFN_ISWOW64PROCESS)GetProcAddress(GetModuleHandle(TEXT("kernel32")), "IsWow64Process"); - - if (fnIsWow64Process) { - if (!fnIsWow64Process(GetCurrentProcess(), &wow64)) { - wow64 = FALSE; - } - } - - return wow64; -} - String OS_Windows::get_processor_name() const { const String id = "Hardware\\Description\\System\\CentralProcessor\\0"; diff --git a/platform/windows/os_windows.h b/platform/windows/os_windows.h index b6a21ed42d..9c7b98d7fd 100644 --- a/platform/windows/os_windows.h +++ b/platform/windows/os_windows.h @@ -172,6 +172,7 @@ public: virtual String get_version() const override; virtual Vector<String> get_video_adapter_driver_info() const override; + virtual bool get_user_prefers_integrated_gpu() const override; virtual void initialize_joypads() override {} diff --git a/platform/windows/rendering_context_driver_vulkan_windows.cpp b/platform/windows/rendering_context_driver_vulkan_windows.cpp index f968ffc1d7..445388af89 100644 --- a/platform/windows/rendering_context_driver_vulkan_windows.cpp +++ b/platform/windows/rendering_context_driver_vulkan_windows.cpp @@ -64,7 +64,7 @@ RenderingContextDriver::SurfaceID RenderingContextDriverVulkanWindows::surface_c create_info.hwnd = wpd->window; VkSurfaceKHR vk_surface = VK_NULL_HANDLE; - VkResult err = vkCreateWin32SurfaceKHR(instance_get(), &create_info, nullptr, &vk_surface); + VkResult err = vkCreateWin32SurfaceKHR(instance_get(), &create_info, get_allocation_callbacks(VK_OBJECT_TYPE_SURFACE_KHR), &vk_surface); ERR_FAIL_COND_V(err != VK_SUCCESS, SurfaceID()); Surface *surface = memnew(Surface); |