From 18929c27f7e48646645313e354df73388f5d9219 Mon Sep 17 00:00:00 2001 From: Scorbutics Date: Fri, 20 Mar 2026 11:25:58 +1000 Subject: [PATCH 1/7] feat: encrypt the archive --- plugins/export_android.rb | 76 ++++++++++++++++++++++++++++++--------- 1 file changed, 59 insertions(+), 17 deletions(-) diff --git a/plugins/export_android.rb b/plugins/export_android.rb index 1e1e3beb..71bc2234 100644 --- a/plugins/export_android.rb +++ b/plugins/export_android.rb @@ -1,29 +1,71 @@ -# Create the PSDK Source Archive +# Create the encrypted PSDK Source Archive for Android # Needs the gem 'rubyzip' require 'zip' +require 'openssl' +require 'tempfile' -def zip_all_recursive(zipfile, path) - path = "#{path}/**" - - Dir.glob(path).each do |file_or_dir| - if File.directory? (file_or_dir) - zip_all_recursive(zipfile, file_or_dir) - else - puts "Adding #{file_or_dir}" - zipfile.add(file_or_dir, file_or_dir) - end - end +ENCRYPTION_KEY = ['1f24dd020fb077983c537dd29af01b9188406ce835bca75567b54db9be9f83f9'].pack('H*') +EPSA_MAGIC = 'PSAE' +EPSA_VERSION = 1 +unless ENCRYPTION_KEY.bytesize == 32 + STDERR.puts "ENCRYPTION_KEY must be exactly 32 bytes (got #{ENCRYPTION_KEY.bytesize})" + exit 1 end folders = ['graphics', 'Fonts', 'Data', 'audio', 'pokemonsdk', 'scripts'] folders = folders.concat(["Saves"]) if ARGV.include?('--with_saves') -ARCHIVE_NAME = (ARGV.find { |arg| !arg.start_with?('--') } || "project") + ".psa" -File.delete(ARCHIVE_NAME) if File.exist? ARCHIVE_NAME +no_encrypt = ARGV.include?('--no-encrypt') +BASE_NAME = ARGV.find { |arg| !arg.start_with?('--') } || "project" -Zip::File.open(ARCHIVE_NAME, create: true) do |zipfile| - zip_all_recursive(zipfile, '{' + folders.join(',') + '}') - zipfile.add("Game.rb", "Game.rb") +# Build ZIP to a temp file (proper format with central directory, required by PhysFS) +tmp_zip = Tempfile.new(['psdk_archive', '.zip'], binmode: true) +begin + tmp_zip.close + Zip::File.open(tmp_zip.path, create: true) do |zip| + glob_pattern = '{' + folders.join(',') + '}/**/*' + Dir.glob(glob_pattern).each do |file_or_dir| + next if File.directory?(file_or_dir) + puts "Adding #{file_or_dir}" + zip.get_output_stream(file_or_dir) { |out| out.write(File.binread(file_or_dir)) } + end + puts "Adding Game.rb" + zip.get_output_stream("Game.rb") { |out| out.write(File.binread("Game.rb")) } + end + zip_data = File.binread(tmp_zip.path) + + # Write plain .psa archive + psa_name = "#{BASE_NAME}.psa" + File.delete(psa_name) if File.exist?(psa_name) + File.binwrite(psa_name, zip_data) + puts "Plain archive written to #{psa_name} (#{File.size(psa_name)} bytes)" + + unless no_encrypt + # Encrypt the ZIP data with AES-256-CBC + begin + cipher = OpenSSL::Cipher::AES256.new(:CBC) + cipher.encrypt + cipher.key = ENCRYPTION_KEY + iv = cipher.random_iv + encrypted_data = cipher.update(zip_data) + cipher.final + rescue OpenSSL::Cipher::CipherError => e + STDERR.puts "Encryption failed: #{e.message}" + exit 1 + end + + # Write the encrypted archive with header + epsa_name = "#{BASE_NAME}.epsa" + File.delete(epsa_name) if File.exist?(epsa_name) + File.open(epsa_name, 'wb') do |f| + f.write(EPSA_MAGIC) # 4 bytes: magic + f.write([EPSA_VERSION].pack('V')) # 4 bytes: version (uint32 LE) + f.write(iv) # 16 bytes: IV + f.write(encrypted_data) # rest: ciphertext + end + puts "Encrypted archive written to #{epsa_name} (#{File.size(epsa_name)} bytes)" + end +ensure + tmp_zip.unlink end From 5c907c6809ea26eb3e60c1959ffa26d324e24cb6 Mon Sep 17 00:00:00 2001 From: Scorbutics Date: Fri, 20 Mar 2026 12:22:14 +1000 Subject: [PATCH 2/7] feat: hash the generated .psa file to ensure nothing is corrupted --- .gitignore | 1 + plugins/export_android.rb | 10 ++++++---- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/.gitignore b/.gitignore index 5883e83a..bdaca217 100644 --- a/.gitignore +++ b/.gitignore @@ -29,3 +29,4 @@ Data/Studio/psdk.dat PSDK_Technical_Demo_*.zip Release/ project.psa +project.epsa diff --git a/plugins/export_android.rb b/plugins/export_android.rb index 71bc2234..8986b8d7 100644 --- a/plugins/export_android.rb +++ b/plugins/export_android.rb @@ -7,7 +7,7 @@ ENCRYPTION_KEY = ['1f24dd020fb077983c537dd29af01b9188406ce835bca75567b54db9be9f83f9'].pack('H*') EPSA_MAGIC = 'PSAE' -EPSA_VERSION = 1 +EPSA_VERSION = 2 unless ENCRYPTION_KEY.bytesize == 32 STDERR.puts "ENCRYPTION_KEY must be exactly 32 bytes (got #{ENCRYPTION_KEY.bytesize})" @@ -29,10 +29,10 @@ Dir.glob(glob_pattern).each do |file_or_dir| next if File.directory?(file_or_dir) puts "Adding #{file_or_dir}" - zip.get_output_stream(file_or_dir) { |out| out.write(File.binread(file_or_dir)) } + zip.add(file_or_dir, file_or_dir) end puts "Adding Game.rb" - zip.get_output_stream("Game.rb") { |out| out.write(File.binread("Game.rb")) } + zip.add("Game.rb", "Game.rb") end zip_data = File.binread(tmp_zip.path) @@ -45,11 +45,13 @@ unless no_encrypt # Encrypt the ZIP data with AES-256-CBC begin + hash = OpenSSL::Digest::SHA256.digest(zip_data) + payload = hash + zip_data # 32-byte SHA-256 hash prepended to ZIP data cipher = OpenSSL::Cipher::AES256.new(:CBC) cipher.encrypt cipher.key = ENCRYPTION_KEY iv = cipher.random_iv - encrypted_data = cipher.update(zip_data) + cipher.final + encrypted_data = cipher.update(payload) + cipher.final rescue OpenSSL::Cipher::CipherError => e STDERR.puts "Encryption failed: #{e.message}" exit 1 From fe1220dab971561cf0eb5d45d13b382594309071 Mon Sep 17 00:00:00 2001 From: Scorbutics Date: Fri, 20 Mar 2026 14:25:14 +1000 Subject: [PATCH 3/7] feat: direct export to apk --- plugins/export_android.rb | 288 ++++++++++++++++++++++++++++++++------ 1 file changed, 249 insertions(+), 39 deletions(-) diff --git a/plugins/export_android.rb b/plugins/export_android.rb index 8986b8d7..f3cc14e2 100644 --- a/plugins/export_android.rb +++ b/plugins/export_android.rb @@ -1,9 +1,33 @@ -# Create the encrypted PSDK Source Archive for Android -# Needs the gem 'rubyzip' +# Export a PSDK project as an Android APK +# +# Usage: +# ruby export_android.rb MyGame --apk PSDK-base.apk [options] +# +# Required: +# Game/app name (first non-flag argument) +# --apk Path to the base PSDK APK template +# +# Optional: +# --icon Custom app icon (PNG, recommended 192x192) +# --package Custom package ID (default: auto-generated from name) +# --keystore Signing keystore path (default: auto-generated debug keystore) +# --ks-pass Keystore password (default: "android") +# --output Output APK path (default: .apk) +# --with-saves Include Saves folder in the archive +# --keep-tmp Keep temporary files for debugging +# +# Requirements: +# - apktool (https://apktool.org) +# - zipalign (Android SDK build-tools) +# - apksigner (Android SDK build-tools) +# - keytool (JDK) +# - gem: rubyzip require 'zip' require 'openssl' require 'tempfile' +require 'fileutils' +require 'shellwords' ENCRYPTION_KEY = ['1f24dd020fb077983c537dd29af01b9188406ce835bca75567b54db9be9f83f9'].pack('H*') EPSA_MAGIC = 'PSAE' @@ -14,60 +38,246 @@ exit 1 end -folders = ['graphics', 'Fonts', 'Data', 'audio', 'pokemonsdk', 'scripts'] -folders = folders.concat(["Saves"]) if ARGV.include?('--with_saves') +# --------------------------------------------------------------------------- +# Argument parsing +# --------------------------------------------------------------------------- + +def parse_flag(flag) + idx = ARGV.index(flag) + return nil unless idx + ARGV.delete_at(idx) + value = ARGV.delete_at(idx) + abort "#{flag} requires a value" unless value + value +end + +def parse_bool(flag) + idx = ARGV.index(flag) + return false unless idx + ARGV.delete_at(idx) + true +end + +base_apk_path = parse_flag('--apk') +icon_path = parse_flag('--icon') +package_id = parse_flag('--package') +keystore_path = parse_flag('--keystore') +keystore_pass = parse_flag('--ks-pass') || 'android' +output_path = parse_flag('--output') +with_saves = parse_bool('--with-saves') +keep_tmp = parse_bool('--keep-tmp') + +app_name = ARGV.find { |arg| !arg.start_with?('--') } + +abort "Usage: ruby export_android.rb --apk [options]" unless app_name +abort "Missing --apk : path to the base PSDK APK template" unless base_apk_path +abort "Base APK not found: #{base_apk_path}" unless File.exist?(base_apk_path) +abort "Icon not found: #{icon_path}" if icon_path && !File.exist?(icon_path) + +# --------------------------------------------------------------------------- +# Tool checks +# --------------------------------------------------------------------------- + +def check_tool(name) + system("which #{name} > /dev/null 2>&1") or abort "Required tool not found: #{name}\nPlease install it and ensure it's in your PATH." +end + +check_tool('apktool') +check_tool('zipalign') +check_tool('apksigner') +check_tool('keytool') + +# --------------------------------------------------------------------------- +# Derived values +# --------------------------------------------------------------------------- + +safe_name = app_name.downcase.gsub(/[^a-z0-9]/, '') +abort "App name must contain at least one letter or digit" if safe_name.empty? + +package_id ||= "com.psdk.#{safe_name}" +output_path ||= "#{app_name.gsub(/\s+/, '_')}.apk" + +tmp_dir = "tmp_export_android_#{$$}" -no_encrypt = ARGV.include?('--no-encrypt') -BASE_NAME = ARGV.find { |arg| !arg.start_with?('--') } || "project" +puts "=== PSDK Android Export ===" +puts " App name: #{app_name}" +puts " Package ID: #{package_id}" +puts " Base APK: #{base_apk_path}" +puts " Icon: #{icon_path || '(default)'}" +puts " Output: #{output_path}" +puts "" + +# --------------------------------------------------------------------------- +# Step 1: Build encrypted .epsa archive +# --------------------------------------------------------------------------- + +puts "[1/6] Building encrypted archive..." + +folders = ['graphics', 'Fonts', 'Data', 'audio', 'pokemonsdk', 'scripts'] +folders << 'Saves' if with_saves -# Build ZIP to a temp file (proper format with central directory, required by PhysFS) tmp_zip = Tempfile.new(['psdk_archive', '.zip'], binmode: true) +epsa_path = File.join(tmp_dir, "game.epsa") + begin + FileUtils.mkdir_p(tmp_dir) tmp_zip.close + Zip::File.open(tmp_zip.path, create: true) do |zip| glob_pattern = '{' + folders.join(',') + '}/**/*' Dir.glob(glob_pattern).each do |file_or_dir| next if File.directory?(file_or_dir) - puts "Adding #{file_or_dir}" zip.add(file_or_dir, file_or_dir) end - puts "Adding Game.rb" zip.add("Game.rb", "Game.rb") end + zip_data = File.binread(tmp_zip.path) + puts " Archive contents: #{zip_data.bytesize} bytes" - # Write plain .psa archive - psa_name = "#{BASE_NAME}.psa" - File.delete(psa_name) if File.exist?(psa_name) - File.binwrite(psa_name, zip_data) - puts "Plain archive written to #{psa_name} (#{File.size(psa_name)} bytes)" - - unless no_encrypt - # Encrypt the ZIP data with AES-256-CBC - begin - hash = OpenSSL::Digest::SHA256.digest(zip_data) - payload = hash + zip_data # 32-byte SHA-256 hash prepended to ZIP data - cipher = OpenSSL::Cipher::AES256.new(:CBC) - cipher.encrypt - cipher.key = ENCRYPTION_KEY - iv = cipher.random_iv - encrypted_data = cipher.update(payload) + cipher.final - rescue OpenSSL::Cipher::CipherError => e - STDERR.puts "Encryption failed: #{e.message}" - exit 1 - end + hash = OpenSSL::Digest::SHA256.digest(zip_data) + payload = hash + zip_data + cipher = OpenSSL::Cipher::AES256.new(:CBC) + cipher.encrypt + cipher.key = ENCRYPTION_KEY + iv = cipher.random_iv + encrypted_data = cipher.update(payload) + cipher.final - # Write the encrypted archive with header - epsa_name = "#{BASE_NAME}.epsa" - File.delete(epsa_name) if File.exist?(epsa_name) - File.open(epsa_name, 'wb') do |f| - f.write(EPSA_MAGIC) # 4 bytes: magic - f.write([EPSA_VERSION].pack('V')) # 4 bytes: version (uint32 LE) - f.write(iv) # 16 bytes: IV - f.write(encrypted_data) # rest: ciphertext - end - puts "Encrypted archive written to #{epsa_name} (#{File.size(epsa_name)} bytes)" + File.open(epsa_path, 'wb') do |f| + f.write(EPSA_MAGIC) + f.write([EPSA_VERSION].pack('V')) + f.write(iv) + f.write(encrypted_data) end + puts " Encrypted archive: #{File.size(epsa_path)} bytes" ensure tmp_zip.unlink end + +# --------------------------------------------------------------------------- +# Step 2: Decompile the base APK with apktool +# --------------------------------------------------------------------------- + +puts "[2/6] Decompiling base APK..." + +decompiled_dir = File.join(tmp_dir, "decompiled") +unless system("apktool d #{base_apk_path.shellescape} -o #{decompiled_dir.shellescape} -f -s 2>&1") + abort "apktool decompile failed" +end + +# --------------------------------------------------------------------------- +# Step 3: Modify app identity (manifest, resources, icon) +# --------------------------------------------------------------------------- + +puts "[3/6] Customizing app identity..." + +# 3a. AndroidManifest.xml — replace ALL occurrences of the old package name. +# This covers: package="...", provider authorities, and auto-generated +# permissions like DYNAMIC_RECEIVER_NOT_EXPORTED_PERMISSION. +manifest_path = File.join(decompiled_dir, "AndroidManifest.xml") +manifest = File.read(manifest_path) +old_package = manifest[/package="([^"]+)"/, 1] + +manifest.gsub!(old_package, package_id) +File.write(manifest_path, manifest) +puts " Package: #{old_package} -> #{package_id}" + +# 3b. res/values/strings.xml — change app_name +strings_path = File.join(decompiled_dir, "res", "values", "strings.xml") +if File.exist?(strings_path) + strings = File.read(strings_path) + strings.gsub!(/.*?<\/string>/, "#{app_name}") + File.write(strings_path, strings) + puts " App name: #{app_name}" +end + +# 3c. Replace icon if provided +if icon_path + # Replace in all drawable directories that contain logo.png + Dir.glob(File.join(decompiled_dir, "res", "drawable*")).each do |drawable_dir| + target = File.join(drawable_dir, "logo.png") + if File.exist?(target) + FileUtils.cp(icon_path, target) + puts " Icon replaced: #{drawable_dir}" + end + end + # Also check mipmap directories + Dir.glob(File.join(decompiled_dir, "res", "mipmap*")).each do |mipmap_dir| + target = File.join(mipmap_dir, "logo.png") + if File.exist?(target) + FileUtils.cp(icon_path, target) + puts " Icon replaced: #{mipmap_dir}" + end + end +end + +# --------------------------------------------------------------------------- +# Step 4: Inject .epsa into assets/ +# --------------------------------------------------------------------------- + +puts "[4/6] Injecting game archive into assets..." + +assets_dir = File.join(decompiled_dir, "assets") +FileUtils.mkdir_p(assets_dir) +FileUtils.cp(epsa_path, File.join(assets_dir, "game.epsa")) +puts " Injected game.epsa into assets/" + +# --------------------------------------------------------------------------- +# Step 5: Rebuild APK with apktool +# --------------------------------------------------------------------------- + +puts "[5/6] Rebuilding APK..." + +unsigned_apk = File.join(tmp_dir, "unsigned.apk") +unless system("apktool b #{decompiled_dir.shellescape} -o #{unsigned_apk.shellescape} 2>&1") + abort "apktool rebuild failed" +end + +# Zipalign +aligned_apk = File.join(tmp_dir, "aligned.apk") +unless system("zipalign -f 4 #{unsigned_apk.shellescape} #{aligned_apk.shellescape}") + abort "zipalign failed" +end + +# --------------------------------------------------------------------------- +# Step 6: Sign the APK +# --------------------------------------------------------------------------- + +puts "[6/6] Signing APK..." + +# Generate a debug keystore if none provided +unless keystore_path + keystore_path = File.join(tmp_dir, "debug.keystore") + unless system( + "keytool -genkey -v -keystore #{keystore_path.shellescape} " \ + "-alias psdk -keyalg RSA -keysize 2048 -validity 10000 " \ + "-storepass #{keystore_pass.shellescape} " \ + "-dname 'CN=PSDK, O=PSDK' 2>&1" + ) + abort "keytool keystore generation failed" + end + puts " Generated debug keystore" +end + +unless system( + "apksigner sign " \ + "--ks #{keystore_path.shellescape} " \ + "--ks-pass pass:#{keystore_pass.shellescape} " \ + "--out #{output_path.shellescape} " \ + "#{aligned_apk.shellescape}" +) + abort "apksigner signing failed" +end + +# --------------------------------------------------------------------------- +# Cleanup +# --------------------------------------------------------------------------- + +unless keep_tmp + FileUtils.rm_rf(tmp_dir) +end + +puts "" +puts "=== Done! ===" +puts " Output: #{output_path} (#{File.size(output_path)} bytes)" +puts " Install: adb install #{output_path}" From 75cc4d8f6a25bc468379bbbe2b0660a3625db466 Mon Sep 17 00:00:00 2001 From: Scorbutics Date: Thu, 16 Apr 2026 10:40:51 +1000 Subject: [PATCH 4/7] feat: add a way for android to upgrade the app without reinstalling --- plugins/export_android.rb | 34 ++++++++++++++++++++++++---------- 1 file changed, 24 insertions(+), 10 deletions(-) diff --git a/plugins/export_android.rb b/plugins/export_android.rb index f3cc14e2..6a6bf283 100644 --- a/plugins/export_android.rb +++ b/plugins/export_android.rb @@ -165,6 +165,14 @@ def check_tool(name) abort "apktool decompile failed" end +# Update versionCode so Android allows installing over a previous build +apktool_yml = File.join(decompiled_dir, "apktool.yml") +version_code = Time.now.to_i / 60 +apktool_content = File.read(apktool_yml) +apktool_content.sub!(/versionCode:\s*'?\d+'?/, "versionCode: #{version_code}") +File.write(apktool_yml, apktool_content) +puts " versionCode: #{version_code}" + # --------------------------------------------------------------------------- # Step 3: Modify app identity (manifest, resources, icon) # --------------------------------------------------------------------------- @@ -245,18 +253,24 @@ def check_tool(name) puts "[6/6] Signing APK..." -# Generate a debug keystore if none provided +# Use or generate a persistent debug keystore when none provided unless keystore_path - keystore_path = File.join(tmp_dir, "debug.keystore") - unless system( - "keytool -genkey -v -keystore #{keystore_path.shellescape} " \ - "-alias psdk -keyalg RSA -keysize 2048 -validity 10000 " \ - "-storepass #{keystore_pass.shellescape} " \ - "-dname 'CN=PSDK, O=PSDK' 2>&1" - ) - abort "keytool keystore generation failed" + default_keystore_dir = File.join(Dir.home, ".android") + keystore_path = File.join(default_keystore_dir, "debug.keystore") + unless File.exist?(keystore_path) + FileUtils.mkdir_p(default_keystore_dir) + unless system( + "keytool -genkey -v -keystore #{keystore_path.shellescape} " \ + "-alias androiddebugkey -keyalg RSA -keysize 2048 -validity 10000 " \ + "-storepass #{keystore_pass.shellescape} " \ + "-dname 'CN=PSDK, O=PSDK' 2>&1" + ) + abort "keytool keystore generation failed" + end + puts " Generated debug keystore: #{keystore_path}" + else + puts " Using existing debug keystore: #{keystore_path}" end - puts " Generated debug keystore" end unless system( From 263ffe0cd990aa676263bfd9200773731b722c63 Mon Sep 17 00:00:00 2001 From: Scorbutics Date: Sat, 18 Apr 2026 09:02:36 +1000 Subject: [PATCH 5/7] chore: rubocop code fixes --- plugins/export_android.rb | 103 ++++++++++++++++++-------------------- 1 file changed, 49 insertions(+), 54 deletions(-) diff --git a/plugins/export_android.rb b/plugins/export_android.rb index 6a6bf283..bef01ce5 100644 --- a/plugins/export_android.rb +++ b/plugins/export_android.rb @@ -34,7 +34,7 @@ EPSA_VERSION = 2 unless ENCRYPTION_KEY.bytesize == 32 - STDERR.puts "ENCRYPTION_KEY must be exactly 32 bytes (got #{ENCRYPTION_KEY.bytesize})" + warn "ENCRYPTION_KEY must be exactly 32 bytes (got #{ENCRYPTION_KEY.bytesize})" exit 1 end @@ -45,6 +45,7 @@ def parse_flag(flag) idx = ARGV.index(flag) return nil unless idx + ARGV.delete_at(idx) value = ARGV.delete_at(idx) abort "#{flag} requires a value" unless value @@ -54,6 +55,7 @@ def parse_flag(flag) def parse_bool(flag) idx = ARGV.index(flag) return false unless idx + ARGV.delete_at(idx) true end @@ -69,8 +71,8 @@ def parse_bool(flag) app_name = ARGV.find { |arg| !arg.start_with?('--') } -abort "Usage: ruby export_android.rb --apk [options]" unless app_name -abort "Missing --apk : path to the base PSDK APK template" unless base_apk_path +abort 'Usage: ruby export_android.rb --apk [options]' unless app_name +abort 'Missing --apk : path to the base PSDK APK template' unless base_apk_path abort "Base APK not found: #{base_apk_path}" unless File.exist?(base_apk_path) abort "Icon not found: #{icon_path}" if icon_path && !File.exist?(icon_path) @@ -92,44 +94,45 @@ def check_tool(name) # --------------------------------------------------------------------------- safe_name = app_name.downcase.gsub(/[^a-z0-9]/, '') -abort "App name must contain at least one letter or digit" if safe_name.empty? +abort 'App name must contain at least one letter or digit' if safe_name.empty? package_id ||= "com.psdk.#{safe_name}" output_path ||= "#{app_name.gsub(/\s+/, '_')}.apk" tmp_dir = "tmp_export_android_#{$$}" -puts "=== PSDK Android Export ===" +puts '=== PSDK Android Export ===' puts " App name: #{app_name}" puts " Package ID: #{package_id}" puts " Base APK: #{base_apk_path}" puts " Icon: #{icon_path || '(default)'}" puts " Output: #{output_path}" -puts "" +puts '' # --------------------------------------------------------------------------- # Step 1: Build encrypted .epsa archive # --------------------------------------------------------------------------- -puts "[1/6] Building encrypted archive..." +puts '[1/6] Building encrypted archive...' -folders = ['graphics', 'Fonts', 'Data', 'audio', 'pokemonsdk', 'scripts'] +folders = %w[graphics Fonts Data audio pokemonsdk scripts] folders << 'Saves' if with_saves tmp_zip = Tempfile.new(['psdk_archive', '.zip'], binmode: true) -epsa_path = File.join(tmp_dir, "game.epsa") +epsa_path = File.join(tmp_dir, 'game.epsa') begin FileUtils.mkdir_p(tmp_dir) tmp_zip.close Zip::File.open(tmp_zip.path, create: true) do |zip| - glob_pattern = '{' + folders.join(',') + '}/**/*' + glob_pattern = "{#{folders.join(',')}}/**/*" Dir.glob(glob_pattern).each do |file_or_dir| next if File.directory?(file_or_dir) + zip.add(file_or_dir, file_or_dir) end - zip.add("Game.rb", "Game.rb") + zip.add('Game.rb', 'Game.rb') end zip_data = File.binread(tmp_zip.path) @@ -137,7 +140,7 @@ def check_tool(name) hash = OpenSSL::Digest::SHA256.digest(zip_data) payload = hash + zip_data - cipher = OpenSSL::Cipher::AES256.new(:CBC) + cipher = OpenSSL::Cipher.new('aes-256-cbc') cipher.encrypt cipher.key = ENCRYPTION_KEY iv = cipher.random_iv @@ -158,15 +161,13 @@ def check_tool(name) # Step 2: Decompile the base APK with apktool # --------------------------------------------------------------------------- -puts "[2/6] Decompiling base APK..." +puts '[2/6] Decompiling base APK...' -decompiled_dir = File.join(tmp_dir, "decompiled") -unless system("apktool d #{base_apk_path.shellescape} -o #{decompiled_dir.shellescape} -f -s 2>&1") - abort "apktool decompile failed" -end +decompiled_dir = File.join(tmp_dir, 'decompiled') +abort 'apktool decompile failed' unless system("apktool d #{base_apk_path.shellescape} -o #{decompiled_dir.shellescape} -f -s 2>&1") # Update versionCode so Android allows installing over a previous build -apktool_yml = File.join(decompiled_dir, "apktool.yml") +apktool_yml = File.join(decompiled_dir, 'apktool.yml') version_code = Time.now.to_i / 60 apktool_content = File.read(apktool_yml) apktool_content.sub!(/versionCode:\s*'?\d+'?/, "versionCode: #{version_code}") @@ -177,12 +178,12 @@ def check_tool(name) # Step 3: Modify app identity (manifest, resources, icon) # --------------------------------------------------------------------------- -puts "[3/6] Customizing app identity..." +puts '[3/6] Customizing app identity...' # 3a. AndroidManifest.xml — replace ALL occurrences of the old package name. # This covers: package="...", provider authorities, and auto-generated # permissions like DYNAMIC_RECEIVER_NOT_EXPORTED_PERMISSION. -manifest_path = File.join(decompiled_dir, "AndroidManifest.xml") +manifest_path = File.join(decompiled_dir, 'AndroidManifest.xml') manifest = File.read(manifest_path) old_package = manifest[/package="([^"]+)"/, 1] @@ -191,10 +192,10 @@ def check_tool(name) puts " Package: #{old_package} -> #{package_id}" # 3b. res/values/strings.xml — change app_name -strings_path = File.join(decompiled_dir, "res", "values", "strings.xml") +strings_path = File.join(decompiled_dir, 'res', 'values', 'strings.xml') if File.exist?(strings_path) strings = File.read(strings_path) - strings.gsub!(/.*?<\/string>/, "#{app_name}") + strings.gsub!(%r{.*?}, "#{app_name}") File.write(strings_path, strings) puts " App name: #{app_name}" end @@ -202,16 +203,16 @@ def check_tool(name) # 3c. Replace icon if provided if icon_path # Replace in all drawable directories that contain logo.png - Dir.glob(File.join(decompiled_dir, "res", "drawable*")).each do |drawable_dir| - target = File.join(drawable_dir, "logo.png") + Dir.glob(File.join(decompiled_dir, 'res', 'drawable*')).each do |drawable_dir| + target = File.join(drawable_dir, 'logo.png') if File.exist?(target) FileUtils.cp(icon_path, target) puts " Icon replaced: #{drawable_dir}" end end # Also check mipmap directories - Dir.glob(File.join(decompiled_dir, "res", "mipmap*")).each do |mipmap_dir| - target = File.join(mipmap_dir, "logo.png") + Dir.glob(File.join(decompiled_dir, 'res', 'mipmap*')).each do |mipmap_dir| + target = File.join(mipmap_dir, 'logo.png') if File.exist?(target) FileUtils.cp(icon_path, target) puts " Icon replaced: #{mipmap_dir}" @@ -223,75 +224,69 @@ def check_tool(name) # Step 4: Inject .epsa into assets/ # --------------------------------------------------------------------------- -puts "[4/6] Injecting game archive into assets..." +puts '[4/6] Injecting game archive into assets...' -assets_dir = File.join(decompiled_dir, "assets") +assets_dir = File.join(decompiled_dir, 'assets') FileUtils.mkdir_p(assets_dir) -FileUtils.cp(epsa_path, File.join(assets_dir, "game.epsa")) -puts " Injected game.epsa into assets/" +FileUtils.cp(epsa_path, File.join(assets_dir, 'game.epsa')) +puts ' Injected game.epsa into assets/' # --------------------------------------------------------------------------- # Step 5: Rebuild APK with apktool # --------------------------------------------------------------------------- -puts "[5/6] Rebuilding APK..." +puts '[5/6] Rebuilding APK...' -unsigned_apk = File.join(tmp_dir, "unsigned.apk") -unless system("apktool b #{decompiled_dir.shellescape} -o #{unsigned_apk.shellescape} 2>&1") - abort "apktool rebuild failed" -end +unsigned_apk = File.join(tmp_dir, 'unsigned.apk') +abort 'apktool rebuild failed' unless system("apktool b #{decompiled_dir.shellescape} -o #{unsigned_apk.shellescape} 2>&1") # Zipalign -aligned_apk = File.join(tmp_dir, "aligned.apk") -unless system("zipalign -f 4 #{unsigned_apk.shellescape} #{aligned_apk.shellescape}") - abort "zipalign failed" -end +aligned_apk = File.join(tmp_dir, 'aligned.apk') +abort 'zipalign failed' unless system("zipalign -f 4 #{unsigned_apk.shellescape} #{aligned_apk.shellescape}") # --------------------------------------------------------------------------- # Step 6: Sign the APK # --------------------------------------------------------------------------- -puts "[6/6] Signing APK..." +puts '[6/6] Signing APK...' # Use or generate a persistent debug keystore when none provided unless keystore_path - default_keystore_dir = File.join(Dir.home, ".android") - keystore_path = File.join(default_keystore_dir, "debug.keystore") - unless File.exist?(keystore_path) + default_keystore_dir = File.join(Dir.home, '.android') + keystore_path = File.join(default_keystore_dir, 'debug.keystore') + if File.exist?(keystore_path) + puts " Using existing debug keystore: #{keystore_path}" + else FileUtils.mkdir_p(default_keystore_dir) unless system( "keytool -genkey -v -keystore #{keystore_path.shellescape} " \ - "-alias androiddebugkey -keyalg RSA -keysize 2048 -validity 10000 " \ + '-alias androiddebugkey -keyalg RSA -keysize 2048 -validity 10000 ' \ "-storepass #{keystore_pass.shellescape} " \ "-dname 'CN=PSDK, O=PSDK' 2>&1" ) - abort "keytool keystore generation failed" + abort 'keytool keystore generation failed' end puts " Generated debug keystore: #{keystore_path}" - else - puts " Using existing debug keystore: #{keystore_path}" end end unless system( - "apksigner sign " \ + 'apksigner sign ' \ "--ks #{keystore_path.shellescape} " \ "--ks-pass pass:#{keystore_pass.shellescape} " \ "--out #{output_path.shellescape} " \ "#{aligned_apk.shellescape}" ) - abort "apksigner signing failed" + abort 'apksigner signing failed' end # --------------------------------------------------------------------------- # Cleanup # --------------------------------------------------------------------------- -unless keep_tmp - FileUtils.rm_rf(tmp_dir) -end +FileUtils.rm_rf(tmp_dir) unless keep_tmp -puts "" -puts "=== Done! ===" +puts '' +puts '=== Done! ===' puts " Output: #{output_path} (#{File.size(output_path)} bytes)" puts " Install: adb install #{output_path}" From 6d3eb3e1566f6ad8b032bcfbbbb022e5f8eeaee0 Mon Sep 17 00:00:00 2001 From: Scorbutics Date: Thu, 7 May 2026 18:31:29 +1000 Subject: [PATCH 6/7] feat: enhanced epsa security --- plugins/apk_signing_block.rb | 176 ++++++++++++++++++++++++++++ plugins/epsa_kdf.rb | 69 +++++++++++ plugins/export_android.rb | 217 ++++++++++++++++++++++++++--------- plugins/pkcs12_cert.rb | 141 +++++++++++++++++++++++ 4 files changed, 547 insertions(+), 56 deletions(-) create mode 100644 plugins/apk_signing_block.rb create mode 100644 plugins/epsa_kdf.rb create mode 100644 plugins/pkcs12_cert.rb diff --git a/plugins/apk_signing_block.rb b/plugins/apk_signing_block.rb new file mode 100644 index 00000000..db1a293b --- /dev/null +++ b/plugins/apk_signing_block.rb @@ -0,0 +1,176 @@ +# Parser for the APK Signing Block (V2 / V3 / V3.1). +# +# Modern APKs (apksigner default since AGP 7.x) ship without V1 (JAR) signing. +# The cert lives in a binary block at the end of the file, between the last +# ZIP entry and the central directory. Android's PackageManager reads the cert +# from here too — so what we extract here matches exactly what +# context.packageManager.getPackageInfo(...).signingInfo.signingCertificateHistory[0] +# returns at runtime, byte-for-byte. +# +# Spec: https://source.android.com/docs/security/features/apksigning/v2 +# +# All multi-byte fields are little-endian. +# +# Layout: +# ZIP entries +# APK Signing Block: +# uint64 size (excludes this leading uint64 — i.e. counts everything below) +# pairs: +# uint64 pair_length (covers the next ID + value) +# uint32 ID (V2: 0x7109871a, V3: 0xf05368c0, V3.1: 0x1b93ad61) +# bytes value +# ...repeated... +# uint64 size (same value as the leading size) +# 16 bytes magic = "APK Sig Block 42" +# ZIP central directory +# ZIP end-of-central-directory record (EOCD) + +module ApkSigningBlock + module_function + + ParseError = Class.new(StandardError) + + EOCD_MAGIC = 0x06054b50 + APK_SIG_MAGIC = 'APK Sig Block 42'.b.freeze + SCHEME_V2_ID = 0x7109871a + SCHEME_V3_ID = 0xf05368c0 + SCHEME_V3_1_ID = 0x1b93ad61 + + # Returns the DER bytes of the first signer's first cert, matching what + # Android's signingCertificateHistory[0] returns at runtime. + # Raises ParseError if no V2/V3/V3.1 block is found. + def first_cert_der(apk_path) + File.open(apk_path, 'rb') do |io| + cd_offset = find_central_directory_offset(io) + raise ParseError, "EOCD record not found in #{apk_path}" unless cd_offset + + block = read_apk_signing_block(io, cd_offset) + raise ParseError, "APK Signing Block not found in #{apk_path}" unless block + + # Prefer V3.1 > V3 > V2 (matching Android's preference order). + [SCHEME_V3_1_ID, SCHEME_V3_ID, SCHEME_V2_ID].each do |id| + scheme = find_id_value(block, id) + next unless scheme + + cert = first_cert_from_scheme_block(scheme) + return cert if cert + end + + raise ParseError, "No V2 or V3 APK Signature Scheme block found in #{apk_path}" + end + end + + # Search the last ~64 KB of the file for the EOCD magic and return the + # central-directory offset stored within. ZIP allows up to a 65535-byte + # comment after EOCD, so we read enough to cover that. + def find_central_directory_offset(io) + size = io.size + search_size = [size, 22 + 65_535].min + io.seek(size - search_size) + tail = io.read(search_size) + + eocd_pos = nil + (search_size - 22).downto(0) do |i| + next unless tail.byteslice(i, 4)&.unpack1('V') == EOCD_MAGIC + + eocd_pos = i + break + end + return nil unless eocd_pos + + tail.byteslice(eocd_pos + 16, 4).unpack1('V') + end + + # Read the APK Signing Block by walking back from the central directory. + # Returns the inner pair-list bytes (without the leading/trailing size or + # the trailing magic), or nil if no signing block is present. + def read_apk_signing_block(io, cd_offset) + return nil if cd_offset < 32 # min: 8 + 0 + 8 + 16 + + io.seek(cd_offset - 16) + return nil unless io.read(16) == APK_SIG_MAGIC + + io.seek(cd_offset - 24) + size = io.read(8).unpack1('Q<') + return nil if size < 24 || cd_offset < size + 8 + + block_start = cd_offset - 8 - size + io.seek(block_start) + leading_size = io.read(8).unpack1('Q<') + return nil unless leading_size == size + + # Pair-list bytes = block content minus trailing size (8) and magic (16). + io.read(size - 24) + end + + # Walk the (uint64-length)(uint32-id)(bytes-value) pairs and return the value + # for the requested ID, or nil if not found. + def find_id_value(block, target_id) + pos = 0 + while pos + 12 <= block.bytesize + length = block.byteslice(pos, 8).unpack1('Q<') + pos += 8 + return nil if length < 4 || pos + length > block.bytesize + + id = block.byteslice(pos, 4).unpack1('V') + return block.byteslice(pos + 4, length - 4) if id == target_id + + pos += length + end + nil + end + + # Parse the inner of a V2/V3/V3.1 scheme block. + # + # Common outer shape (uint32 = u4): + # block: + # u4 signers_list_length + # [for each signer] + # u4 signer_length + # [signer body] + # + # Signer body (we only need signed_data, which is the first field): + # u4 signed_data_length + # [signed_data body] + # ...other fields we ignore... + # + # signed_data body (V2 and V3 share the first two fields): + # u4 digests_length [digests] + # u4 certificates_list_length + # u4 cert_length + # [cert DER bytes] + # ... + # ...other fields we ignore... + def first_cert_from_scheme_block(block) + p = 0 + + return nil if p + 4 > block.bytesize + signers_list_len = block.byteslice(p, 4).unpack1('V'); p += 4 + return nil if p + signers_list_len > block.bytesize + + return nil if p + 4 > block.bytesize + signer_len = block.byteslice(p, 4).unpack1('V'); p += 4 + return nil if p + signer_len > block.bytesize + signer_end = p + signer_len + + return nil if p + 4 > signer_end + sd_len = block.byteslice(p, 4).unpack1('V'); p += 4 + return nil if p + sd_len > signer_end + sd_end = p + sd_len + + sd_p = p + return nil if sd_p + 4 > sd_end + digests_len = block.byteslice(sd_p, 4).unpack1('V'); sd_p += 4 + digests_len + return nil if sd_p > sd_end + + return nil if sd_p + 4 > sd_end + certs_list_len = block.byteslice(sd_p, 4).unpack1('V'); sd_p += 4 + return nil if sd_p + certs_list_len > sd_end + + return nil if sd_p + 4 > sd_end + cert_len = block.byteslice(sd_p, 4).unpack1('V'); sd_p += 4 + return nil if sd_p + cert_len > sd_end + + block.byteslice(sd_p, cert_len) + end +end diff --git a/plugins/epsa_kdf.rb b/plugins/epsa_kdf.rb new file mode 100644 index 00000000..6349e484 --- /dev/null +++ b/plugins/epsa_kdf.rb @@ -0,0 +1,69 @@ +# Host-side counterpart to PSDK-android's libepsakdf.so. +# +# Produces byte-identical output to the native C implementation in +# PSDK-android/app/src/main/cpp/epsa_kdf.cpp. The parity test in +# plugins/test_epsa_kdf.rb is the gate that catches drift. + +require 'openssl' + +module EpsaKdf + module_function + + CURRENT_KDF_VERSION = 1 + + OBF_KEY = 0x7b + + # Original SALT bytes (32) XOR'd against OBF_KEY. Mirrors SALT_XOR in epsa_kdf.cpp. + SALT_XOR = [ + '64ab5fc7696bc6c2d8b9cc9099549759' \ + 'd15eef1f98d3d289ed0a37d198f4c0fe' + ].pack('H*').freeze + + # "psdk-epsa-bundle" (16 bytes) XOR'd against OBF_KEY. Mirrors INFO_XOR in epsa_kdf.cpp. + INFO_XOR = ['0b081f10561e0b081a56190e151f171e'].pack('H*').freeze + + BUILD_ID_SIZE = 8 + KEY_SIZE = 32 + + def deobfuscate(blob) + blob.bytes.map { |b| b ^ OBF_KEY }.pack('C*') + end + + def hkdf_extract(salt, ikm) + OpenSSL::HMAC.digest('SHA256', salt, ikm) + end + + def hkdf_expand(prk, info, length) + raise ArgumentError, 'length too large' if length > 255 * 32 + + out = String.new(encoding: Encoding::ASCII_8BIT) + t = String.new(encoding: Encoding::ASCII_8BIT) + counter = 1 + while out.bytesize < length + t = OpenSSL::HMAC.digest('SHA256', prk, t + info + counter.chr) + out << t + counter += 1 + end + out.byteslice(0, length) + end + + def hkdf_sha256(ikm:, salt:, info:, length:) + hkdf_expand(hkdf_extract(salt, ikm), info, length) + end + + # Derive the 32-byte K_master. + # + # @param cert_der [String] APK signing cert DER bytes (ASCII-8BIT) + # @param build_id [String] 8 bytes (ASCII-8BIT) + # @param kdf_version [Integer] 0..255 + def derive(cert_der, build_id, kdf_version = CURRENT_KDF_VERSION) + raise ArgumentError, 'build_id must be 8 bytes' unless build_id.bytesize == BUILD_ID_SIZE + raise ArgumentError, 'kdf_version out of byte range' unless (0..255).cover?(kdf_version) + + salt = deobfuscate(SALT_XOR) + info_prefix = deobfuscate(INFO_XOR) + info = info_prefix + kdf_version.chr + build_id + + hkdf_sha256(ikm: cert_der, salt: salt, info: info, length: KEY_SIZE) + end +end diff --git a/plugins/export_android.rb b/plugins/export_android.rb index bef01ce5..30710ecd 100644 --- a/plugins/export_android.rb +++ b/plugins/export_android.rb @@ -1,27 +1,64 @@ -# Export a PSDK project as an Android APK +# Export a PSDK project as an Android APK, or as a standalone .epsa archive. # -# Usage: -# ruby export_android.rb MyGame --apk PSDK-base.apk [options] +# Two modes: # -# Required: +# 1. Full APK export (default): +# ruby export_android.rb MyGame --apk PSDK-base.apk [options] +# Builds, signs, and outputs a complete .apk. Requires a PKCS12 keystore. +# The .epsa is bundled inside the APK and encrypted with a key derived from +# the keystore's signing certificate. +# +# 2. EPSA-only export (--epsa-only): +# ruby export_android.rb MyGame --epsa-only --apk player.apk --output mygame.epsa +# ruby export_android.rb MyGame --epsa-only --cert player.crt --output mygame.epsa +# Produces just the encrypted .epsa file. No apktool, no apksigner. +# The cert is the *public* X.509 cert of the eventual APK that will play this +# archive — no private key needed, no keystore needed. Either: +# --apk : extract the cert directly from the player APK's +# META-INF/ (the easy path — Pokemon Studio already +# has the player APK for distribution). +# --cert : hand the cert as a separate PEM/DER file. +# Intended for makers who don't have direct access to the signing keystore +# (for example, when the player APK is built by a CI server). +# +# Required (full APK mode): # Game/app name (first non-flag argument) # --apk Path to the base PSDK APK template # +# Required (epsa-only mode): +# Game/app name (first non-flag argument) +# --epsa-only Skip APK build/sign, output just the .epsa +# --apk | --cert Source of the target APK's signing cert (exactly one) +# --output Destination .epsa path +# # Optional: -# --icon Custom app icon (PNG, recommended 192x192) -# --package Custom package ID (default: auto-generated from name) -# --keystore Signing keystore path (default: auto-generated debug keystore) -# --ks-pass Keystore password (default: "android") -# --output Output APK path (default: .apk) +# --icon Custom app icon (PNG, recommended 192x192) — APK mode only +# --package Custom package ID (default: auto-generated) — APK mode only +# --keystore Explicit PKCS12 keystore path — APK mode only +# If unset, an auto-generated keystore is used (see --keystore-dir). +# --keystore-dir Directory holding (or to hold) the auto-generated keystore. +# Used as /debug.p12 when --keystore is not provided. +# Default: ~/.psdk/. Pokemon Studio sets this to a project-relative +# location so the keystore travels with the project. — APK mode only +# --ks-pass Keystore password (default: "android") — APK mode only +# --output Output path (default: .apk in APK mode, required in epsa-only mode) # --with-saves Include Saves folder in the archive -# --keep-tmp Keep temporary files for debugging +# --keep-tmp Keep temporary files for debugging — APK mode only # -# Requirements: +# Requirements (full APK mode): # - apktool (https://apktool.org) # - zipalign (Android SDK build-tools) # - apksigner (Android SDK build-tools) -# - keytool (JDK) # - gem: rubyzip +# +# Requirements (epsa-only mode): +# - gem: rubyzip +# +# Keystore note: APK mode reads the signing certificate via Ruby's OpenSSL +# (PKCS12 only) and binds the .epsa encryption key to it via HKDF. JKS or BKS +# keystores are not supported — convert them once to PKCS12 outside this +# pipeline. EPSA-only mode bypasses this entirely; it just reads a standalone +# public cert. require 'zip' require 'openssl' @@ -29,14 +66,11 @@ require 'fileutils' require 'shellwords' -ENCRYPTION_KEY = ['1f24dd020fb077983c537dd29af01b9188406ce835bca75567b54db9be9f83f9'].pack('H*') -EPSA_MAGIC = 'PSAE' -EPSA_VERSION = 2 +require_relative 'epsa_kdf' +require_relative 'pkcs12_cert' -unless ENCRYPTION_KEY.bytesize == 32 - warn "ENCRYPTION_KEY must be exactly 32 bytes (got #{ENCRYPTION_KEY.bytesize})" - exit 1 -end +EPSA_MAGIC = 'PSAE' +EPSA_VERSION = 3 # --------------------------------------------------------------------------- # Argument parsing @@ -64,17 +98,37 @@ def parse_bool(flag) icon_path = parse_flag('--icon') package_id = parse_flag('--package') keystore_path = parse_flag('--keystore') +keystore_dir = parse_flag('--keystore-dir') keystore_pass = parse_flag('--ks-pass') || 'android' output_path = parse_flag('--output') +cert_path = parse_flag('--cert') with_saves = parse_bool('--with-saves') keep_tmp = parse_bool('--keep-tmp') +epsa_only_mode = parse_bool('--epsa-only') app_name = ARGV.find { |arg| !arg.start_with?('--') } -abort 'Usage: ruby export_android.rb --apk [options]' unless app_name -abort 'Missing --apk : path to the base PSDK APK template' unless base_apk_path -abort "Base APK not found: #{base_apk_path}" unless File.exist?(base_apk_path) -abort "Icon not found: #{icon_path}" if icon_path && !File.exist?(icon_path) +abort 'Usage: + Full APK: ruby export_android.rb --apk [options] + EPSA only: ruby export_android.rb --epsa-only --apk --output + ruby export_android.rb --epsa-only --cert --output ' unless app_name + +if epsa_only_mode + abort '--epsa-only requires either --apk or --cert ' \ + unless cert_path || base_apk_path + abort '--epsa-only: pass exactly one of --apk or --cert (not both)' if cert_path && base_apk_path + abort '--epsa-only requires --output ' unless output_path + abort "Cert file not found: #{cert_path}" if cert_path && !File.exist?(cert_path) + abort "APK file not found: #{base_apk_path}" if base_apk_path && !File.exist?(base_apk_path) + abort '--epsa-only is incompatible with --keystore / --keystore-dir / --icon / --package' \ + if keystore_path || keystore_dir || icon_path || package_id +else + abort 'Missing --apk : path to the base PSDK APK template' unless base_apk_path + abort "Base APK not found: #{base_apk_path}" unless File.exist?(base_apk_path) + abort "Icon not found: #{icon_path}" if icon_path && !File.exist?(icon_path) + abort '--cert is only valid in --epsa-only mode' if cert_path + abort '--keystore-dir is ignored when --keystore is also provided' if keystore_path && keystore_dir +end # --------------------------------------------------------------------------- # Tool checks @@ -84,10 +138,11 @@ def check_tool(name) system("which #{name} > /dev/null 2>&1") or abort "Required tool not found: #{name}\nPlease install it and ensure it's in your PATH." end -check_tool('apktool') -check_tool('zipalign') -check_tool('apksigner') -check_tool('keytool') +unless epsa_only_mode + check_tool('apktool') + check_tool('zipalign') + check_tool('apksigner') +end # --------------------------------------------------------------------------- # Derived values @@ -96,39 +151,90 @@ def check_tool(name) safe_name = app_name.downcase.gsub(/[^a-z0-9]/, '') abort 'App name must contain at least one letter or digit' if safe_name.empty? -package_id ||= "com.psdk.#{safe_name}" -output_path ||= "#{app_name.gsub(/\s+/, '_')}.apk" +package_id ||= "com.psdk.#{safe_name}" unless epsa_only_mode +output_path ||= "#{app_name.gsub(/\s+/, '_')}.apk" unless epsa_only_mode tmp_dir = "tmp_export_android_#{$$}" puts '=== PSDK Android Export ===' +puts " Mode: #{epsa_only_mode ? 'EPSA-only' : 'Full APK'}" puts " App name: #{app_name}" -puts " Package ID: #{package_id}" -puts " Base APK: #{base_apk_path}" -puts " Icon: #{icon_path || '(default)'}" +puts " Package ID: #{package_id}" unless epsa_only_mode +puts " Base APK: #{base_apk_path}" unless epsa_only_mode +puts " Icon: #{icon_path || '(default)'}" unless epsa_only_mode +puts " Cert source: #{cert_path ? "cert file #{cert_path}" : "APK #{base_apk_path}"}" if epsa_only_mode puts " Output: #{output_path}" puts '' +# --------------------------------------------------------------------------- +# Step 0: Load the cert that will seed the KDF +# --------------------------------------------------------------------------- +# +# Two paths: +# - Full APK mode: the cert comes from the keystore that will sign the APK. +# We load (or generate) the keystore and extract the cert from it. +# - EPSA-only mode: the cert is provided directly as a standalone PEM/DER +# file, no keystore needed. Used when the maker doesn't have access to the +# signing keystore (e.g., CI-built player APK). + +signing_cert_der = + if epsa_only_mode + begin + cert_path ? Pkcs12Cert.public_cert_der(cert_path) : Pkcs12Cert.cert_der_from_apk(base_apk_path) + rescue Pkcs12Cert::KeystoreError => e + abort e.message + end + else + unless keystore_path + auto_keystore_dir = keystore_dir || File.join(Dir.home, '.psdk') + keystore_path = File.join(auto_keystore_dir, 'debug.p12') + if File.exist?(keystore_path) + puts " Using existing keystore: #{keystore_path}" + else + FileUtils.mkdir_p(auto_keystore_dir) + Pkcs12Cert.generate(p12_path: keystore_path, password: keystore_pass) + puts " Generated PKCS12 keystore: #{keystore_path}" + puts ' NOTE: back this file up. Losing it means future builds get a different' + puts ' signing cert, and users with the previous build will have to' + puts ' uninstall before installing the new one.' + end + end + begin + Pkcs12Cert.signing_cert_der(keystore_path, keystore_pass) + rescue Pkcs12Cert::KeystoreError => e + abort e.message + end + end +puts " Signing cert: #{signing_cert_der.bytesize} DER bytes loaded" +puts '' + # --------------------------------------------------------------------------- # Step 1: Build encrypted .epsa archive # --------------------------------------------------------------------------- -puts '[1/6] Building encrypted archive...' +puts(epsa_only_mode ? '[1/1] Building encrypted archive...' : '[1/6] Building encrypted archive...') folders = %w[graphics Fonts Data audio pokemonsdk scripts] folders << 'Saves' if with_saves tmp_zip = Tempfile.new(['psdk_archive', '.zip'], binmode: true) -epsa_path = File.join(tmp_dir, 'game.epsa') +# In epsa-only mode the .epsa is the final artifact; in full APK mode it's an +# intermediate that gets bundled into assets/ before rebuilding the APK. +epsa_path = epsa_only_mode ? output_path : File.join(tmp_dir, 'game.epsa') begin - FileUtils.mkdir_p(tmp_dir) + FileUtils.mkdir_p(tmp_dir) unless epsa_only_mode + FileUtils.mkdir_p(File.dirname(epsa_path)) if epsa_only_mode && !File.dirname(epsa_path).empty? tmp_zip.close Zip::File.open(tmp_zip.path, create: true) do |zip| glob_pattern = "{#{folders.join(',')}}/**/*" Dir.glob(glob_pattern).each do |file_or_dir| next if File.directory?(file_or_dir) + # Saves/input.json holds desktop-side scancode bindings that don't match + # Android SFML scancodes — bundling it would override the in-memory + # defaults and silently misroute touch input. Let each install regenerate. + next if file_or_dir == 'Saves/input.json' zip.add(file_or_dir, file_or_dir) end @@ -140,23 +246,40 @@ def check_tool(name) hash = OpenSSL::Digest::SHA256.digest(zip_data) payload = hash + zip_data + + build_id = OpenSSL::Random.random_bytes(8) + kdf_version = EpsaKdf::CURRENT_KDF_VERSION + key = EpsaKdf.derive(signing_cert_der, build_id, kdf_version) + cipher = OpenSSL::Cipher.new('aes-256-cbc') cipher.encrypt - cipher.key = ENCRYPTION_KEY + cipher.key = key iv = cipher.random_iv encrypted_data = cipher.update(payload) + cipher.final + # v3 header: magic (4) | version (4) | kdf_version (1) | reserved (3) | build_id (8) | IV (16) File.open(epsa_path, 'wb') do |f| f.write(EPSA_MAGIC) f.write([EPSA_VERSION].pack('V')) + f.write([kdf_version].pack('C')) + f.write("\x00\x00\x00".b) + f.write(build_id) f.write(iv) f.write(encrypted_data) end - puts " Encrypted archive: #{File.size(epsa_path)} bytes" + puts " Encrypted archive: #{File.size(epsa_path)} bytes (v#{EPSA_VERSION}, kdf=#{kdf_version})" ensure tmp_zip.unlink end +# In epsa-only mode we're done — the archive is the final artifact. +if epsa_only_mode + puts '' + puts '=== Done! ===' + puts " Output: #{epsa_path} (#{File.size(epsa_path)} bytes)" + exit 0 +end + # --------------------------------------------------------------------------- # Step 2: Decompile the base APK with apktool # --------------------------------------------------------------------------- @@ -250,25 +373,7 @@ def check_tool(name) puts '[6/6] Signing APK...' -# Use or generate a persistent debug keystore when none provided -unless keystore_path - default_keystore_dir = File.join(Dir.home, '.android') - keystore_path = File.join(default_keystore_dir, 'debug.keystore') - if File.exist?(keystore_path) - puts " Using existing debug keystore: #{keystore_path}" - else - FileUtils.mkdir_p(default_keystore_dir) - unless system( - "keytool -genkey -v -keystore #{keystore_path.shellescape} " \ - '-alias androiddebugkey -keyalg RSA -keysize 2048 -validity 10000 ' \ - "-storepass #{keystore_pass.shellescape} " \ - "-dname 'CN=PSDK, O=PSDK' 2>&1" - ) - abort 'keytool keystore generation failed' - end - puts " Generated debug keystore: #{keystore_path}" - end -end +# Keystore was already resolved/generated in step 0 — apksigner just uses it. unless system( 'apksigner sign ' \ diff --git a/plugins/pkcs12_cert.rb b/plugins/pkcs12_cert.rb new file mode 100644 index 00000000..d2e5d196 --- /dev/null +++ b/plugins/pkcs12_cert.rb @@ -0,0 +1,141 @@ +# Extract the signing certificate (DER bytes) from a PKCS12 keystore. +# +# We deliberately depend only on Ruby's built-in OpenSSL — no shelling out to +# `keytool`. That means the host keystore must be in PKCS12 format. JKS users +# convert their keystore once outside this pipeline. + +require 'openssl' + +module Pkcs12Cert + module_function + + KeystoreError = Class.new(StandardError) + + # @param p12_path [String] path to the .p12 / .pfx keystore + # @param password [String] keystore password + # @return [String] DER-encoded X.509 certificate bytes + def signing_cert_der(p12_path, password) + raise KeystoreError, "Keystore not found: #{p12_path}" unless File.exist?(p12_path) + + begin + p12 = OpenSSL::PKCS12.new(File.binread(p12_path), password) + rescue OpenSSL::PKCS12::PKCS12Error => e + raise KeystoreError, + "Failed to read PKCS12 keystore #{p12_path}: #{e.message}.\n" \ + 'The keystore must be PKCS12 format (.p12 / .pfx). ' \ + 'JKS keystores are not supported by this pipeline.' + end + + raise KeystoreError, "PKCS12 keystore #{p12_path} contains no certificate" unless p12.certificate + + p12.certificate.to_der + end + + # Extract the signing certificate from an Android APK and return its DER bytes. + # + # Tries the APK Signature Scheme V3.1 / V3 / V2 block first (the canonical + # source — this is exactly what context.packageManager.getPackageInfo(...). + # signingInfo.signingCertificateHistory[0].toByteArray() returns at runtime). + # Falls back to V1 META-INF/*.RSA for old APKs that only have V1 signing. + # + # @param apk_path [String] path to the .apk file + # @return [String] DER-encoded X.509 certificate bytes + def cert_der_from_apk(apk_path) + raise KeystoreError, "APK file not found: #{apk_path}" unless File.exist?(apk_path) + + require_relative 'apk_signing_block' + + # V2/V3 path — the modern, canonical source. Apksigner since AGP 7.x produces + # V2+V3 only by default; META-INF/*.RSA is absent. + begin + cert = ApkSigningBlock.first_cert_der(apk_path) + return cert if cert + rescue ApkSigningBlock::ParseError + # No V2/V3 block — fall through to V1. + end + + # V1 fallback — META-INF/.RSA holds a PKCS#7 SignedData with the cert. + require 'zip' + Zip::File.open(apk_path) do |zip| + rsa_entry = zip.glob('META-INF/*.RSA').first \ + || zip.glob('META-INF/*.EC').first \ + || zip.glob('META-INF/*.DSA').first + if rsa_entry.nil? + raise KeystoreError, + "No V2/V3 signing block AND no V1 META-INF/*.RSA found in #{apk_path}.\n" \ + 'The APK must be signed by apksigner (or jarsigner) before its cert can ' \ + 'be extracted. Build a signed APK first.' + end + pkcs7_data = rsa_entry.get_input_stream.read + + begin + pkcs7 = OpenSSL::PKCS7.new(pkcs7_data) + rescue OpenSSL::PKCS7::PKCS7Error => e + raise KeystoreError, "Failed to parse PKCS#7 in #{apk_path}!#{rsa_entry.name}: #{e.message}" + end + + certs = pkcs7.certificates + raise KeystoreError, "PKCS#7 in #{apk_path}!#{rsa_entry.name} contains no certificates" if certs.nil? || certs.empty? + + certs.first.to_der + end + end + + # Read a standalone X.509 certificate (PEM or DER) and return its DER bytes. + # Used by the maker-side "epsa-only" flow where the maker doesn't have access + # to the CI signing keystore — only the public cert that the eventual APK + # will be signed with. + # + # @param cert_path [String] path to the .pem / .crt / .der / .cer file + # @return [String] DER-encoded X.509 certificate bytes + def public_cert_der(cert_path) + raise KeystoreError, "Cert file not found: #{cert_path}" unless File.exist?(cert_path) + + raw = File.binread(cert_path) + cert = begin + # PEM: base64 wrapped in BEGIN/END markers. Try this first — DER bytes + # that happen to start with 0x2d would be ambiguous, but in practice + # X.509 DER always starts with 0x30 (SEQUENCE) so disambiguation is clean. + OpenSSL::X509::Certificate.new(raw) + rescue OpenSSL::X509::CertificateError => e + raise KeystoreError, + "Failed to parse certificate at #{cert_path}: #{e.message}.\n" \ + 'The file must be a valid X.509 certificate in PEM or DER format.' + end + + cert.to_der + end + + # Generate a new PKCS12 keystore with a self-signed RSA-2048 cert. + # Used when the user doesn't supply --keystore (replaces the previous + # `keytool -genkey` shell-out so we have no PATH dependency on keytool). + # + # @param p12_path [String] destination path + # @param password [String] keystore password + # @param dn [String] distinguished name (e.g. "CN=PSDK, O=PSDK") + def generate(p12_path:, password:, dn: 'CN=PSDK, O=PSDK', validity_years: 30) + key = OpenSSL::PKey::RSA.new(2048) + name = OpenSSL::X509::Name.parse(dn) + + cert = OpenSSL::X509::Certificate.new + cert.version = 2 + cert.serial = OpenSSL::BN.rand(159) # 159 bits — sub-160 to stay positive + cert.subject = name + cert.issuer = name + cert.public_key = key.public_key + cert.not_before = Time.now - 60 + cert.not_after = cert.not_before + (validity_years * 365 * 24 * 60 * 60) + + ef = OpenSSL::X509::ExtensionFactory.new + ef.subject_certificate = cert + ef.issuer_certificate = cert + cert.add_extension(ef.create_extension('basicConstraints', 'CA:TRUE', true)) + cert.add_extension(ef.create_extension('subjectKeyIdentifier', 'hash', false)) + + cert.sign(key, OpenSSL::Digest.new('SHA256')) + + p12 = OpenSSL::PKCS12.create(password, 'androiddebugkey', key, cert) + File.binwrite(p12_path, p12.to_der) + p12_path + end +end From 2faa3c5f5b3ab07ab8585a59be6666d3ae83af22 Mon Sep 17 00:00:00 2001 From: Scorbutics Date: Fri, 8 May 2026 09:55:00 +1000 Subject: [PATCH 7/7] feat: enhanced epsa security --- plugins/epsa_format.rb | 104 ++++++++++++++++++++++++++++++++++++++ plugins/epsa_kdf.rb | 28 ++++++---- plugins/epsa_writer.rb | 64 +++++++++++++++++++++++ plugins/export_android.rb | 36 ++++--------- 4 files changed, 195 insertions(+), 37 deletions(-) create mode 100644 plugins/epsa_format.rb create mode 100644 plugins/epsa_writer.rb diff --git a/plugins/epsa_format.rb b/plugins/epsa_format.rb new file mode 100644 index 00000000..6b4db45b --- /dev/null +++ b/plugins/epsa_format.rb @@ -0,0 +1,104 @@ +# EPSA archive format constants — single source of truth. +# +# v4 streaming-decryption layout: +# +# +-----------------------------------------------+ byte 0 +# | Header (HEADER_SIZE = 48 bytes) | +# +-----------------------------------------------+ byte HEADER_SIZE +# | HMAC table (32 bytes per chunk) | +# +-----------------------------------------------+ byte HEADER_SIZE + hmac_table_len +# | AES-256-CTR ciphertext (plaintext_len bytes) | +# +-----------------------------------------------+ EOF +# +# Header byte layout: +# +# off size field +# 0 4 magic "PSAE" +# 4 4 version u32 LE (= 4) +# 8 1 kdf_version (currently 1) +# 9 3 reserved (must be zero) +# 12 8 build_id (random per archive) +# 20 16 nonce (CTR base counter, random per archive) +# 36 4 chunk_log2 u32 LE (chunk_size = 1 << chunk_log2; default 16 → 64 KiB) +# 40 8 plaintext_len u64 LE +# +# Crypto: +# +# K_enc, K_mac = HKDF-SHA256(ikm = signing_cert_DER, +# salt = SALT, +# info = INFO_PFX || kdf_version || build_id || tag, +# length = 32) +# where tag = "psdk-epsa-enc-v4" for K_enc, "psdk-epsa-mac-v4" for K_mac. +# +# For chunk k (0 <= k < n_chunks), with chunk_size C = 1 << chunk_log2: +# ptxt_k = plaintext[k*C : min((k+1)*C, plaintext_len)] +# iv_k = (be_u128(nonce) + k * (C / 16)) mod 2^128, packed big-endian +# ctxt_k = AES-256-CTR(K_enc, iv_k).encrypt(ptxt_k) +# hmac_k = HMAC-SHA256(K_mac, u64_le(k) || ctxt_k) +# +# The HMAC table at offset HEADER_SIZE is hmac_0 || hmac_1 || ... || hmac_{n-1}. +# The ciphertext follows: ctxt_0 || ctxt_1 || ... || ctxt_{n-1}. +# +# Encrypt-then-MAC: HMAC covers the chunk INDEX and the CIPHERTEXT (not the +# plaintext). The chunk index in the HMAC input prevents reorder attacks. +# +# Why these choices: +# - AES-CTR (vs CBC): trivially seekable per-block — required because PhysFS +# reads the archive at random offsets when parsing the embedded ZIP central +# directory. CTR has no padding, so plaintext_len = ciphertext length. +# - Per-chunk HMAC (vs one tag at end): a single end-of-stream MAC would force +# reading the whole archive on first byte to verify integrity, defeating +# streaming. Per-chunk lets us verify-on-first-touch and skip already- +# verified chunks. +# - chunk_log2 = 16 (64 KiB): tradeoff between HMAC-table overhead (32 B per +# chunk → 0.05% overhead at this size) and wasted decrypt for small reads. +# ZIP central-directory reads are scattered; smaller chunks reduce per-read +# amortization. 64 KiB matches typical ZIP read patterns and keeps the +# table small (a 1 GB archive's table is 512 KiB). +# +# Compatibility: v3 archives are not readable by v4 code. The transition is a +# hard cutover — every consumer ships fresh with the new producer. + +module EpsaFormat + MAGIC = 'PSAE' + VERSION = 4 + HEADER_SIZE = 48 + KDF_VERSION_OFFSET = 8 + RESERVED_OFFSET = 9 + BUILD_ID_OFFSET = 12 + NONCE_OFFSET = 20 + CHUNK_LOG2_OFFSET = 36 + PLAINTEXT_LEN_OFFSET = 40 + + BUILD_ID_SIZE = 8 + NONCE_SIZE = 16 + HMAC_SIZE = 32 + + DEFAULT_CHUNK_LOG2 = 16 # 64 KiB + DEFAULT_CHUNK_SIZE = 1 << DEFAULT_CHUNK_LOG2 + + KDF_INFO_TAG_ENC = 'psdk-epsa-enc-v4' + KDF_INFO_TAG_MAC = 'psdk-epsa-mac-v4' + + AES_BLOCK_SIZE = 16 + + # CTR counter arithmetic. The 16-byte nonce in the header is the initial + # counter block; OpenSSL's aes-256-ctr increments it per AES block as a + # big-endian uint128. To start decrypt/encrypt at chunk k, the counter + # must equal `nonce + k * (chunk_size / AES_BLOCK_SIZE)` mod 2^128. + def self.advance_be128(nonce_bytes, increment) + raise ArgumentError, "expected #{NONCE_SIZE} bytes" unless nonce_bytes.bytesize == NONCE_SIZE + v = (nonce_bytes.unpack1('H*').to_i(16) + increment) & ((1 << 128) - 1) + [v.to_s(16).rjust(NONCE_SIZE * 2, '0')].pack('H*') + end + + # Counter for AES block index `block_index` (0-based, global). + def self.iv_for_block(nonce_bytes, block_index) + advance_be128(nonce_bytes, block_index) + end + + # Counter for chunk k (the first AES block of that chunk). + def self.iv_for_chunk(nonce_bytes, chunk_index, chunk_size) + advance_be128(nonce_bytes, chunk_index * (chunk_size / AES_BLOCK_SIZE)) + end +end diff --git a/plugins/epsa_kdf.rb b/plugins/epsa_kdf.rb index 6349e484..9006a09b 100644 --- a/plugins/epsa_kdf.rb +++ b/plugins/epsa_kdf.rb @@ -1,11 +1,12 @@ # Host-side counterpart to PSDK-android's libepsakdf.so. # # Produces byte-identical output to the native C implementation in -# PSDK-android/app/src/main/cpp/epsa_kdf.cpp. The parity test in -# plugins/test_epsa_kdf.rb is the gate that catches drift. +# PSDK-android/app/src/main/cpp/epsa_kdf.cpp. require 'openssl' +require_relative 'epsa_format' + module EpsaKdf module_function @@ -22,7 +23,7 @@ module EpsaKdf # "psdk-epsa-bundle" (16 bytes) XOR'd against OBF_KEY. Mirrors INFO_XOR in epsa_kdf.cpp. INFO_XOR = ['0b081f10561e0b081a56190e151f171e'].pack('H*').freeze - BUILD_ID_SIZE = 8 + BUILD_ID_SIZE = EpsaFormat::BUILD_ID_SIZE KEY_SIZE = 32 def deobfuscate(blob) @@ -51,19 +52,24 @@ def hkdf_sha256(ikm:, salt:, info:, length:) hkdf_expand(hkdf_extract(salt, ikm), info, length) end - # Derive the 32-byte K_master. + # Derive the 32-byte v4 K_enc and K_mac. Returns { enc_key:, mac_key: }. # - # @param cert_der [String] APK signing cert DER bytes (ASCII-8BIT) - # @param build_id [String] 8 bytes (ASCII-8BIT) - # @param kdf_version [Integer] 0..255 - def derive(cert_der, build_id, kdf_version = CURRENT_KDF_VERSION) + # Two separate HKDF derivations off the same (cert_der, build_id, kdf_version), + # distinguished by an `info`-suffix tag — see EpsaFormat for the layout. + def derive_v4(cert_der, build_id, kdf_version = CURRENT_KDF_VERSION) raise ArgumentError, 'build_id must be 8 bytes' unless build_id.bytesize == BUILD_ID_SIZE raise ArgumentError, 'kdf_version out of byte range' unless (0..255).cover?(kdf_version) salt = deobfuscate(SALT_XOR) - info_prefix = deobfuscate(INFO_XOR) - info = info_prefix + kdf_version.chr + build_id + info_prefix = deobfuscate(INFO_XOR) + kdf_version.chr + build_id + + enc_key = hkdf_sha256(ikm: cert_der, salt: salt, + info: info_prefix + EpsaFormat::KDF_INFO_TAG_ENC, + length: KEY_SIZE) + mac_key = hkdf_sha256(ikm: cert_der, salt: salt, + info: info_prefix + EpsaFormat::KDF_INFO_TAG_MAC, + length: KEY_SIZE) - hkdf_sha256(ikm: cert_der, salt: salt, info: info, length: KEY_SIZE) + { enc_key: enc_key, mac_key: mac_key } end end diff --git a/plugins/epsa_writer.rb b/plugins/epsa_writer.rb new file mode 100644 index 00000000..9bc35a0b --- /dev/null +++ b/plugins/epsa_writer.rb @@ -0,0 +1,64 @@ +# Streaming-format (.epsa v4) encrypter. Producer-side counterpart of +# PSDK-android's EpsaStream. + +require 'openssl' + +require_relative 'epsa_format' +require_relative 'epsa_kdf' + +module EpsaWriter + module_function + + # Encrypt `plaintext` (binary string) and write a v4 .epsa to `epsa_path`. + # Returns the chunk count for logging. + # + # @param epsa_path [String] destination + # @param plaintext [String] ASCII-8BIT bytes of the ZIP payload + # @param signing_cert_der [String] APK signing cert DER bytes (KDF ikm) + # @param kdf_version [Integer] 0..255 + def write(epsa_path:, plaintext:, signing_cert_der:, + kdf_version: EpsaKdf::CURRENT_KDF_VERSION, + chunk_log2: EpsaFormat::DEFAULT_CHUNK_LOG2) + build_id = OpenSSL::Random.random_bytes(EpsaFormat::BUILD_ID_SIZE) + nonce = OpenSSL::Random.random_bytes(EpsaFormat::NONCE_SIZE) + keys = EpsaKdf.derive_v4(signing_cert_der, build_id, kdf_version) + + chunk_size = 1 << chunk_log2 + plaintext_len = plaintext.bytesize + n_chunks = (plaintext_len + chunk_size - 1) / chunk_size + + hmac_table = String.new(encoding: Encoding::ASCII_8BIT) + ciphertext_blob = String.new(encoding: Encoding::ASCII_8BIT) + + n_chunks.times do |k| + ptxt = plaintext.byteslice(k * chunk_size, chunk_size) || ''.b + iv = EpsaFormat.iv_for_chunk(nonce, k, chunk_size) + + cipher = OpenSSL::Cipher.new('aes-256-ctr') + cipher.encrypt + cipher.key = keys[:enc_key] + cipher.iv = iv + ctxt = cipher.update(ptxt) + cipher.final + + hmac = OpenSSL::HMAC.digest('SHA256', keys[:mac_key], [k].pack('Q<') + ctxt) + + hmac_table << hmac + ciphertext_blob << ctxt + end + + File.open(epsa_path, 'wb') do |f| + f.write(EpsaFormat::MAGIC) + f.write([EpsaFormat::VERSION].pack('V')) + f.write([kdf_version].pack('C')) + f.write("\x00\x00\x00".b) + f.write(build_id) + f.write(nonce) + f.write([chunk_log2].pack('V')) + f.write([plaintext_len].pack('Q<')) + f.write(hmac_table) + f.write(ciphertext_blob) + end + + n_chunks + end +end diff --git a/plugins/export_android.rb b/plugins/export_android.rb index 30710ecd..4a2906c1 100644 --- a/plugins/export_android.rb +++ b/plugins/export_android.rb @@ -66,12 +66,11 @@ require 'fileutils' require 'shellwords' +require_relative 'epsa_format' require_relative 'epsa_kdf' +require_relative 'epsa_writer' require_relative 'pkcs12_cert' -EPSA_MAGIC = 'PSAE' -EPSA_VERSION = 3 - # --------------------------------------------------------------------------- # Argument parsing # --------------------------------------------------------------------------- @@ -244,30 +243,15 @@ def check_tool(name) zip_data = File.binread(tmp_zip.path) puts " Archive contents: #{zip_data.bytesize} bytes" - hash = OpenSSL::Digest::SHA256.digest(zip_data) - payload = hash + zip_data - - build_id = OpenSSL::Random.random_bytes(8) kdf_version = EpsaKdf::CURRENT_KDF_VERSION - key = EpsaKdf.derive(signing_cert_der, build_id, kdf_version) - - cipher = OpenSSL::Cipher.new('aes-256-cbc') - cipher.encrypt - cipher.key = key - iv = cipher.random_iv - encrypted_data = cipher.update(payload) + cipher.final - - # v3 header: magic (4) | version (4) | kdf_version (1) | reserved (3) | build_id (8) | IV (16) - File.open(epsa_path, 'wb') do |f| - f.write(EPSA_MAGIC) - f.write([EPSA_VERSION].pack('V')) - f.write([kdf_version].pack('C')) - f.write("\x00\x00\x00".b) - f.write(build_id) - f.write(iv) - f.write(encrypted_data) - end - puts " Encrypted archive: #{File.size(epsa_path)} bytes (v#{EPSA_VERSION}, kdf=#{kdf_version})" + n_chunks = EpsaWriter.write( + epsa_path: epsa_path, + plaintext: zip_data, + signing_cert_der: signing_cert_der, + kdf_version: kdf_version + ) + puts " Encrypted archive: #{File.size(epsa_path)} bytes " \ + "(v#{EpsaFormat::VERSION}, kdf=#{kdf_version}, chunks=#{n_chunks})" ensure tmp_zip.unlink end