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-repo-policy";
#[cfg(test)]
pub const EARLIER_NAMESPACE: &str = "openmoat-policy";
pub const PACK_NAMESPACE: &str = "openmoat-pack";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Signed {
RepoPolicy,
Pack,
}
impl Signed {
#[must_use]
pub fn namespace(self) -> &'static str {
match self {
Self::RepoPolicy => NAMESPACE,
Self::Pack => PACK_NAMESPACE,
}
}
}
const PROGRAM: &str = "ssh-keygen";
const MAX_PRINCIPAL_LEN: usize = 128;
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 \
`openmoat init` recorded; install OpenSSH, then run `openmoat 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() <= MAX_PRINCIPAL_LEN
&& 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},{PACK_NAMESPACE}\" {kind} {blob}"
))
}
#[must_use]
pub fn same_signer(a: &str, b: &str) -> bool {
signer_key(a).is_some_and(|k| signer_key(b) == Some(k))
}
fn signer_key(line: &str) -> Option<(&str, &str, &str)> {
let words: Vec<&str> = line.split_whitespace().collect();
match words.as_slice() {
&[principal, .., kind, key] => Some((principal, kind, key)),
_ => None,
}
}
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,
what: Signed,
) -> 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, what.namespace()),
data,
signature,
)? {
return Ok(Some(principal));
}
}
Ok(None)
}
fn verify_as(
program: &Path,
signers: &Path,
(principal, namespace): (&str, &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;