Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -29,3 +29,4 @@ Data/Studio/psdk.dat
PSDK_Technical_Demo_*.zip
Release/
project.psa
project.epsa
176 changes: 176 additions & 0 deletions plugins/apk_signing_block.rb
Original file line number Diff line number Diff line change
@@ -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
104 changes: 104 additions & 0 deletions plugins/epsa_format.rb
Original file line number Diff line number Diff line change
@@ -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
75 changes: 75 additions & 0 deletions plugins/epsa_kdf.rb
Original file line number Diff line number Diff line change
@@ -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
Loading