use std::fmt;
use std::path::{Path, PathBuf};
use std::process::Command;
use thiserror::Error;
pub const TEST_ONLY_MARKER: &str = "TEST_ONLY_NOT_FOR_PRODUCTION";
pub const DEFAULT_VALIDITY_DAYS: u32 = 3650;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Role {
Pk,
Kek,
Db,
}
impl Role {
#[must_use]
pub fn stem(self) -> &'static str {
match self {
Self::Pk => "PK",
Self::Kek => "KEK",
Self::Db => "db",
}
}
#[must_use]
pub fn subject_cn(self) -> String {
format!("/CN={TEST_ONLY_MARKER} aegis-hwsim {}", self.stem())
}
#[must_use]
pub fn uefi_var_name(self) -> &'static str {
match self {
Self::Pk => "PK",
Self::Kek => "KEK",
Self::Db => "db",
}
}
#[must_use]
pub fn all() -> &'static [Self] {
&[Self::Pk, Self::Kek, Self::Db]
}
}
impl fmt::Display for Role {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.stem())
}
}
#[derive(Debug, Clone)]
pub struct GenerateOptions {
pub out_dir: PathBuf,
pub owner_guid: String,
pub validity_days: u32,
pub timestamp: Option<String>,
}
impl Default for GenerateOptions {
fn default() -> Self {
Self {
out_dir: PathBuf::from("firmware/test-keyring/generated"),
owner_guid: "aeaeaeae-aeae-4aea-aeae-aeaeaeaeaeae".to_string(),
validity_days: DEFAULT_VALIDITY_DAYS,
timestamp: Some("2024-01-01 00:00:00".to_string()),
}
}
}
#[derive(Debug, Error)]
pub enum GenerateError {
#[error("required tool '{tool}' not on PATH ({hint})")]
MissingTool {
tool: &'static str,
hint: &'static str,
},
#[error("output directory {path:?}: {source}")]
OutputDir {
path: PathBuf,
#[source]
source: std::io::Error,
},
#[error("{tool} {args:?} failed: {detail}")]
Subprocess {
tool: &'static str,
args: Vec<String>,
detail: String,
},
#[error("ovmf vars template {path:?} not readable: {source}")]
VarsTemplateMissing {
path: PathBuf,
#[source]
source: std::io::Error,
},
}
#[derive(Debug, Clone)]
pub struct EnrolledVars {
pub vars_out: PathBuf,
}
pub fn generate(opts: &GenerateOptions) -> Result<KeyringPaths, GenerateError> {
require_tool("openssl", "Debian: apt install openssl")?;
require_tool("cert-to-efi-sig-list", "Debian: apt install efitools")?;
require_tool("sign-efi-sig-list", "Debian: apt install efitools")?;
std::fs::create_dir_all(&opts.out_dir).map_err(|source| GenerateError::OutputDir {
path: opts.out_dir.clone(),
source,
})?;
for role in Role::all() {
gen_cert(*role, opts)?;
}
for role in Role::all() {
cert_to_esl(*role, opts)?;
}
sign_esl(Role::Pk, Role::Pk, opts)?;
sign_esl(Role::Kek, Role::Pk, opts)?;
sign_esl(Role::Db, Role::Kek, opts)?;
let guid_path = opts.out_dir.join("GUID");
std::fs::write(&guid_path, opts.owner_guid.as_bytes()).map_err(|source| {
GenerateError::OutputDir {
path: guid_path.clone(),
source,
}
})?;
write_readme(opts)?;
Ok(KeyringPaths::new(&opts.out_dir))
}
pub fn enroll_into_vars(
keyring: &KeyringPaths,
template: &Path,
vars_out: &Path,
owner_guid: &str,
) -> Result<EnrolledVars, GenerateError> {
require_tool("virt-fw-vars", "Debian: apt install python3-virt-firmware")?;
let template_meta =
std::fs::metadata(template).map_err(|source| GenerateError::VarsTemplateMissing {
path: template.to_path_buf(),
source,
})?;
if !template_meta.is_file() {
return Err(GenerateError::VarsTemplateMissing {
path: template.to_path_buf(),
source: std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"ovmf vars template is not a regular file",
),
});
}
let args: Vec<String> = vec![
"-i".into(),
template.display().to_string(),
"--set-pk".into(),
owner_guid.to_string(),
keyring.pk_crt.display().to_string(),
"--add-kek".into(),
owner_guid.to_string(),
keyring.kek_crt.display().to_string(),
"--add-db".into(),
owner_guid.to_string(),
keyring.db_crt.display().to_string(),
"-o".into(),
vars_out.display().to_string(),
];
run("virt-fw-vars", &args)?;
Ok(EnrolledVars {
vars_out: vars_out.to_path_buf(),
})
}
#[derive(Debug, Clone)]
pub struct KeyringPaths {
pub pk_key: PathBuf,
pub pk_crt: PathBuf,
pub pk_esl: PathBuf,
pub pk_auth: PathBuf,
pub kek_key: PathBuf,
pub kek_crt: PathBuf,
pub kek_esl: PathBuf,
pub kek_auth: PathBuf,
pub db_key: PathBuf,
pub db_crt: PathBuf,
pub db_esl: PathBuf,
pub db_auth: PathBuf,
pub guid: PathBuf,
}
impl KeyringPaths {
fn new(dir: &Path) -> Self {
let p = |stem: &str, ext: &str| dir.join(format!("{stem}.{ext}"));
Self {
pk_key: p("PK", "key"),
pk_crt: p("PK", "crt"),
pk_esl: p("PK", "esl"),
pk_auth: p("PK", "auth"),
kek_key: p("KEK", "key"),
kek_crt: p("KEK", "crt"),
kek_esl: p("KEK", "esl"),
kek_auth: p("KEK", "auth"),
db_key: p("db", "key"),
db_crt: p("db", "crt"),
db_esl: p("db", "esl"),
db_auth: p("db", "auth"),
guid: dir.join("GUID"),
}
}
}
fn gen_cert(role: Role, opts: &GenerateOptions) -> Result<(), GenerateError> {
let key = opts.out_dir.join(format!("{}.key", role.stem()));
let crt = opts.out_dir.join(format!("{}.crt", role.stem()));
let subj = role.subject_cn();
let days = opts.validity_days.to_string();
let args: Vec<String> = vec![
"req".into(),
"-new".into(),
"-x509".into(),
"-newkey".into(),
"rsa:2048".into(),
"-nodes".into(),
"-days".into(),
days,
"-subj".into(),
subj,
"-keyout".into(),
key.display().to_string(),
"-out".into(),
crt.display().to_string(),
];
run("openssl", &args)
}
fn cert_to_esl(role: Role, opts: &GenerateOptions) -> Result<(), GenerateError> {
let crt = opts.out_dir.join(format!("{}.crt", role.stem()));
let esl = opts.out_dir.join(format!("{}.esl", role.stem()));
let args: Vec<String> = vec![
"-g".into(),
opts.owner_guid.clone(),
crt.display().to_string(),
esl.display().to_string(),
];
run("cert-to-efi-sig-list", &args)
}
fn sign_esl(role: Role, signer: Role, opts: &GenerateOptions) -> Result<(), GenerateError> {
let esl = opts.out_dir.join(format!("{}.esl", role.stem()));
let auth = opts.out_dir.join(format!("{}.auth", role.stem()));
let signer_key = opts.out_dir.join(format!("{}.key", signer.stem()));
let signer_crt = opts.out_dir.join(format!("{}.crt", signer.stem()));
let mut args: Vec<String> = vec![
"-c".into(),
signer_crt.display().to_string(),
"-k".into(),
signer_key.display().to_string(),
];
if let Some(ts) = &opts.timestamp {
args.push("-t".into());
args.push(ts.clone());
}
args.extend([
role.uefi_var_name().to_string(),
esl.display().to_string(),
auth.display().to_string(),
]);
run("sign-efi-sig-list", &args)
}
fn run(tool: &'static str, args: &[String]) -> Result<(), GenerateError> {
let output = Command::new(tool)
.args(args)
.output()
.map_err(|e| GenerateError::Subprocess {
tool,
args: args.to_vec(),
detail: format!("spawn failed: {e}"),
})?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(GenerateError::Subprocess {
tool,
args: args.to_vec(),
detail: format!("exit {}: {}", output.status, stderr.trim()),
});
}
Ok(())
}
fn require_tool(tool: &'static str, hint: &'static str) -> Result<(), GenerateError> {
if which_on_path(tool).is_some() {
Ok(())
} else {
Err(GenerateError::MissingTool { tool, hint })
}
}
fn which_on_path(binary: &str) -> Option<PathBuf> {
let path = std::env::var("PATH").ok()?;
for dir in path.split(':') {
let candidate = PathBuf::from(dir).join(binary);
if candidate.is_file() {
return Some(candidate);
}
}
None
}
fn write_readme(opts: &GenerateOptions) -> Result<(), GenerateError> {
let readme_path = opts.out_dir.join("README.md");
let body = format!(
"# Generated test keyring\n\
\n\
**DO NOT SHIP.** Every cert here carries `{TEST_ONLY_MARKER}` in\n\
its CN. The release-gate audit (`scripts/audit-no-test-keys.sh`)\n\
refuses to publish any artifact carrying the marker, and\n\
Cargo.toml's `exclude` keeps `firmware/test-keyring/**` out of\n\
the cargo package by default.\n\
\n\
Generated by `aegis-hwsim gen-test-keyring`.\n\
\n\
## Files\n\
\n\
| Stem | Role | Signs | Notes |\n\
|------|------|-------|-------|\n\
| `PK` | Platform Key | itself (UEFI rule) | self-signed root of trust |\n\
| `KEK` | Key Exchange Key | db updates | signed by PK |\n\
| `db` | Authorized signature database | EFI binaries (shim/grub/kernel) | signed by KEK |\n\
\n\
Each stem has 4 artifacts: `<stem>.key` (private key, PEM),\n\
`<stem>.crt` (X.509 cert, PEM), `<stem>.esl` (UEFI signature\n\
list), `<stem>.auth` (signed update payload OVMF accepts via\n\
`SetVariable`).\n\
\n\
## Owner GUID\n\
\n\
`{guid}` — grep an OVMF VARS dump for `aeae` to confirm a VM\n\
is running on this keyring rather than real Microsoft enrollment.\n\
\n\
## Loading into OVMF_VARS\n\
\n\
Deferred to E5.1d. The intended path is `virt-fw-vars`\n\
(python3-virt-firmware): `virt-fw-vars --set-pk PK.auth\n\
--set-kek KEK.auth --add-db db.auth -i template.fd -o\n\
custom-pk.fd`.\n",
guid = opts.owner_guid,
);
std::fs::write(&readme_path, body).map_err(|source| GenerateError::OutputDir {
path: readme_path,
source,
})
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
mod tests {
use super::*;
#[test]
fn role_subject_cn_carries_marker_at_start() {
for role in Role::all() {
let cn = role.subject_cn();
let after_eq = cn.strip_prefix("/CN=").expect("CN prefix");
assert!(
after_eq.starts_with(TEST_ONLY_MARKER),
"role {role} CN must start with marker; got {cn:?}"
);
}
}
#[test]
fn role_uefi_var_name_matches_uefi_spec() {
assert_eq!(Role::Pk.uefi_var_name(), "PK");
assert_eq!(Role::Kek.uefi_var_name(), "KEK");
assert_eq!(Role::Db.uefi_var_name(), "db");
}
#[test]
fn role_all_iterates_in_dependency_order() {
let order: Vec<Role> = Role::all().to_vec();
assert_eq!(order, vec![Role::Pk, Role::Kek, Role::Db]);
}
#[test]
fn keyring_paths_layout_uses_role_stems() {
let dir = PathBuf::from("/tmp/somewhere");
let paths = KeyringPaths::new(&dir);
assert_eq!(paths.pk_key, dir.join("PK.key"));
assert_eq!(paths.kek_crt, dir.join("KEK.crt"));
assert_eq!(paths.db_auth, dir.join("db.auth"));
assert_eq!(paths.guid, dir.join("GUID"));
}
#[test]
fn missing_tool_error_carries_tool_and_hint() {
let e = GenerateError::MissingTool {
tool: "openssl",
hint: "Debian: apt install openssl",
};
let rendered = format!("{e}");
assert!(rendered.contains("openssl"), "got {rendered:?}");
assert!(rendered.contains("apt install openssl"), "got {rendered:?}");
}
#[test]
fn generate_produces_full_keyring_with_marker_cn() {
if which_on_path("openssl").is_none()
|| which_on_path("cert-to-efi-sig-list").is_none()
|| which_on_path("sign-efi-sig-list").is_none()
{
eprintln!("skipping: openssl + efitools required");
return;
}
let tmp = tempfile::tempdir().unwrap();
let opts = GenerateOptions {
out_dir: tmp.path().to_path_buf(),
validity_days: 30,
..Default::default()
};
let paths = generate(&opts).expect("generate must succeed when tools present");
for f in [
&paths.pk_key,
&paths.pk_crt,
&paths.pk_esl,
&paths.pk_auth,
&paths.kek_key,
&paths.kek_crt,
&paths.kek_esl,
&paths.kek_auth,
&paths.db_key,
&paths.db_crt,
&paths.db_esl,
&paths.db_auth,
&paths.guid,
] {
assert!(f.is_file(), "missing generated file: {}", f.display());
}
let out = Command::new("openssl")
.args([
"x509",
"-in",
&paths.pk_crt.display().to_string(),
"-noout",
"-subject",
])
.output()
.expect("openssl x509 -subject must run");
let subject = String::from_utf8_lossy(&out.stdout);
assert!(
subject.contains(TEST_ONLY_MARKER),
"PK cert subject {subject:?} must contain {TEST_ONLY_MARKER}"
);
}
#[test]
fn enroll_rejects_missing_template() {
let paths = KeyringPaths::new(&PathBuf::from("/tmp/unused"));
let result = enroll_into_vars(
&paths,
&PathBuf::from("/no/such/template.fd"),
&PathBuf::from("/tmp/unused-out.fd"),
"aeaeaeae-aeae-4aea-aeae-aeaeaeaeaeae",
);
match result {
Err(GenerateError::MissingTool { tool, .. }) => {
assert_eq!(tool, "virt-fw-vars");
}
Err(GenerateError::VarsTemplateMissing { path, .. }) => {
assert_eq!(path, PathBuf::from("/no/such/template.fd"));
}
other => panic!("expected MissingTool or VarsTemplateMissing, got {other:?}"),
}
}
#[test]
fn enroll_produces_loadable_vars_file() {
if which_on_path("openssl").is_none()
|| which_on_path("cert-to-efi-sig-list").is_none()
|| which_on_path("sign-efi-sig-list").is_none()
|| which_on_path("virt-fw-vars").is_none()
{
eprintln!("skipping: full E5 toolchain (openssl + efitools + virt-fw-vars) required");
return;
}
let template = PathBuf::from("/usr/share/OVMF/OVMF_VARS_4M.fd");
if !template.is_file() {
eprintln!("skipping: /usr/share/OVMF/OVMF_VARS_4M.fd not present (apt install ovmf)");
return;
}
let tmp = tempfile::tempdir().unwrap();
let opts = GenerateOptions {
out_dir: tmp.path().to_path_buf(),
validity_days: 30,
..Default::default()
};
let paths = generate(&opts).expect("generate must succeed");
let vars_out = tmp.path().join("custom-pk.fd");
let enrolled = enroll_into_vars(&paths, &template, &vars_out, &opts.owner_guid)
.expect("enroll_into_vars must succeed when toolchain is present");
assert_eq!(enrolled.vars_out, vars_out);
assert!(vars_out.is_file(), "vars_out file must exist");
let template_size = std::fs::metadata(&template).unwrap().len();
let out_size = std::fs::metadata(&vars_out).unwrap().len();
assert_eq!(
out_size, template_size,
"enrolled VARS file must match template size (got {out_size}, expected {template_size})"
);
}
}