Files
ai-app/scripts/xtask/src/keystore.rs
T
irisandClaude Opus 5 4ccfda6b8e Delete the decisions and design logs; scripts, rigs and xtask off the root
Iris: "remove both decisions and iris.md. I've decided to instead make
decisions when planning with agents rather than after they do things, and
they're both too long for me to wanna read, + don't cover all the
decisions I'll wanna make about the code anyways. I'll just naturally run
into things for now. Todo is important though."

So docs/DECISIONS.md (850 lines) and docs/IRIS.md (1,986) are gone, and
AGENTS.md now says not to start another: raise a choice while planning it
with her, otherwise decide it and put the reasoning at the code it
governs. The TODO lists stay. docs/SUBAGENTS_DECISIONS.md went with them
-- same artefact, same reasoning, and she did not name it, so its six
decisions were folded into docs/SUBAGENTS.md rather than deleted.

Deleting the logs left ~30 citations dangling in code comments and docs.
Each states its reason inline and cited the file only for provenance, so
they now read "decided 2026-09-07" or name the module doc that carries
the reasoning.

The root had six things that were not a program or a document. Moved,
per "I only meant top level sh files":

  run-tests.sh, test-wg-tunnel.sh, wg-setup-host.sh  -> scripts/
  rigs/                                              -> scripts/rigs/
  xtask/                                             -> scripts/xtask/

A project's own scripts stayed with the project: app/*.sh, app-rust/*.sh,
iris/*.sh and server/enroll-link.sh did not move.

`target/` at the root is deleted and cannot come back: there was never a
workspace there, and the 29 MB was only xtask's scratch space, now in
scripts/xtask/target/. `cargo xtask apk` still runs from the repo root
and now publishes to scripts/build/outputs/apk/<mode>/ -- one directory
deep, because that is what Dev Updater's `*/build/outputs/apk/*/*.apk`
discovery pattern needs, and scripts/xtask/build would have been two.

Verified: ./scripts/run-tests.sh and `cd iris && cargo test` green, clippy
and fmt clean everywhere, `cargo xtask apk debug --abi x86_64` builds and
signs an APK carrying lib/x86_64/libai_app.so at the new publish path, and
the repo root is now eleven entries with no build output among them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 00:16:24 -04:00

255 lines
8.7 KiB
Rust

//! The signing key. Mirrors `app/build-apk.sh`'s exact logic for the
//! release key -- same env vars, same path, same generation recipe -- so
//! the two tools sign with the *same* key and their outputs can
//! `adb install -r` over each other. That is the whole point of E5's pass
//! condition: the key has to be identical, not merely present.
use std::path::PathBuf;
use std::process::Command;
use crate::Fail;
pub struct Signer {
pub keystore: PathBuf,
pub password: String,
pub alias: String,
}
/// The release key at `$AI_APP_KEYSTORE` or
/// `$XDG_CONFIG_HOME/ai-app/release.jks` (`~/.config/ai-app/release.jks` by
/// default) -- generated with `keytool` if it doesn't exist yet, exactly as
/// `build-apk.sh` does, so either tool can run first on a fresh machine.
pub fn release_signer() -> Result<Signer, Fail> {
let keystore = std::env::var_os("AI_APP_KEYSTORE")
.map(PathBuf::from)
.unwrap_or_else(|| {
let config_home = std::env::var_os("XDG_CONFIG_HOME")
.map(PathBuf::from)
.unwrap_or_else(|| {
PathBuf::from(std::env::var_os("HOME").unwrap()).join(".config")
});
config_home.join("ai-app").join("release.jks")
});
let alias = "ai-app".to_string();
let password_file = keystore.with_extension("jks.password");
if keystore.is_file() {
let password = std::fs::read_to_string(&password_file)
.map_err(|e| {
Fail::new(
"release key exists but its password file is unreadable",
&format!("{}: {e}", password_file.display()),
"restore the password file, or delete both and let this regenerate them",
)
})?
.trim()
.to_string();
return Ok(Signer {
keystore,
password,
alias,
});
}
let keytool = which_keytool()?;
if let Some(parent) = keystore.parent() {
std::fs::create_dir_all(parent).map_err(|e| {
Fail::new(
"could not create the keystore's directory",
&format!("{}: {e}", parent.display()),
"check permissions on that path",
)
})?;
}
let password = random_password();
write_owner_only(&password_file, format!("{password}\n").as_bytes())?;
let status = Command::new(&keytool)
.args(["-genkeypair", "-keystore"])
.arg(&keystore)
.args([
"-alias",
&alias,
"-keyalg",
"RSA",
"-keysize",
"2048",
"-validity",
"10000",
])
.args(["-storepass", &password, "-keypass", &password])
.args(["-dname", "CN=ai-app"])
.status()
.map_err(|e| {
Fail::new(
"failed to run keytool",
&format!("{}: {e}", keytool.display()),
"set JAVA_HOME to the JDK Gradle uses",
)
})?;
if !status.success() {
return Err(Fail::new(
"keytool exited with an error while generating the release key",
&format!("status: {status}"),
"check the keytool output above",
));
}
// Owner-only, matching build-apk.sh -- this key is what the phone
// recognises the app by, so it never goes in the repo and it stays
// unreadable to anything else on this machine.
set_owner_only(&keystore)?;
Ok(Signer {
keystore,
password,
alias,
})
}
/// The conventional Android debug key (`~/.android/debug.keystore`,
/// well-known password `android`, alias `androiddebugkey`) -- generated on
/// first use exactly the way Android Studio and Gradle's own debug signing
/// config do, so a `--debug` build here needs no setup and never touches
/// the real release key.
pub fn debug_signer() -> Result<Signer, Fail> {
let home = PathBuf::from(std::env::var_os("HOME").ok_or_else(|| {
Fail::new(
"no $HOME set",
"the debug keystore lives under ~/.android",
"set $HOME",
)
})?);
let keystore = home.join(".android").join("debug.keystore");
let alias = "androiddebugkey".to_string();
let password = "android".to_string();
if !keystore.is_file() {
let keytool = which_keytool()?;
std::fs::create_dir_all(keystore.parent().unwrap()).map_err(|e| {
Fail::new(
"could not create ~/.android",
&format!("{e}"),
"check permissions on your home directory",
)
})?;
let status = Command::new(&keytool)
.args(["-genkeypair", "-keystore"])
.arg(&keystore)
.args([
"-alias",
&alias,
"-keyalg",
"RSA",
"-keysize",
"2048",
"-validity",
"10000",
])
.args(["-storepass", &password, "-keypass", &password])
.args(["-dname", "CN=Android Debug,O=Android,C=US"])
.status()
.map_err(|e| {
Fail::new(
"failed to run keytool",
&format!("{}: {e}", keytool.display()),
"set JAVA_HOME to the JDK Gradle uses",
)
})?;
if !status.success() {
return Err(Fail::new(
"keytool exited with an error while generating the debug key",
&format!("status: {status}"),
"check the keytool output above",
));
}
}
Ok(Signer {
keystore,
password,
alias,
})
}
fn which_keytool() -> Result<PathBuf, Fail> {
if let Some(java_home) = std::env::var_os("JAVA_HOME") {
let candidate = PathBuf::from(java_home).join("bin").join("keytool");
if candidate.is_file() {
return Ok(candidate);
}
}
if Command::new("keytool").arg("-help").output().is_ok() {
return Ok(PathBuf::from("keytool"));
}
Err(Fail::new(
"no keytool available to generate the release key",
"checked $JAVA_HOME/bin/keytool and keytool on PATH",
"set JAVA_HOME to the JDK Gradle uses, or set AI_APP_KEYSTORE to an existing key",
))
}
fn random_password() -> String {
// No dependency on `rand`: /dev/urandom is what build-apk.sh's `head -c
// 24 /dev/urandom | base64` reads too, so this reproduces exactly the
// same recipe without shelling out to head/base64/tr for it.
let mut bytes = [0u8; 24];
std::fs::File::open("/dev/urandom")
.and_then(|mut f| std::io::Read::read_exact(&mut f, &mut bytes))
.expect("/dev/urandom must be readable to generate a signing key password");
base64_no_padding(&bytes)
}
fn base64_no_padding(bytes: &[u8]) -> String {
const ALPHABET: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
let mut out = String::new();
for chunk in bytes.chunks(3) {
let b0 = chunk[0] as u32;
let b1 = *chunk.get(1).unwrap_or(&0) as u32;
let b2 = *chunk.get(2).unwrap_or(&0) as u32;
let n = (b0 << 16) | (b1 << 8) | b2;
out.push(ALPHABET[(n >> 18 & 0x3f) as usize] as char);
out.push(ALPHABET[(n >> 12 & 0x3f) as usize] as char);
if chunk.len() > 1 {
out.push(ALPHABET[(n >> 6 & 0x3f) as usize] as char);
}
if chunk.len() > 2 {
out.push(ALPHABET[(n & 0x3f) as usize] as char);
}
}
// build-apk.sh strips '/', '+' and '=' from its password (tr -d
// '/+='), so the value never needs quoting when it is passed as a
// command-line argument later.
out.retain(|c| c != '/' && c != '+' && c != '=');
out
}
#[cfg(unix)]
fn write_owner_only(path: &std::path::Path, contents: &[u8]) -> Result<(), Fail> {
use std::os::unix::fs::OpenOptionsExt;
std::fs::OpenOptions::new()
.write(true)
.create(true)
.truncate(true)
.mode(0o600)
.open(path)
.and_then(|mut f| std::io::Write::write_all(&mut f, contents))
.map_err(|e| {
Fail::new(
"could not write the keystore password file",
&format!("{}: {e}", path.display()),
"check permissions on that directory",
)
})
}
#[cfg(unix)]
fn set_owner_only(path: &std::path::Path) -> Result<(), Fail> {
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)).map_err(|e| {
Fail::new(
"could not restrict the keystore's permissions",
&format!("{}: {e}", path.display()),
"chmod 600 it by hand",
)
})
}