use std::io::Write as _;
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use anyhow::{Context as _, Result, bail};
use crate::home::Home;
use crate::integrity::Lock;
pub const NAMESPACE: &str = "openmoat-policy";
const PROGRAM: &str = "ssh-keygen";
pub fn locate(home: &Home) -> Option<PathBuf> {
crate::environment::program(home, PROGRAM)
}
pub fn require(home: &Home) -> Result<PathBuf> {
locate(home).with_context(|| {
format!(
"signed policy needs OpenSSH's `{PROGRAM}`, and none was found on the search path \
`moat init` recorded; install OpenSSH, then run `moat init` again"
)
})
}
pub fn trusted_signers(home: &Home) -> Option<PathBuf> {
let path = home.signers_path();
let lock = Lock::load(&home.lock_path()).ok()?;
(path.is_file() && lock.pins(&path)).then_some(path)
}
#[must_use]
pub fn principals(signers: &str) -> Vec<String> {
let mut out: Vec<String> = Vec::new();
for line in signers.lines().map(str::trim) {
if line.is_empty() || line.starts_with('#') {
continue;
}
if let Some(name) = line.split_whitespace().next()
&& !out.iter().any(|n| n == name)
{
out.push(name.to_owned());
}
}
out
}
pub fn signer_line(principal: &str, key: &str) -> Result<String> {
let valid = !principal.is_empty()
&& principal.len() <= 128
&& principal
.chars()
.all(|c| c.is_ascii_alphanumeric() || "@._+-".contains(c));
if !valid {
bail!(
"`{principal}` is not a signer name: use letters, digits and `@._+-` \
(an email address works)"
);
}
let line = key
.lines()
.map(str::trim)
.find(|l| !l.is_empty() && !l.starts_with('#'))
.context("the key file is empty")?;
let mut words = line.split_whitespace();
let (Some(kind), Some(blob)) = (words.next(), words.next()) else {
bail!("not an OpenSSH public key: expected `<type> <base64> [comment]`");
};
let known = ["ssh-", "ecdsa-sha2-", "sk-ssh-", "sk-ecdsa-"]
.iter()
.any(|p| kind.starts_with(p));
let base64 = blob
.chars()
.all(|c| c.is_ascii_alphanumeric() || "+/=".contains(c));
if !known || !base64 {
bail!("not an OpenSSH public key: `{kind}` is not a key type ssh-keygen signs with");
}
Ok(format!(
"{principal} namespaces=\"{NAMESPACE}\" {kind} {blob}"
))
}
pub fn fingerprint(program: &Path, key: &Path) -> Result<String> {
let out = Command::new(program)
.arg("-l")
.arg("-f")
.arg(key)
.stdin(Stdio::null())
.output()
.with_context(|| format!("running {}", program.display()))?;
if !out.status.success() {
bail!(
"{} is not a public key ssh-keygen reads: {}",
key.display(),
String::from_utf8_lossy(&out.stderr).trim()
);
}
Ok(String::from_utf8_lossy(&out.stdout).trim().to_owned())
}
pub fn verify(
program: &Path,
signers: &Path,
data: &[u8],
signature: &Path,
) -> Result<Option<String>> {
if !signature.is_file() {
bail!("signature {} is not a regular file", signature.display());
}
let text = std::fs::read_to_string(signers)
.with_context(|| format!("reading {}", signers.display()))?;
for principal in principals(&text) {
if verify_as(program, signers, &principal, data, signature)? {
return Ok(Some(principal));
}
}
Ok(None)
}
fn verify_as(
program: &Path,
signers: &Path,
principal: &str,
data: &[u8],
signature: &Path,
) -> Result<bool> {
let mut child = Command::new(program)
.args(["-Y", "verify", "-n", NAMESPACE, "-I", principal, "-f"])
.arg(signers)
.arg("-s")
.arg(signature)
.stdin(Stdio::piped())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.with_context(|| format!("running {}", program.display()))?;
if let Some(mut stdin) = child.stdin.take() {
let _ = stdin.write_all(data);
}
let status = child
.wait()
.with_context(|| format!("waiting for {}", program.display()))?;
Ok(status.success())
}
#[cfg(test)]
mod tests;