mod common;
use std::io::{Cursor, Read, Write};
use std::path::{Path, PathBuf};
use apk::archive::{ApkLayout, ZipError};
use apk::prelude::*;
use apk::sign::{BlockId, CoverageError, Scheme, SignError, SigningBlock, VerifyError};
fn unsigned(dir: &Path, min_sdk: u32) -> PathBuf {
let path = dir.join(format!("unsigned-{min_sdk}.apk"));
let mut app = ApkBuilder::native_activity("org.example.signing".parse().unwrap(), "postcard".parse().unwrap());
app.min_sdk(ApiLevel::try_new(min_sdk).unwrap())
.native_library_bytes(Abi::Arm64V8a, "postcard".parse().unwrap(), common::NATIVE_LIBRARY)
.unwrap()
.asset("big.bin", vec![7u8; (1 << 20) + 3])
.unwrap()
.file(ArchivePath::try_new("META-INF/services/org.example.Plugin").unwrap(), b"org.example.Impl", None)
.unwrap();
app.write(&path).unwrap();
let file = std::fs::OpenOptions::new().read(true).write(true).open(&path).unwrap();
let mut zip = zip::ZipWriter::new_append(file).unwrap();
zip.start_file("META-INF/STALE.SF", zip::write::SimpleFileOptions::default()).unwrap();
zip.write_all(b"stale").unwrap();
zip.finish().unwrap();
path
}
fn entry_names(apk: &Path) -> Vec<String> {
Apk::open(apk).unwrap().entries().map(str::to_owned).collect()
}
fn read_entry(apk: &Path, name: &str) -> Vec<u8> {
Apk::open(apk).unwrap().read(name).unwrap()
}
fn layout(bytes: &[u8]) -> ApkLayout {
ApkLayout::read(&mut Cursor::new(bytes)).unwrap()
}
fn assert_apksigner_accepts(apk: &Path, min_sdk: u32) {
if let Some(run) = common::apksigner_verify(apk, min_sdk) {
run.expect_success(&format!("apksigner verify --min-sdk-version {min_sdk} {}", apk.display()));
}
}
fn assert_apksigner_rejects(apk: &Path, min_sdk: u32) {
if let Some(run) = common::apksigner_verify(apk, min_sdk) {
assert!(!run.success, "apksigner accepted an invalid APK:\n{}", run.stdout);
}
}
#[test]
fn every_key_type_and_scheme_set_verifies() {
let dir = tempfile::tempdir().unwrap();
let cases = [
("debug", 16, Schemes::for_min_sdk(ApiLevel::try_new(16).unwrap())),
("debug", 24, Schemes::V2),
("rsa-4096", 24, Schemes::V2 | Schemes::V3),
("ec-p256", 18, Schemes::V1 | Schemes::V2 | Schemes::V3),
("ec-p256", 21, Schemes::V1 | Schemes::V2),
("ec-p256-sec1", 28, Schemes::V3),
("ec-p384", 24, Schemes::V2 | Schemes::V3),
];
for (key, min_sdk, schemes) in cases {
let path = unsigned(dir.path(), min_sdk);
Signer::new(common::identity(key)).schemes(schemes).sign_file(&path).unwrap();
let verification = verify(&path).unwrap();
assert_eq!(verification.schemes, schemes, "{key} at API {min_sdk}");
assert_eq!(verification.certificate(), common::identity(key).certificate());
assert_apksigner_accepts(&path, min_sdk);
}
}
#[test]
fn apks_signed_by_apksigner_verify_and_re_sign() {
let dir = tempfile::tempdir().unwrap();
for (key, min_sdk) in [("debug", 21), ("ec-p256", 24), ("rsa-4096", 28)] {
let path = unsigned(dir.path(), min_sdk);
let identity = common::identity(key);
let Some(run) = common::apksigner_sign(&path, &identity) else { return };
run.expect_success("apksigner sign");
let verification = verify(&path).unwrap_or_else(|e| panic!("{key}: apksigner's signature did not verify: {e}"));
assert!(verification.schemes.contains(Schemes::V2 | Schemes::V3), "{key}: {:?}", verification.schemes);
assert_eq!(verification.certificate(), identity.certificate());
Signer::new(common::identity("ec-p256")).sign_file(&path).unwrap();
assert_eq!(verify(&path).unwrap().certificate(), common::identity("ec-p256").certificate());
assert_apksigner_accepts(&path, min_sdk);
}
}
#[test]
fn the_minimum_sdk_comes_from_the_manifest() {
let dir = tempfile::tempdir().unwrap();
let path = unsigned(dir.path(), 16);
Signer::new(common::identity("debug")).sign_file(&path).unwrap();
assert_eq!(verify(&path).unwrap().schemes, Schemes::V1 | Schemes::V2 | Schemes::V3);
let manifest = String::from_utf8(read_entry(&path, "META-INF/MANIFEST.MF")).unwrap();
assert!(manifest.contains("SHA1-Digest: "), "API 16 needs SHA-1 JAR digests:\n{manifest}");
let signature_file = String::from_utf8(read_entry(&path, "META-INF/CERT.SF")).unwrap();
assert!(signature_file.contains("X-Android-APK-Signed: 2, 3\r\n"), "{signature_file}");
}
#[test]
fn verification_requires_every_supported_release_to_be_covered() {
let dir = tempfile::tempdir().unwrap();
let path = unsigned(dir.path(), 21);
Signer::new(common::identity("debug")).min_sdk(ApiLevel::N).sign_file(&path).unwrap();
assert!(matches!(
verify(&path),
Err(apk::Error::Verify(VerifyError::Coverage(CoverageError::UnverifiableOnMinSdk { needed: Scheme::V1, .. })))
));
assert_apksigner_rejects(&path, 21);
}
#[test]
fn re_signing_replaces_signatures_and_preserves_everything_else() {
let dir = tempfile::tempdir().unwrap();
let path = unsigned(dir.path(), 21);
let library = read_entry(&path, "lib/arm64-v8a/libpostcard.so");
Signer::new(common::identity("debug")).sign_file(&path).unwrap();
Signer::new(common::identity("ec-p256")).sign_file(&path).unwrap();
assert_eq!(verify(&path).unwrap().certificate(), common::identity("ec-p256").certificate());
let names = entry_names(&path);
assert!(names.contains(&"META-INF/services/org.example.Plugin".to_owned()));
assert!(names.contains(&"META-INF/CERT.EC".to_owned()));
for stale in ["META-INF/STALE.SF", "META-INF/CERT.RSA"] {
assert!(!names.contains(&stale.to_owned()), "{stale} survived re-signing");
}
assert_eq!(read_entry(&path, "lib/arm64-v8a/libpostcard.so"), library);
let mut archive = zip::ZipArchive::new(std::fs::File::open(&path).unwrap()).unwrap();
assert_eq!(archive.by_name("lib/arm64-v8a/libpostcard.so").unwrap().data_start().unwrap() % 16384, 0);
assert_apksigner_accepts(&path, 21);
}
fn corrupt(path: &Path, edit: impl Fn(&mut Vec<u8>, &ApkLayout)) -> PathBuf {
let mut bytes = std::fs::read(path).unwrap();
let layout = layout(&bytes);
edit(&mut bytes, &layout);
let corrupted = path.with_extension("corrupt.apk");
std::fs::write(&corrupted, bytes).unwrap();
corrupted
}
struct Tamper {
what: &'static str,
edit: fn(&mut Vec<u8>, &ApkLayout),
}
#[test]
fn tampering_anywhere_is_detected() {
let dir = tempfile::tempdir().unwrap();
let signed = unsigned(dir.path(), 24);
Signer::new(common::identity("debug")).sign_file(&signed).unwrap();
let tampers = [
Tamper { what: "first entry byte", edit: |bytes, _| bytes[40] ^= 1 },
Tamper { what: "last entry byte", edit: |bytes, layout| bytes[layout.entries_end as usize - 1] ^= 1 },
Tamper {
what: "signature inside the signing block",
edit: |bytes, layout| bytes[layout.signing_block.clone().unwrap().start as usize + 600] ^= 1,
},
Tamper {
what: "central directory",
edit: |bytes, layout| bytes[layout.central_directory.start as usize + 20] ^= 1,
},
Tamper {
what: "appended EOCD comment",
edit: |bytes, layout| {
bytes[layout.eocd_offset as usize + 20] = 1;
bytes.push(b'!');
},
},
];
for Tamper { what, edit } in tampers {
let corrupted = corrupt(&signed, edit);
assert!(verify(&corrupted).is_err(), "tampered {what} still verified");
assert_apksigner_rejects(&corrupted, 24);
}
}
#[test]
fn removing_the_v2_block_is_detected_by_the_v1_signature() {
let dir = tempfile::tempdir().unwrap();
let signed = unsigned(dir.path(), 21);
Signer::new(common::identity("debug")).sign_file(&signed).unwrap();
let stripped = corrupt(&signed, |bytes, layout| {
let block = layout.signing_block.clone().unwrap();
let removed = block.end - block.start;
bytes.drain(block.start as usize..block.end as usize);
let eocd = (layout.eocd_offset - removed) as usize;
bytes[eocd + 16..eocd + 20].copy_from_slice(&(block.start as u32).to_le_bytes());
});
assert!(matches!(
verify(&stripped),
Err(apk::Error::Verify(VerifyError::StrippedScheme { protected_by: Scheme::V1, .. }))
));
assert_apksigner_rejects(&stripped, 21);
}
#[test]
fn rejected_signing_leaves_the_input_untouched() {
let dir = tempfile::tempdir().unwrap();
let path = unsigned(dir.path(), 16);
let before = std::fs::read(&path).unwrap();
let error = Signer::new(common::identity("ec-p256")).sign_file(&path).unwrap_err();
assert!(matches!(error, apk::Error::Sign(SignError::EcdsaJarRequiresApi18 { .. })), "{error:?}");
let error = Signer::new(common::identity("debug")).schemes(Schemes::V3).sign_file(&path).unwrap_err();
assert!(
matches!(error, apk::Error::Sign(SignError::Coverage(CoverageError::UnverifiableOnMinSdk { .. }))),
"{error:?}"
);
assert_eq!(std::fs::read(&path).unwrap(), before);
assert_eq!(std::fs::read_dir(dir.path()).unwrap().count(), 1, "temporary files leaked");
}
#[test]
fn archives_without_a_manifest_can_be_signed_with_an_explicit_min_sdk() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("plain.zip");
let mut zip = zip::ZipWriter::new(std::fs::File::create(&path).unwrap());
zip.start_file("data.txt", zip::write::SimpleFileOptions::default()).unwrap();
zip.write_all(b"payload").unwrap();
zip.finish().unwrap();
let error = Signer::new(common::identity("debug")).sign_file(&path).unwrap_err();
assert!(matches!(error, apk::Error::Sign(SignError::UnknownMinSdk)), "{error:?}");
Signer::new(common::identity("debug")).min_sdk(ApiLevel::N).sign_file(&path).unwrap();
assert_eq!(verify(&path).unwrap().schemes, Schemes::V2 | Schemes::V3);
let mut contents = String::new();
zip::ZipArchive::new(std::fs::File::open(&path).unwrap())
.unwrap()
.by_name("data.txt")
.unwrap()
.read_to_string(&mut contents)
.unwrap();
assert_eq!(contents, "payload");
}
fn replace_signing_block(path: &Path, block: &SigningBlock) {
let bytes = std::fs::read(path).unwrap();
let layout = layout(&bytes);
let mut out = bytes[..layout.entries_end as usize].to_vec();
out.extend_from_slice(&block.encode());
let cd_start = out.len();
out.extend_from_slice(&bytes[layout.central_directory.start as usize..]);
let eocd = cd_start + (layout.eocd_offset - layout.central_directory.start) as usize;
out[eocd + 16..eocd + 20].copy_from_slice(&(cd_start as u32).to_le_bytes());
std::fs::write(path, out).unwrap();
}
fn signing_block(path: &Path) -> SigningBlock {
let bytes = std::fs::read(path).unwrap();
let range = layout(&bytes).signing_block.unwrap();
SigningBlock::parse(&bytes[range.start as usize..range.end as usize]).unwrap()
}
#[test]
fn re_signing_keeps_unrelated_signing_block_entries() {
let dir = tempfile::tempdir().unwrap();
let path = unsigned(dir.path(), 24);
Signer::new(common::identity("debug")).sign_file(&path).unwrap();
let mut block = signing_block(&path);
block.pairs.push((BlockId::DEPENDENCY_INFO, b"play dependency metadata".to_vec()));
replace_signing_block(&path, &block);
Signer::new(common::identity("ec-p256")).sign_file(&path).unwrap();
let block = signing_block(&path);
assert_eq!(block.get(BlockId::DEPENDENCY_INFO), Some(b"play dependency metadata".as_slice()));
assert_eq!(block.pairs.iter().filter(|(id, _)| *id == BlockId::V2).count(), 1);
assert_eq!(verify(&path).unwrap().certificate(), common::identity("ec-p256").certificate());
assert_apksigner_accepts(&path, 24);
}
#[test]
fn duplicate_entry_names_are_rejected() {
let dir = tempfile::tempdir().unwrap();
let signed = unsigned(dir.path(), 21);
Signer::new(common::identity("debug")).sign_file(&signed).unwrap();
let duplicated = corrupt(&signed, |bytes, layout| {
let cd = layout.central_directory.start as usize;
let [name, extra, comment] =
[28, 30, 32].map(|at| usize::from(u16::from_le_bytes([bytes[cd + at], bytes[cd + at + 1]])));
let record = bytes[cd..cd + 46 + name + extra + comment].to_vec();
let eocd = layout.eocd_offset as usize;
bytes.splice(eocd..eocd, record.iter().copied());
let eocd = eocd + record.len();
for at in [8, 10] {
let count = u16::from_le_bytes([bytes[eocd + at], bytes[eocd + at + 1]]) + 1;
bytes[eocd + at..eocd + at + 2].copy_from_slice(&count.to_le_bytes());
}
let size = u32::from_le_bytes(bytes[eocd + 12..eocd + 16].try_into().unwrap()) + record.len() as u32;
bytes[eocd + 12..eocd + 16].copy_from_slice(&size.to_le_bytes());
});
assert!(matches!(verify(&duplicated), Err(apk::Error::Zip(ZipError::DuplicateNames { .. }))));
assert!(matches!(Apk::open(&duplicated), Err(apk::Error::Zip(ZipError::DuplicateNames { .. }))));
assert_apksigner_rejects(&duplicated, 21);
}
#[test]
fn signing_respects_the_classic_zip_entry_count_atomically() {
let dir = tempfile::tempdir().unwrap();
let archive = |name, count| {
let path = dir.path().join(name);
let mut zip = zip::ZipWriter::new(std::fs::File::create(&path).unwrap());
let options = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored);
for index in 0..count {
zip.start_file(format!("f{index:05}"), options).unwrap();
}
zip.finish().unwrap();
path
};
let signer = Signer::new(common::identity("debug")).min_sdk(ApiLevel::LOLLIPOP).schemes(Schemes::V1 | Schemes::V2);
let valid = archive("valid.zip", 65_531);
signer.sign_file(&valid).unwrap();
assert_eq!(layout(&std::fs::read(&valid).unwrap()).entry_count(), 65_534);
let sentinel = archive("sentinel.zip", 65_532);
let before = std::fs::read(&sentinel).unwrap();
assert!(matches!(
signer.sign_file(&sentinel),
Err(apk::Error::Zip(ZipError::LimitExceeded { limit: apk::archive::ZipLimit::EntryCount }))
));
assert_eq!(std::fs::read(&sentinel).unwrap(), before);
assert_eq!(std::fs::read_dir(dir.path()).unwrap().count(), 2, "temporary files leaked");
}