mirror of
https://github.com/EthanShoeDev/fressh.git
synced 2026-01-11 06:12:51 +00:00
ai generated normalize
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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<Arc<SshConnection>, SshE
|
||||
|
||||
#[uniffi::export]
|
||||
pub fn validate_private_key(private_key_content: String) -> Result<String, SshError> {
|
||||
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<russh::client::AuthResult> 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::<Vec<_>>()
|
||||
.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<u32> {
|
||||
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<u8>, v: u32) { out.extend_from_slice(&v.to_be_bytes()); }
|
||||
fn write_string(out: &mut Vec<u8>, 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<u8> = 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 {
|
||||
|
||||
Reference in New Issue
Block a user