rust-apk 1.0.0

Build, align, sign, verify, and inspect Android APKs in pure Rust
Documentation
//! Signing existing APKs and verifying them, cross-checked with apksigner.

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};

/// An unsigned APK for `min_sdk` with a native library, a stale JAR
/// signature, and unrelated `META-INF` metadata.
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();
    // A leftover signature file, as an earlier signing tool would have written.
    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);
    // Overriding the minimum SDK signs for API 24+ only, without v1.
    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);
}

/// Writes a copy of the APK at `path` with `edit` applied to its bytes.
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
}

/// A small change to a signed APK.
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");
}

/// Rebuilds the APK at `path` with a new signing block between its entries
/// and central directory.
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();
    // Repeat the first central directory record, as in "Master Key"-style attacks.
    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");
}