Add WASI SSH CLI and in-process Git SSH transport
This commit is contained in:
@@ -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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user