Add WASI SSH CLI and in-process Git SSH transport
This commit is contained in:
@@ -0,0 +1,223 @@
|
||||
//! RFC8731 curve25519-sha256, RFC4253 key derivation, ed25519 host signatures.
|
||||
include!("ssh_negotiation_tests.inc");
|
||||
use super::ssh_wire::*;
|
||||
use sha2::{Digest, Sha256};
|
||||
use signature::Verifier;
|
||||
use std::io::{self, Read, Write};
|
||||
use x25519_dalek::{PublicKey, StaticSecret};
|
||||
|
||||
pub const VERSION: &[u8] = b"SSH-2.0-SausageGit";
|
||||
const KEX: &[u8] = b"curve25519-sha256";
|
||||
const HOST: &[u8] = b"ssh-ed25519";
|
||||
const CIPHER: &[u8] = b"aes128-ctr";
|
||||
const MAC: &[u8] = b"hmac-sha2-256-etm@openssh.com,hmac-sha2-512-etm@openssh.com,hmac-sha2-256";
|
||||
|
||||
pub fn banner<S: Read + Write>(stream: &mut S) -> io::Result<Vec<u8>> {
|
||||
stream.write_all(VERSION)?;
|
||||
stream.write_all(b"\r\n")?;
|
||||
stream.flush()?;
|
||||
// RFC4253 permits pre-banner lines, bounded here to 8KiB.
|
||||
let mut line = Vec::new();
|
||||
for _ in 0..8192 {
|
||||
let mut b = [0];
|
||||
stream.read_exact(&mut b)?;
|
||||
if b[0] == b'\n' {
|
||||
if line.last() == Some(&b'\r') {
|
||||
line.pop();
|
||||
}
|
||||
if line.starts_with(b"SSH-") {
|
||||
if !line.starts_with(b"SSH-2.0-") || line.len() > 253 {
|
||||
return Err(fail("unsupported server version"));
|
||||
}
|
||||
return Ok(line);
|
||||
}
|
||||
line.clear();
|
||||
} else {
|
||||
line.push(b[0]);
|
||||
if line.len() > 1024 {
|
||||
return Err(fail("server banner too long"));
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(fail("server banner missing"))
|
||||
}
|
||||
fn init() -> io::Result<Vec<u8>> {
|
||||
let mut p = vec![20];
|
||||
let mut cookie = [0; 16];
|
||||
random(&mut cookie)?;
|
||||
p.extend(cookie);
|
||||
for list in [
|
||||
KEX, HOST, CIPHER, CIPHER, MAC, MAC, b"none", b"none", b"", b"",
|
||||
] {
|
||||
string(&mut p, list);
|
||||
}
|
||||
p.push(0);
|
||||
uint(&mut p, 0);
|
||||
Ok(p)
|
||||
}
|
||||
fn contains(list: &[u8], name: &[u8]) -> bool {
|
||||
list.split(|b| *b == b',').any(|n| n == name)
|
||||
}
|
||||
fn select<'a>(kind: &str, ours: &'a [u8], theirs: &[u8]) -> io::Result<&'a [u8]> {
|
||||
ours.split(|b| *b == b',')
|
||||
.find(|name| contains(theirs, name))
|
||||
.ok_or_else(|| {
|
||||
fail(&format!(
|
||||
"no matching {kind}; client offered {:?}; server offered {:?}",
|
||||
String::from_utf8_lossy(ours),
|
||||
String::from_utf8_lossy(theirs)
|
||||
))
|
||||
})
|
||||
}
|
||||
#[derive(Debug)]
|
||||
struct Negotiated {
|
||||
send: MacAlgorithm,
|
||||
recv: MacAlgorithm,
|
||||
wrong_guess: bool,
|
||||
}
|
||||
fn negotiate(server: &[u8]) -> io::Result<Negotiated> {
|
||||
let mut d = Decoder(server);
|
||||
if d.byte()? != 20 {
|
||||
return Err(fail("expected KEXINIT"));
|
||||
}
|
||||
d.take(16)?;
|
||||
let kex = d.string()?;
|
||||
let host = d.string()?;
|
||||
select("kex", KEX, kex)?;
|
||||
select("host-key", HOST, host)?;
|
||||
select("cipher client-to-server", CIPHER, d.string()?)?;
|
||||
select("cipher server-to-client", CIPHER, d.string()?)?;
|
||||
let send = MacAlgorithm::from_name(select("MAC client-to-server", MAC, d.string()?)?)?;
|
||||
let recv = MacAlgorithm::from_name(select("MAC server-to-client", MAC, d.string()?)?)?;
|
||||
select("compression client-to-server", b"none", d.string()?)?;
|
||||
select("compression server-to-client", b"none", d.string()?)?;
|
||||
d.string()?;
|
||||
d.string()?;
|
||||
let follows = d.byte()? != 0;
|
||||
d.uint()?;
|
||||
d.end()?;
|
||||
Ok(Negotiated {
|
||||
send,
|
||||
recv,
|
||||
wrong_guess: follows
|
||||
&& (kex.split(|b| *b == b',').next() != Some(KEX)
|
||||
|| host.split(|b| *b == b',').next() != Some(HOST)),
|
||||
})
|
||||
}
|
||||
/// RFC8731 §3.1 reinterprets the raw X25519 output as network-order unsigned K.
|
||||
pub fn mpint(shared: &[u8; 32]) -> Vec<u8> {
|
||||
let mut value: Vec<_> = shared.iter().copied().skip_while(|b| *b == 0).collect();
|
||||
if value.first().is_some_and(|b| b & 128 != 0) {
|
||||
value.insert(0, 0);
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
string(&mut out, &value);
|
||||
out
|
||||
}
|
||||
pub fn derive(k: &[u8], hash: &[u8], letter: u8, session: &[u8]) -> Vec<u8> {
|
||||
let mut h = Sha256::new();
|
||||
h.update(k);
|
||||
h.update(hash);
|
||||
h.update([letter]);
|
||||
h.update(session);
|
||||
h.finalize().to_vec()
|
||||
}
|
||||
/// RFC4253 §7.2 expansion: SHA-512 MAC needs 64 bytes even with SHA-256 KEX.
|
||||
pub fn derive_len(k: &[u8], hash: &[u8], letter: u8, session: &[u8], len: usize) -> Vec<u8> {
|
||||
let mut key = derive(k, hash, letter, session);
|
||||
while key.len() < len {
|
||||
let mut h = Sha256::new();
|
||||
h.update(k);
|
||||
h.update(hash);
|
||||
h.update(&key);
|
||||
key.extend_from_slice(&h.finalize());
|
||||
}
|
||||
key.truncate(len);
|
||||
key
|
||||
}
|
||||
/// Authenticate a key exchange and switch protection in each direction at NEWKEYS.
|
||||
pub fn exchange<S: Read + Write>(
|
||||
packets: &mut Packets<S>,
|
||||
server_version: &[u8],
|
||||
session: &mut Vec<u8>,
|
||||
server_init: Option<Vec<u8>>,
|
||||
mut verify: impl FnMut(&[u8]) -> io::Result<()>,
|
||||
) -> io::Result<()> {
|
||||
let ours = init()?;
|
||||
packets.write(&ours)?;
|
||||
let theirs = match server_init {
|
||||
Some(p) => p,
|
||||
None => packets.meaningful()?,
|
||||
};
|
||||
let negotiated = negotiate(&theirs)?;
|
||||
let mut secret = [0; 32];
|
||||
random(&mut secret)?;
|
||||
let secret = StaticSecret::from(secret);
|
||||
let public = PublicKey::from(&secret);
|
||||
packets.write(&message(30, &[public.as_bytes()]))?;
|
||||
if negotiated.wrong_guess {
|
||||
packets.meaningful()?;
|
||||
}
|
||||
let reply = packets.meaningful()?;
|
||||
let mut d = Decoder(&reply);
|
||||
if d.byte()? != 31 {
|
||||
return Err(fail("expected curve25519 reply"));
|
||||
}
|
||||
let host = d.string()?;
|
||||
let remote = d.string()?;
|
||||
let signature = d.string()?;
|
||||
d.end()?;
|
||||
let remote: [u8; 32] = remote
|
||||
.try_into()
|
||||
.map_err(|_| fail("invalid curve25519 public key"))?;
|
||||
let shared = secret.diffie_hellman(&PublicKey::from(remote));
|
||||
if !shared.was_contributory() {
|
||||
return Err(fail("invalid all-zero shared secret"));
|
||||
}
|
||||
let k = mpint(shared.as_bytes());
|
||||
let mut transcript = Vec::new();
|
||||
for item in [
|
||||
VERSION,
|
||||
server_version,
|
||||
&ours,
|
||||
&theirs,
|
||||
host,
|
||||
public.as_bytes(),
|
||||
&remote,
|
||||
] {
|
||||
string(&mut transcript, item);
|
||||
}
|
||||
transcript.extend_from_slice(&k);
|
||||
let hash = Sha256::digest(&transcript);
|
||||
let key = ssh_key::PublicKey::from_bytes(host).map_err(|_| fail("invalid host public key"))?;
|
||||
if key.algorithm() != ssh_key::Algorithm::Ed25519 {
|
||||
return Err(fail("unsupported host key"));
|
||||
}
|
||||
let signature =
|
||||
ssh_key::Signature::try_from(signature).map_err(|_| fail("invalid host signature"))?;
|
||||
Verifier::verify(&key, &hash, &signature)
|
||||
.map_err(|_| fail("host key exchange signature mismatch"))?;
|
||||
verify(host)?;
|
||||
if session.is_empty() {
|
||||
session.extend_from_slice(&hash);
|
||||
}
|
||||
let send = Protection::with_mac(
|
||||
&derive(&k, &hash, b'C', session)[..16],
|
||||
&derive(&k, &hash, b'A', session)[..16],
|
||||
derive_len(&k, &hash, b'E', session, negotiated.send.key_len()),
|
||||
negotiated.send,
|
||||
)?;
|
||||
let recv = Protection::with_mac(
|
||||
&derive(&k, &hash, b'D', session)[..16],
|
||||
&derive(&k, &hash, b'B', session)[..16],
|
||||
derive_len(&k, &hash, b'F', session, negotiated.recv.key_len()),
|
||||
negotiated.recv,
|
||||
)?;
|
||||
packets.write(&[21])?;
|
||||
packets.send = Some(send);
|
||||
if packets.meaningful()? != [21] {
|
||||
return Err(fail("expected NEWKEYS"));
|
||||
}
|
||||
packets.recv = Some(recv);
|
||||
Ok(())
|
||||
}
|
||||
Reference in New Issue
Block a user