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/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_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 new file mode 100644 index 00000000..9006a09b --- /dev/null +++ b/plugins/epsa_kdf.rb @@ -0,0 +1,75 @@ +# 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. + +require 'openssl' + +require_relative 'epsa_format' + +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 = EpsaFormat::BUILD_ID_SIZE + 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 v4 K_enc and K_mac. Returns { enc_key:, mac_key: }. + # + # 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) + 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) + + { 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 1e1e3beb..4a2906c1 100644 --- a/plugins/export_android.rb +++ b/plugins/export_android.rb @@ -1,29 +1,381 @@ -# Create the PSDK Source Archive -# Needs the gem 'rubyzip' +# Export a PSDK project as an Android APK, or as a standalone .epsa archive. +# +# Two modes: +# +# 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) — 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 — APK mode only +# +# Requirements (full APK mode): +# - apktool (https://apktool.org) +# - zipalign (Android SDK build-tools) +# - apksigner (Android SDK build-tools) +# - 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' +require 'tempfile' +require 'fileutils' +require 'shellwords' -def zip_all_recursive(zipfile, path) - path = "#{path}/**" +require_relative 'epsa_format' +require_relative 'epsa_kdf' +require_relative 'epsa_writer' +require_relative 'pkcs12_cert' - 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) +# --------------------------------------------------------------------------- +# 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_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: + 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 +# --------------------------------------------------------------------------- + +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 + +unless epsa_only_mode + check_tool('apktool') + check_tool('zipalign') + check_tool('apksigner') +end + +# --------------------------------------------------------------------------- +# 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}" 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}" 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(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) +# 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) 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 + zip.add('Game.rb', 'Game.rb') end + zip_data = File.binread(tmp_zip.path) + puts " Archive contents: #{zip_data.bytesize} bytes" + + kdf_version = EpsaKdf::CURRENT_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 + +# 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 +# --------------------------------------------------------------------------- + +puts '[2/6] Decompiling base APK...' + +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') +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) +# --------------------------------------------------------------------------- + +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!(%r{.*?}, "#{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 -folders = ['graphics', 'Fonts', 'Data', 'audio', 'pokemonsdk', 'scripts'] -folders = folders.concat(["Saves"]) if ARGV.include?('--with_saves') +# --------------------------------------------------------------------------- +# 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 +# --------------------------------------------------------------------------- -ARCHIVE_NAME = (ARGV.find { |arg| !arg.start_with?('--') } || "project") + ".psa" -File.delete(ARCHIVE_NAME) if File.exist? ARCHIVE_NAME +puts '[5/6] Rebuilding APK...' -Zip::File.open(ARCHIVE_NAME, create: true) do |zipfile| - zip_all_recursive(zipfile, '{' + folders.join(',') + '}') - zipfile.add("Game.rb", "Game.rb") +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') +abort 'zipalign failed' unless system("zipalign -f 4 #{unsigned_apk.shellescape} #{aligned_apk.shellescape}") + +# --------------------------------------------------------------------------- +# Step 6: Sign the APK +# --------------------------------------------------------------------------- + +puts '[6/6] Signing APK...' + +# Keystore was already resolved/generated in step 0 — apksigner just uses it. + +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 +# --------------------------------------------------------------------------- + +FileUtils.rm_rf(tmp_dir) unless keep_tmp + +puts '' +puts '=== Done! ===' +puts " Output: #{output_path} (#{File.size(output_path)} bytes)" +puts " Install: adb install #{output_path}" 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