Add WASI SSH CLI and in-process Git SSH transport

This commit is contained in:
2026-11-06 19:42:00 +00:00
parent 608d0c3fc1
commit 99a8e3a8be
29 changed files with 3546 additions and 30 deletions
+239
View File
@@ -0,0 +1,239 @@
//! OpenSSH known_hosts policy: explicit keys, hashed names, patterns and revocations.
use super::ssh_wire::fail;
use base64::{Engine, engine::general_purpose::STANDARD};
use hmac::{Hmac, Mac};
use std::io::{self, Write};
fn pattern(pattern: &[u8], name: &[u8]) -> bool {
// Linear glob matcher with a single backtracking '*' position, no recursion.
let (mut p, mut n, mut star, mut retry) = (0, 0, None, 0);
while n < name.len() {
if p < pattern.len() && (pattern[p] == b'?' || pattern[p].eq_ignore_ascii_case(&name[n])) {
p += 1;
n += 1;
} else if p < pattern.len() && pattern[p] == b'*' {
star = Some(p);
p += 1;
retry = n;
} else if let Some(s) = star {
retry += 1;
n = retry;
p = s + 1;
} else {
return false;
}
}
while p < pattern.len() && pattern[p] == b'*' {
p += 1;
}
p == pattern.len()
}
fn matches(names: &str, host: &str) -> bool {
if names.starts_with('|') {
let parts: Vec<_> = names.split('|').collect();
if parts.len() != 4 || parts[1] != "1" {
return false;
}
let (Ok(salt), Ok(hash)) = (STANDARD.decode(parts[2]), STANDARD.decode(parts[3])) else {
return false;
};
let Ok(mut mac) = <Hmac<sha1::Sha1> as Mac>::new_from_slice(&salt) else {
return false;
};
mac.update(host.as_bytes());
return mac.verify_slice(&hash).is_ok();
}
let mut found = false;
for part in names.split(',') {
if let Some(negated) = part.strip_prefix('!') {
if pattern(negated.as_bytes(), host.as_bytes()) {
return false;
}
} else if pattern(part.as_bytes(), host.as_bytes()) {
found = true;
}
}
found
}
/// Returns false for an unknown host; mismatched/revoked keys are always errors.
pub fn check(text: &str, host: &str, key: &[u8]) -> io::Result<bool> {
let mut seen = false;
let mut matched = false;
for line in text.lines() {
let mut fields = line.split_whitespace();
let Some(first) = fields.next() else {
continue;
};
if first.starts_with('#') {
continue;
}
let (marker, names) = if first.starts_with('@') {
(Some(first), fields.next().unwrap_or(""))
} else {
(None, first)
};
if !matches(names, host) {
continue;
}
let algorithm = fields
.next()
.ok_or_else(|| fail("malformed known_hosts entry"))?;
let encoded = fields
.next()
.ok_or_else(|| fail("malformed known_hosts entry"))?;
let bytes = STANDARD
.decode(encoded)
.map_err(|_| fail("malformed known_hosts key"))?;
if marker == Some("@cert-authority") {
continue;
} // certificates not negotiated
if marker.is_some() && marker != Some("@revoked") {
return Err(fail("unsupported known_hosts marker"));
}
if marker == Some("@revoked") {
if bytes == key {
return Err(fail(
"Host key verification failed: revoked key in ~/.ssh/known_hosts",
));
}
continue;
}
// Other algorithms for this host are not a changed ed25519 key.
if algorithm == "ssh-ed25519" {
seen = true;
if bytes == key {
matched = true;
}
}
}
if matched {
Ok(true)
} else if seen {
Err(fail(
"WARNING: REMOTE HOST IDENTIFICATION HAS CHANGED! Host key verification failed.",
))
} else {
Ok(false)
}
}
/// No global lock: network operations in unrelated repositories must stay independent.
/// Accept-new uses append mode, never read/modify/replace. Racing checks may append
/// identical entries; these are harmless. External trust-file edits must still be
/// coordinated by the person (as with any concurrent known_hosts editor).
pub fn verify(host: &str, port: u16, key: &[u8], accept_new: bool) -> io::Result<()> {
let host = if port == 22 {
host.to_owned()
} else {
format!("[{host}]:{port}")
};
let folder = std::path::PathBuf::from(std::env::var_os("HOME").unwrap_or_else(|| "/".into()))
.join(".ssh");
std::fs::create_dir_all(&folder)?;
let path = folder.join("known_hosts");
let text = match std::fs::read_to_string(&path) {
Ok(s) => s,
Err(e) if e.kind() == io::ErrorKind::NotFound => String::new(),
Err(_) => return Err(fail("could not read ~/.ssh/known_hosts")),
};
if check(&text, &host, key)? {
return Ok(());
}
if !accept_new {
use sha2::Digest;
let fingerprint =
base64::engine::general_purpose::STANDARD_NO_PAD.encode(sha2::Sha256::digest(key));
return Err(fail(&format!(
"No ED25519 host key is known for {host} and you have requested strict checking. Host key verification failed. SHA256:{fingerprint}"
)));
}
let prefix = if text.is_empty() || text.ends_with('\n') {
""
} else {
"\n"
};
let line = format!("{prefix}{host} ssh-ed25519 {}\n", STANDARD.encode(key));
let mut file = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(&path)?;
file.write_all(line.as_bytes())?;
file.sync_all()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn hosts_plain_hashed_changed_revoked() {
let key = b"synthetic-host-key";
let encoded = STANDARD.encode(key);
assert!(
check(
&format!("*.example,!bad.example ssh-ed25519 {encoded}"),
"git.example",
key
)
.unwrap()
);
assert!(
!check(
&format!("*.example,!bad.example ssh-ed25519 {encoded}"),
"bad.example",
key
)
.unwrap()
);
assert!(
check(
&format!("git.example ssh-ed25519 {encoded}"),
"git.example",
b"changed"
)
.is_err()
);
assert!(
check(
&format!(
"git.example ssh-ed25519 {encoded}\n@revoked git.example ssh-ed25519 {encoded}"
),
"git.example",
key
)
.is_err()
);
let salt = b"01234567890123456789";
let mut mac = <Hmac<sha1::Sha1> as Mac>::new_from_slice(salt).unwrap();
mac.update(b"[git.example]:2222");
let names = format!(
"|1|{}|{}",
STANDARD.encode(salt),
STANDARD.encode(mac.finalize().into_bytes())
);
assert!(
check(
&format!("{names} ssh-ed25519 {encoded}"),
"[git.example]:2222",
key
)
.unwrap()
);
assert!(
!check(
&format!("{names} ssh-ed25519 {encoded}"),
"git.example",
key
)
.unwrap()
);
}
#[test]
fn duplicate_appends_preserve_trust_policy() {
// Model two accept-new callers that both read an unknown host before appending.
let key = b"synthetic-host-key";
let line = format!("git.example ssh-ed25519 {}\n", STANDARD.encode(key));
let duplicated = line.repeat(2);
assert!(check(&duplicated, "git.example", key).unwrap());
assert!(check(&duplicated, "git.example", b"changed").is_err());
let revoked = format!("{duplicated}@revoked {line}");
assert!(check(&revoked, "git.example", key).is_err());
}
}