Skip to main content

rustferry_android/
signing.rs

1use std::{
2    fs,
3    fs::OpenOptions,
4    io::Write,
5    time::{SystemTime, UNIX_EPOCH},
6};
7
8use camino::{Utf8Path, Utf8PathBuf};
9use directories::BaseDirs;
10use fs2::FileExt;
11use uuid::Uuid;
12
13use crate::command::external_tool_path_arg;
14use crate::{AndroidError, AndroidToolchain, CommandSpec, error::io_error, run_command};
15
16/// Alias used only by the machine-local debug certificate.
17pub const DEBUG_KEY_ALIAS: &str = "rustferrydebugkey";
18
19/// Non-inline password source accepted by Android signing tools.
20#[derive(Clone, Debug, Eq, PartialEq)]
21pub enum SigningPasswordSource {
22    /// Read one password line from a file.
23    File(Utf8PathBuf),
24    /// Read a password from a named environment variable.
25    Environment(String),
26}
27
28impl SigningPasswordSource {
29    fn apksigner_argument(&self) -> Result<String, AndroidError> {
30        match self {
31            Self::File(path) => Ok(format!("file:{}", external_tool_path_arg(path)?)),
32            Self::Environment(name)
33                if !name.is_empty()
34                    && name
35                        .chars()
36                        .all(|character| character.is_ascii_alphanumeric() || character == '_') =>
37            {
38                Ok(format!("env:{name}"))
39            }
40            Self::Environment(name) => Err(AndroidError::InvalidRequest(format!(
41                "signing password environment variable `{name}` is not a safe variable name"
42            ))),
43        }
44    }
45}
46
47/// APK signing selection.
48#[derive(Clone, Debug, Eq, PartialEq)]
49pub enum AndroidSigningConfig {
50    /// Persistent machine-local certificate, created on first non-dry build.
51    Debug {
52        /// Override cargo-ferry's OS configuration directory.
53        config_dir: Option<Utf8PathBuf>,
54    },
55    /// User-supplied release or development key store.
56    Keystore {
57        /// Key store path outside generated output.
58        keystore: Utf8PathBuf,
59        /// Private-key alias.
60        key_alias: String,
61        /// Store password source.
62        store_password: SigningPasswordSource,
63        /// Optional distinct private-key password source.
64        key_password: Option<SigningPasswordSource>,
65    },
66}
67
68impl Default for AndroidSigningConfig {
69    fn default() -> Self {
70        Self::Debug { config_dir: None }
71    }
72}
73
74/// Machine-local files backing the persistent debug signer.
75#[derive(Clone, Debug, Eq, PartialEq)]
76pub struct DebugSigningPaths {
77    /// PKCS#12 key store.
78    pub keystore: Utf8PathBuf,
79    /// Owner-readable password file.
80    pub password_file: Utf8PathBuf,
81    /// Coordination lock for concurrent first builds.
82    pub lock_file: Utf8PathBuf,
83}
84
85/// Fully resolved signing parameters; password values are never stored here.
86#[derive(Clone, Debug, Eq, PartialEq)]
87pub struct ResolvedSigningConfig {
88    /// Key store path.
89    pub keystore: Utf8PathBuf,
90    /// Private-key alias.
91    pub key_alias: String,
92    /// Store password source.
93    pub store_password: SigningPasswordSource,
94    /// Optional distinct private-key password source.
95    pub key_password: Option<SigningPasswordSource>,
96}
97
98/// Resolve paths in cargo-ferry's OS configuration directory without creating files.
99///
100/// # Errors
101///
102/// Returns an error when the host configuration directory is unavailable or non-UTF-8.
103pub fn default_debug_signing_paths(
104    config_dir: Option<&Utf8Path>,
105) -> Result<DebugSigningPaths, AndroidError> {
106    let root = if let Some(root) = config_dir {
107        root.to_owned()
108    } else {
109        let base = BaseDirs::new().ok_or_else(|| {
110            AndroidError::InvalidRequest(
111                "the operating-system configuration directory could not be resolved".to_owned(),
112            )
113        })?;
114        Utf8PathBuf::from_path_buf(base.config_dir().to_owned())
115            .map_err(AndroidError::NonUtf8Path)?
116            .join("cargo-ferry")
117    };
118    let android = root.join("android");
119    Ok(DebugSigningPaths {
120        keystore: android.join("debug.keystore"),
121        password_file: android.join("debug-keystore.pass"),
122        lock_file: android.join("debug-keystore.lock"),
123    })
124}
125
126/// Resolve a signing configuration, creating and validating the debug key when needed.
127///
128/// # Errors
129///
130/// Returns an error for missing explicit signing files, invalid password references, key-tool
131/// failures, or machine-local filesystem failures.
132pub fn resolve_signing_config(
133    signing: &AndroidSigningConfig,
134    toolchain: &AndroidToolchain,
135    log_dir: &Utf8Path,
136) -> Result<ResolvedSigningConfig, AndroidError> {
137    match signing {
138        AndroidSigningConfig::Debug { config_dir } => {
139            let paths = default_debug_signing_paths(config_dir.as_deref())?;
140            ensure_debug_keystore(&paths, toolchain, log_dir)?;
141            Ok(ResolvedSigningConfig {
142                keystore: paths.keystore,
143                key_alias: DEBUG_KEY_ALIAS.to_owned(),
144                store_password: SigningPasswordSource::File(paths.password_file),
145                key_password: None,
146            })
147        }
148        AndroidSigningConfig::Keystore {
149            keystore,
150            key_alias,
151            store_password,
152            key_password,
153        } => {
154            if !keystore.is_file() {
155                return Err(AndroidError::InvalidRequest(format!(
156                    "signing keystore does not exist: {keystore}"
157                )));
158            }
159            if key_alias.trim().is_empty() {
160                return Err(AndroidError::InvalidRequest(
161                    "signing key alias cannot be empty".to_owned(),
162                ));
163            }
164            validate_password_source(store_password)?;
165            if let Some(source) = key_password {
166                validate_password_source(source)?;
167            }
168            Ok(ResolvedSigningConfig {
169                keystore: keystore.clone(),
170                key_alias: key_alias.clone(),
171                store_password: store_password.clone(),
172                key_password: key_password.clone(),
173            })
174        }
175    }
176}
177
178/// Resolve signing paths and password references without reading or creating signing files.
179///
180/// # Errors
181///
182/// Returns an error for an invalid key alias, password reference, or config path.
183pub fn preview_signing_config(
184    signing: &AndroidSigningConfig,
185) -> Result<ResolvedSigningConfig, AndroidError> {
186    match signing {
187        AndroidSigningConfig::Debug { config_dir } => {
188            let paths = default_debug_signing_paths(config_dir.as_deref())?;
189            Ok(ResolvedSigningConfig {
190                keystore: paths.keystore,
191                key_alias: DEBUG_KEY_ALIAS.to_owned(),
192                store_password: SigningPasswordSource::File(paths.password_file),
193                key_password: None,
194            })
195        }
196        AndroidSigningConfig::Keystore {
197            keystore,
198            key_alias,
199            store_password,
200            key_password,
201        } => {
202            if key_alias.trim().is_empty() {
203                return Err(AndroidError::InvalidRequest(
204                    "signing key alias cannot be empty".to_owned(),
205                ));
206            }
207            store_password.apksigner_argument()?;
208            if let Some(source) = key_password {
209                source.apksigner_argument()?;
210            }
211            Ok(ResolvedSigningConfig {
212                keystore: keystore.clone(),
213                key_alias: key_alias.clone(),
214                store_password: store_password.clone(),
215                key_password: key_password.clone(),
216            })
217        }
218    }
219}
220
221/// Build the `apksigner sign` command without exposing a password value.
222///
223/// # Errors
224///
225/// Returns an error when `apksigner` is absent or a password source is invalid.
226pub fn apksigner_sign_command(
227    toolchain: &AndroidToolchain,
228    signing: &ResolvedSigningConfig,
229    input: &Utf8Path,
230    output: &Utf8Path,
231    current_dir: &Utf8Path,
232) -> Result<CommandSpec, AndroidError> {
233    let apksigner =
234        toolchain
235            .build_tools
236            .apksigner
237            .clone()
238            .ok_or_else(|| AndroidError::ToolMissing {
239                tool: "apksigner".to_owned(),
240                searched: vec![toolchain.build_tools.directory.clone()],
241                fix: "Install a complete Android SDK Build Tools revision.".to_owned(),
242            })?;
243    let mut command = CommandSpec::new(
244        "sign APK",
245        apksigner,
246        Utf8PathBuf::from(external_tool_path_arg(current_dir)?),
247    );
248    command.args = vec![
249        "sign".to_owned(),
250        "--ks".to_owned(),
251        external_tool_path_arg(&signing.keystore)?,
252        "--ks-key-alias".to_owned(),
253        signing.key_alias.clone(),
254        "--ks-pass".to_owned(),
255        signing.store_password.apksigner_argument()?,
256    ];
257    if let Some(source) = &signing.key_password {
258        command.args.push("--key-pass".to_owned());
259        command.args.push(source.apksigner_argument()?);
260    }
261    command.args.extend([
262        "--out".to_owned(),
263        external_tool_path_arg(output)?,
264        external_tool_path_arg(input)?,
265    ]);
266    Ok(command)
267}
268
269/// Build the mandatory signature verification command.
270///
271/// # Errors
272///
273/// Returns an error when the selected Build Tools do not contain `apksigner`.
274pub fn apksigner_verify_command(
275    toolchain: &AndroidToolchain,
276    apk: &Utf8Path,
277    current_dir: &Utf8Path,
278) -> Result<CommandSpec, AndroidError> {
279    let apksigner =
280        toolchain
281            .build_tools
282            .apksigner
283            .clone()
284            .ok_or_else(|| AndroidError::ToolMissing {
285                tool: "apksigner".to_owned(),
286                searched: vec![toolchain.build_tools.directory.clone()],
287                fix: "Install a complete Android SDK Build Tools revision.".to_owned(),
288            })?;
289    let mut command = CommandSpec::new(
290        "verify APK signature",
291        apksigner,
292        Utf8PathBuf::from(external_tool_path_arg(current_dir)?),
293    );
294    command.args = vec![
295        "verify".to_owned(),
296        "--verbose".to_owned(),
297        "--print-certs".to_owned(),
298        external_tool_path_arg(apk)?,
299    ];
300    Ok(command)
301}
302
303fn ensure_debug_keystore(
304    paths: &DebugSigningPaths,
305    toolchain: &AndroidToolchain,
306    log_dir: &Utf8Path,
307) -> Result<(), AndroidError> {
308    let parent = paths.keystore.parent().ok_or_else(|| {
309        AndroidError::InvalidRequest("debug keystore has no parent directory".to_owned())
310    })?;
311    fs::create_dir_all(parent)
312        .map_err(|source| io_error("create signing configuration directory", parent, source))?;
313    #[cfg(unix)]
314    secure_directory(parent)?;
315    let lock = OpenOptions::new()
316        .create(true)
317        .truncate(false)
318        .read(true)
319        .write(true)
320        .open(&paths.lock_file)
321        .map_err(|source| io_error("open debug keystore lock", &paths.lock_file, source))?;
322    lock.lock_exclusive()
323        .map_err(|source| io_error("lock debug keystore", &paths.lock_file, source))?;
324
325    if paths.keystore.exists() && !paths.password_file.is_file() {
326        return Err(AndroidError::InvalidRequest(format!(
327            "debug keystore exists but its password file is missing: {}. Move the keystore aside or restore the password file; cargo-ferry will not overwrite the key.",
328            paths.keystore
329        )));
330    }
331    if !paths.password_file.exists() {
332        create_password_file(&paths.password_file)?;
333    }
334    #[cfg(unix)]
335    secure_file(&paths.password_file)?;
336
337    if !paths.keystore.exists() {
338        let mut command = CommandSpec::new(
339            "create persistent debug keystore",
340            toolchain.keytool.clone(),
341            Utf8PathBuf::from(external_tool_path_arg(parent)?),
342        );
343        command.args = vec![
344            "-genkeypair".to_owned(),
345            "-keystore".to_owned(),
346            external_tool_path_arg(&paths.keystore)?,
347            "-storetype".to_owned(),
348            "PKCS12".to_owned(),
349            "-storepass:file".to_owned(),
350            external_tool_path_arg(&paths.password_file)?,
351            "-alias".to_owned(),
352            DEBUG_KEY_ALIAS.to_owned(),
353            "-keyalg".to_owned(),
354            "RSA".to_owned(),
355            "-keysize".to_owned(),
356            "2048".to_owned(),
357            "-validity".to_owned(),
358            "10000".to_owned(),
359            "-dname".to_owned(),
360            "CN=RustFerry Debug,O=RustFerry,C=US".to_owned(),
361            "-noprompt".to_owned(),
362        ];
363        run_command(&command, &log_dir.join("keytool-create.log"))?;
364        #[cfg(unix)]
365        secure_file(&paths.keystore)?;
366    }
367
368    let mut verify = CommandSpec::new(
369        "validate persistent debug keystore",
370        toolchain.keytool.clone(),
371        Utf8PathBuf::from(external_tool_path_arg(parent)?),
372    );
373    verify.args = vec![
374        "-J-Duser.language=en".to_owned(),
375        "-J-Duser.country=US".to_owned(),
376        "-list".to_owned(),
377        "-keystore".to_owned(),
378        external_tool_path_arg(&paths.keystore)?,
379        "-storetype".to_owned(),
380        "PKCS12".to_owned(),
381        "-storepass:file".to_owned(),
382        external_tool_path_arg(&paths.password_file)?,
383        "-alias".to_owned(),
384        DEBUG_KEY_ALIAS.to_owned(),
385        "-v".to_owned(),
386    ];
387    let output = run_command(&verify, &log_dir.join("keytool-verify.log"))?;
388    let stdout = std::str::from_utf8(&output.stdout).map_err(|error| {
389        AndroidError::InvalidRequest(format!(
390            "keytool returned non-UTF-8 certificate metadata; inspect keytool-verify.log: {error}"
391        ))
392    })?;
393    validate_debug_certificate_expiry(stdout, utc_today()?)?;
394    Ok(())
395}
396
397#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
398struct CalendarDate {
399    year: i64,
400    month: u8,
401    day: u8,
402}
403
404fn validate_debug_certificate_expiry(
405    keytool_output: &str,
406    today: CalendarDate,
407) -> Result<(), AndroidError> {
408    let expiration = parse_keytool_expiration(keytool_output).ok_or_else(|| {
409        AndroidError::InvalidRequest(
410            "keytool did not report a parseable debug certificate expiry; inspect keytool-verify.log and recreate the debug key pair deliberately"
411                .to_owned(),
412        )
413    })?;
414    if expiration <= today {
415        return Err(AndroidError::InvalidRequest(format!(
416            "persistent debug certificate expired on {:04}-{:02}-{:02}; move the debug keystore and password file aside together, then rebuild to create a fresh identity",
417            expiration.year, expiration.month, expiration.day
418        )));
419    }
420    Ok(())
421}
422
423fn parse_keytool_expiration(output: &str) -> Option<CalendarDate> {
424    let expiry = output
425        .lines()
426        .find_map(|line| line.split_once("until:").map(|(_, expiry)| expiry.trim()))?;
427    let fields = expiry.split_whitespace().collect::<Vec<_>>();
428    let month = match *fields.get(1)? {
429        "Jan" => 1,
430        "Feb" => 2,
431        "Mar" => 3,
432        "Apr" => 4,
433        "May" => 5,
434        "Jun" => 6,
435        "Jul" => 7,
436        "Aug" => 8,
437        "Sep" => 9,
438        "Oct" => 10,
439        "Nov" => 11,
440        "Dec" => 12,
441        _ => return None,
442    };
443    let day = fields.get(2)?.parse::<u8>().ok()?;
444    let year = fields.last()?.parse::<i64>().ok()?;
445    (day > 0 && day <= days_in_month(year, month)).then_some(CalendarDate { year, month, day })
446}
447
448fn utc_today() -> Result<CalendarDate, AndroidError> {
449    let elapsed = SystemTime::now().duration_since(UNIX_EPOCH).map_err(|_| {
450        AndroidError::InvalidRequest(
451            "system clock predates the Unix epoch; debug certificate expiry cannot be checked"
452                .to_owned(),
453        )
454    })?;
455    let days = i64::try_from(elapsed.as_secs() / 86_400).map_err(|_| {
456        AndroidError::InvalidRequest(
457            "system clock is outside the supported certificate-validation range".to_owned(),
458        )
459    })?;
460    Ok(civil_date_from_unix_days(days))
461}
462
463fn civil_date_from_unix_days(days: i64) -> CalendarDate {
464    let shifted = days + 719_468;
465    let era = if shifted >= 0 {
466        shifted
467    } else {
468        shifted - 146_096
469    } / 146_097;
470    let day_of_era = shifted - era * 146_097;
471    let year_of_era =
472        (day_of_era - day_of_era / 1_460 + day_of_era / 36_524 - day_of_era / 146_096) / 365;
473    let mut year = year_of_era + era * 400;
474    let day_of_year = day_of_era - (365 * year_of_era + year_of_era / 4 - year_of_era / 100);
475    let month_prime = (5 * day_of_year + 2) / 153;
476    let day = day_of_year - (153 * month_prime + 2) / 5 + 1;
477    let month = month_prime + if month_prime < 10 { 3 } else { -9 };
478    if month <= 2 {
479        year += 1;
480    }
481    CalendarDate {
482        year,
483        month: u8::try_from(month).expect("civil month is bounded"),
484        day: u8::try_from(day).expect("civil day is bounded"),
485    }
486}
487
488fn days_in_month(year: i64, month: u8) -> u8 {
489    match month {
490        1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
491        4 | 6 | 9 | 11 => 30,
492        2 if year % 4 == 0 && (year % 100 != 0 || year % 400 == 0) => 29,
493        2 => 28,
494        _ => 0,
495    }
496}
497
498fn validate_password_source(source: &SigningPasswordSource) -> Result<(), AndroidError> {
499    match source {
500        SigningPasswordSource::File(path) if !path.is_file() => Err(AndroidError::InvalidRequest(
501            format!("signing password file does not exist: {path}"),
502        )),
503        _ => source.apksigner_argument().map(|_| ()),
504    }
505}
506
507fn create_password_file(path: &Utf8Path) -> Result<(), AndroidError> {
508    let mut file = OpenOptions::new()
509        .write(true)
510        .create_new(true)
511        .open(path)
512        .map_err(|source| io_error("create debug keystore password file", path, source))?;
513    #[cfg(unix)]
514    secure_file(path)?;
515    writeln!(
516        file,
517        "{}{}",
518        Uuid::new_v4().simple(),
519        Uuid::new_v4().simple()
520    )
521    .map_err(|source| io_error("write debug keystore password file", path, source))
522}
523
524#[cfg(unix)]
525fn secure_directory(path: &Utf8Path) -> Result<(), AndroidError> {
526    use std::os::unix::fs::PermissionsExt;
527    fs::set_permissions(path, fs::Permissions::from_mode(0o700))
528        .map_err(|source| io_error("set signing directory permissions", path, source))
529}
530
531#[cfg(unix)]
532fn secure_file(path: &Utf8Path) -> Result<(), AndroidError> {
533    use std::os::unix::fs::PermissionsExt;
534    fs::set_permissions(path, fs::Permissions::from_mode(0o600))
535        .map_err(|source| io_error("set signing file permissions", path, source))
536}
537
538#[cfg(test)]
539mod tests {
540    use super::*;
541    #[cfg(windows)]
542    use crate::{AndroidBuildTools, AndroidNdk, AndroidPlatform};
543
544    #[cfg(windows)]
545    fn signing_toolchain() -> AndroidToolchain {
546        AndroidToolchain {
547            sdk_root: r"C:\Android\Sdk".into(),
548            platform: AndroidPlatform {
549                sdk_root: r"C:\Android\Sdk".into(),
550                api_level: 36,
551                directory: r"C:\Android\Sdk\platforms\android-36".into(),
552                android_jar: r"C:\Android\Sdk\platforms\android-36\android.jar".into(),
553            },
554            build_tools: AndroidBuildTools {
555                sdk_root: r"C:\Android\Sdk".into(),
556                version: "36.0.0".to_owned(),
557                directory: r"C:\Android\Sdk\build-tools\36.0.0".into(),
558                aapt2: None,
559                d8: None,
560                zipalign: None,
561                apksigner: Some(r"C:\Android\Sdk\build-tools\36.0.0\apksigner.bat".into()),
562            },
563            ndk: AndroidNdk {
564                root: r"C:\Android\Sdk\ndk\29.0.0".into(),
565                version: "29.0.0".to_owned(),
566                llvm_prebuilt: None,
567            },
568            cargo: r"C:\Rust\cargo.exe".into(),
569            rustc: None,
570            rustup: None,
571            java: None,
572            javac: None,
573            keytool: r"C:\Java\bin\keytool.exe".into(),
574        }
575    }
576
577    #[test]
578    fn password_values_are_never_command_arguments() {
579        #[cfg(windows)]
580        let path = r"C:\secure\signing.pass";
581        #[cfg(not(windows))]
582        let path = "/secure/signing.pass";
583        let source = SigningPasswordSource::File(path.into());
584        assert_eq!(source.apksigner_argument().unwrap(), format!("file:{path}"));
585        assert!(!source.apksigner_argument().unwrap().contains("secret"));
586    }
587
588    #[test]
589    fn rejects_unsafe_environment_names() {
590        let source = SigningPasswordSource::Environment("PASSWORD;echo".to_owned());
591        assert!(source.apksigner_argument().is_err());
592    }
593
594    #[cfg(windows)]
595    #[test]
596    fn apksigner_commands_normalize_windows_verbatim_paths() {
597        let signing = ResolvedSigningConfig {
598            keystore: r"\\?\C:\secure\debug.keystore".into(),
599            key_alias: "debug".to_owned(),
600            store_password: SigningPasswordSource::File(
601                r"\\?\C:\secure\debug-keystore.pass".into(),
602            ),
603            key_password: None,
604        };
605        let command = apksigner_sign_command(
606            &signing_toolchain(),
607            &signing,
608            Utf8Path::new(r"\\?\C:\work\aligned.apk"),
609            Utf8Path::new(r"\\?\C:\work\calculator.apk"),
610            Utf8Path::new(r"\\?\C:\work"),
611        )
612        .unwrap();
613        assert_eq!(command.current_dir, Utf8Path::new(r"C:\work"));
614        assert!(command.args.iter().all(|value| !value.contains(r"\\?\")));
615        assert!(command.args.contains(&r"C:\work\calculator.apk".to_owned()));
616
617        let verify = apksigner_verify_command(
618            &signing_toolchain(),
619            Utf8Path::new(r"\\?\C:\work\calculator.apk"),
620            Utf8Path::new(r"\\?\C:\work"),
621        )
622        .unwrap();
623        assert!(verify.args.iter().all(|value| !value.contains(r"\\?\")));
624    }
625
626    #[test]
627    fn parses_and_enforces_debug_certificate_expiry() {
628        let output =
629            "Valid from: Sat Aug 01 04:45:48 MSK 2026 until: Wed Dec 17 04:45:48 MSK 2053\n";
630        let expiration = CalendarDate {
631            year: 2053,
632            month: 12,
633            day: 17,
634        };
635        assert_eq!(parse_keytool_expiration(output), Some(expiration));
636        assert!(
637            validate_debug_certificate_expiry(
638                output,
639                CalendarDate {
640                    year: 2053,
641                    month: 12,
642                    day: 16,
643                }
644            )
645            .is_ok()
646        );
647        assert!(validate_debug_certificate_expiry(output, expiration).is_err());
648    }
649
650    #[test]
651    fn unix_day_conversion_handles_epoch_and_leap_days() {
652        assert_eq!(
653            civil_date_from_unix_days(0),
654            CalendarDate {
655                year: 1970,
656                month: 1,
657                day: 1,
658            }
659        );
660        assert_eq!(
661            civil_date_from_unix_days(19_782),
662            CalendarDate {
663                year: 2024,
664                month: 2,
665                day: 29,
666            }
667        );
668    }
669}