diff --git a/packages/react-native-uniffi-russh/rust/Cargo.lock b/packages/react-native-uniffi-russh/rust/Cargo.lock index aa44fec..254dc7c 100644 --- a/packages/react-native-uniffi-russh/rust/Cargo.lock +++ b/packages/react-native-uniffi-russh/rust/Cargo.lock @@ -136,6 +136,12 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + [[package]] name = "base64ct" version = "1.6.0" @@ -2016,6 +2022,7 @@ name = "uniffi-russh" version = "0.1.0" dependencies = [ "async-trait", + "base64", "bytes", "futures", "once_cell", diff --git a/packages/react-native-uniffi-russh/rust/uniffi-russh/Cargo.toml b/packages/react-native-uniffi-russh/rust/uniffi-russh/Cargo.toml index e6d4553..edaebb6 100644 --- a/packages/react-native-uniffi-russh/rust/uniffi-russh/Cargo.toml +++ b/packages/react-native-uniffi-russh/rust/uniffi-russh/Cargo.toml @@ -28,6 +28,7 @@ tokio = { version = "1.47.1", features = ["rt-multi-thread", "macros", "time", " # frequently useful and pulled in by transitive deps). bytes = "1.10.1" futures = "0.3.31" +base64 = "0.22" # SSH client and keys. `russh` is the client; `russh-keys` handles key types, # generation, and OpenSSH (PEM) encoding/decoding. diff --git a/packages/react-native-uniffi-russh/rust/uniffi-russh/src/lib.rs b/packages/react-native-uniffi-russh/rust/uniffi-russh/src/lib.rs index f17e5df..dc816a4 100644 --- a/packages/react-native-uniffi-russh/rust/uniffi-russh/src/lib.rs +++ b/packages/react-native-uniffi-russh/rust/uniffi-russh/src/lib.rs @@ -20,6 +20,7 @@ use russh_keys::{Algorithm, EcdsaCurve}; use russh::keys::{PrivateKey, PrivateKeyWithHashAlg}; use russh_keys::ssh_key::{self, LineEnding}; use bytes::Bytes; +use base64::Engine as _; uniffi::setup_scaffolding!(); @@ -790,7 +791,9 @@ pub async fn connect(options: ConnectOptions) -> Result, SshE #[uniffi::export] pub fn validate_private_key(private_key_content: String) -> Result { - let parsed: russh_keys::PrivateKey = russh_keys::PrivateKey::from_openssh(&private_key_content)?; + // Normalize seed-only ed25519 keys (no-op for already-normal keys), then validate. + let normalized = normalize_openssh_ed25519_seed_key(&private_key_content); + let parsed: russh_keys::PrivateKey = russh_keys::PrivateKey::from_openssh(&normalized)?; Ok(parsed.to_openssh(LineEnding::LF)?.to_string()) } @@ -899,6 +902,147 @@ impl From for SshError { } +// Best-effort fix for OpenSSH ed25519 keys that store only a 32-byte seed in +// the private section (instead of 64 bytes consisting of seed || public). +// If the input matches an unencrypted OpenSSH ed25519 key with a 32-byte +// private field, this function returns a normalized PEM string with the +// correct 64-byte private field (seed || public). Otherwise, returns None. +fn normalize_openssh_ed25519_seed_key(input: &str) -> String { + // If it already parses, return as-is (already normal) + if russh_keys::PrivateKey::from_openssh(input).is_ok() { + return input.to_string(); + } + const HEADER: &str = "-----BEGIN OPENSSH PRIVATE KEY-----"; + const FOOTER: &str = "-----END OPENSSH PRIVATE KEY-----"; + // Extract base64 payload between header and footer + let (start, end) = match (input.find(HEADER), input.find(FOOTER)) { + (Some(h), Some(f)) => (h + HEADER.len(), f), + _ => return input.to_string(), + }; + let body = &input[start..end]; + let b64: String = body + .lines() + .map(str::trim) + .filter(|l| !l.is_empty()) + .collect::>() + .join(""); + + let raw = match base64::engine::general_purpose::STANDARD.decode(b64.as_bytes()) { + Ok(v) => v, + Err(_) => return input.to_string(), + }; + + // Parse OpenSSH binary format: "openssh-key-v1\0" then strings + let mut idx = 0usize; + let magic = b"openssh-key-v1\0"; + if raw.len() < magic.len() || &raw[..magic.len()] != magic { return input.to_string(); } + idx += magic.len(); + + fn read_u32(buf: &[u8], idx: &mut usize) -> Option { + if *idx + 4 > buf.len() { return None; } + let v = u32::from_be_bytes([buf[*idx], buf[*idx + 1], buf[*idx + 2], buf[*idx + 3]]); + *idx += 4; + Some(v) + } + fn read_string<'a>(buf: &'a [u8], idx: &mut usize) -> Option<&'a [u8]> { + let n = read_u32(buf, idx)? as usize; + if *idx + n > buf.len() { return None; } + let s = &buf[*idx..*idx + n]; + *idx += n; + Some(s) + } + fn write_u32(out: &mut Vec, v: u32) { out.extend_from_slice(&v.to_be_bytes()); } + fn write_string(out: &mut Vec, s: &[u8]) { + write_u32(out, s.len() as u32); + out.extend_from_slice(s); + } + + let ciphername = match read_string(&raw, &mut idx) { Some(v) => v, None => return input.to_string() }; + let kdfname = match read_string(&raw, &mut idx) { Some(v) => v, None => return input.to_string() }; + let kdfopts = match read_string(&raw, &mut idx) { Some(v) => v, None => return input.to_string() }; + // Only handle unencrypted keys + if ciphername != b"none" || kdfname != b"none" { return input.to_string(); } + // kdfopts should be empty for "none", but if not, proceed and preserve it. + + let nkeys = match read_u32(&raw, &mut idx) { Some(v) => v as usize, None => return input.to_string() }; + let mut pubkeys: Vec<&[u8]> = Vec::with_capacity(nkeys); + for _ in 0..nkeys { + let pk = match read_string(&raw, &mut idx) { Some(v) => v, None => return input.to_string() }; + pubkeys.push(pk); + } + let private_block = match read_string(&raw, &mut idx) { Some(v) => v, None => return input.to_string() }; + + // Parse private block + let mut pidx = 0usize; + let check1 = match read_u32(private_block, &mut pidx) { Some(v) => v, None => return input.to_string() }; + let check2 = match read_u32(private_block, &mut pidx) { Some(v) => v, None => return input.to_string() }; + if check1 != check2 { return input.to_string(); } + + let alg = match read_string(private_block, &mut pidx) { Some(v) => v, None => return input.to_string() }; + if alg != b"ssh-ed25519" { return input.to_string(); } + let pubkey = match read_string(private_block, &mut pidx) { Some(v) => v, None => return input.to_string() }; + let privkey = match read_string(private_block, &mut pidx) { Some(v) => v, None => return input.to_string() }; + let comment = match read_string(private_block, &mut pidx) { Some(v) => v, None => return input.to_string() }; + // Remaining bytes are padding; we will recompute + let _padding = &private_block[pidx..]; + + // Only fix the specific case where privkey is 32-byte seed and pubkey is 32 bytes + let fixed_priv: Vec = if privkey.len() == 32 && pubkey.len() == 32 { + let mut v = Vec::with_capacity(64); + v.extend_from_slice(privkey); + v.extend_from_slice(pubkey); + v + } else { + // Keep original private if already 64 or any other length, but re-encode + privkey.to_vec() + }; + + // Rebuild private block with proper padding to 8-byte boundary + let mut new_priv_block = Vec::new(); + write_u32(&mut new_priv_block, check1); + write_u32(&mut new_priv_block, check2); + write_string(&mut new_priv_block, alg); + write_string(&mut new_priv_block, pubkey); + write_string(&mut new_priv_block, &fixed_priv); + write_string(&mut new_priv_block, comment); + // padding bytes 1..n to reach 8-byte alignment + let block_size = 8usize; + let rem = new_priv_block.len() % block_size; + let mut pad_len = if rem == 0 { 0 } else { block_size - rem }; + // Ensure there is at least one byte of padding, mirroring OpenSSH behavior + if pad_len == 0 { pad_len = block_size; } + for i in 1..=pad_len { new_priv_block.push(i as u8); } + + // Rebuild outer container + let mut out = Vec::new(); + out.extend_from_slice(magic); + write_string(&mut out, ciphername); + write_string(&mut out, kdfname); + write_string(&mut out, kdfopts); + write_u32(&mut out, nkeys as u32); + for pk in pubkeys { write_string(&mut out, pk); } + write_string(&mut out, &new_priv_block); + + // Base64 encode and wrap with header/footer + let b64 = base64::engine::general_purpose::STANDARD.encode(out); + // Wrap lines at 70 chars (OpenSSH uses 70) + let mut wrapped = String::new(); + let mut i = 0usize; + while i < b64.len() { + let end = (i + 70).min(b64.len()); + wrapped.push_str(&b64[i..end]); + wrapped.push('\n'); + i = end; + } + let mut pem = String::new(); + pem.push_str(HEADER); + pem.push('\n'); + pem.push_str(&wrapped); + pem.push_str(FOOTER); + pem.push('\n'); + pem +} + // ---------- Unit Tests ---------- #[cfg(test)] mod tests {