rdsh 0.12.0

Rust port/shim for dsh: fast launcher, token saver, zero behavior breakage via passthrough
//! Per-process bearer-token handoff without putting the credential in logs or argv.

use anyhow::{bail, Context, Result};
use std::fs::{self, File, OpenOptions};
use std::io::Write;
use std::path::{Path, PathBuf};

pub struct TokenHandoff {
    path: PathBuf,
}

impl TokenHandoff {
    pub fn create(token: &str, locale: crate::locale::Locale) -> Result<Self> {
        Self::create_file("rdsh-token", "txt", token, locale)
    }

    /// A protected local page that forwards the browser to `base` with the
    /// token in the fragment. Opening this file keeps the credential out of
    /// the browser-launch argv (only the path is passed) while sparing the
    /// person from pasting the handoff file by hand.
    pub fn launcher(base: &str, token: &str, locale: crate::locale::Locale) -> Result<Self> {
        let target = format!("{base}#key={token}");
        let page = format!(
            "<!doctype html><meta charset=\"utf-8\"><meta name=\"referrer\" content=\"no-referrer\">\
<title>rdsh</title><script>location.replace({target:?});</script>\
<p><a href=\"{target}\">rdsh</a></p>"
        );
        Self::create_file("rdsh-open", "html", &page, locale)
    }

    fn create_file(
        prefix: &str,
        extension: &str,
        content: &str,
        locale: crate::locale::Locale,
    ) -> Result<Self> {
        let dir = std::env::temp_dir();
        for _ in 0..8 {
            // The name is independently random; the bearer credential is only file content.
            let name = format!(
                "{prefix}-{}.{extension}",
                crate::local_http::random_token()?
            );
            let path = dir.join(name);
            let mut file = match open_restricted(&path, locale) {
                Ok(file) => file,
                Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => continue,
                Err(error) => {
                    return Err(error).with_context(|| {
                        format!(
                            "{} {}",
                            handoff_text(
                                locale,
                                "cannot create protected token handoff file",
                                "无法创建受保护的令牌交接文件",
                                "保護されたトークン引き渡しファイルを作成できません"
                            ),
                            path.display()
                        )
                    });
                }
            };
            if let Err(error) = write_handoff(&mut file, &path, content, locale) {
                drop(file);
                let _ = fs::remove_file(&path);
                return Err(error);
            }
            return Ok(Self { path });
        }
        bail!(
            "{} {}",
            handoff_text(
                locale,
                "could not create a unique token handoff file in",
                "无法在以下位置创建唯一的令牌交接文件:",
                "一意なトークン引き渡しファイルを作成できません:"
            ),
            dir.display()
        )
    }

    pub fn path(&self) -> &Path {
        &self.path
    }
}

impl Drop for TokenHandoff {
    fn drop(&mut self) {
        let _ = fs::remove_file(&self.path);
    }
}

fn open_restricted(path: &Path, locale: crate::locale::Locale) -> std::io::Result<File> {
    #[cfg(unix)]
    let _ = locale;
    let mut options = OpenOptions::new();
    options.write(true).create_new(true);
    #[cfg(unix)]
    {
        use std::os::unix::fs::OpenOptionsExt;
        options.mode(0o600);
    }
    let file = options.open(path)?;
    #[cfg(windows)]
    if let Err(error) = restrict_windows_acl(path, locale) {
        drop(file);
        let _ = fs::remove_file(path);
        return Err(std::io::Error::other(error.to_string()));
    }
    #[cfg(not(any(unix, windows)))]
    {
        drop(file);
        let _ = fs::remove_file(path);
        return Err(std::io::Error::new(
            std::io::ErrorKind::Unsupported,
            handoff_text(
                locale,
                "secure token handoff is not supported on this platform",
                "此平台不支持安全令牌交接",
                "このプラットフォームでは安全なトークン引き渡しをサポートしていません",
            ),
        ));
    }
    Ok(file)
}

fn write_handoff(
    file: &mut File,
    path: &Path,
    token: &str,
    locale: crate::locale::Locale,
) -> Result<()> {
    file.write_all(token.as_bytes()).with_context(|| {
        format!(
            "{} {}",
            handoff_text(
                locale,
                "cannot write token handoff file",
                "无法写入令牌交接文件",
                "トークン引き渡しファイルを書き込めません"
            ),
            path.display()
        )
    })?;
    file.sync_all().with_context(|| {
        format!(
            "{} {}",
            handoff_text(
                locale,
                "cannot flush token handoff file",
                "无法刷新令牌交接文件",
                "トークン引き渡しファイルをフラッシュできません"
            ),
            path.display()
        )
    })
}

#[cfg(windows)]
fn restrict_windows_acl(path: &Path, locale: crate::locale::Locale) -> Result<()> {
    use std::os::windows::process::CommandExt;
    const CREATE_NO_WINDOW: u32 = 0x0800_0000;

    let system_root = std::env::var_os("SystemRoot").context(handoff_text(
        locale,
        "SystemRoot is unavailable; cannot protect the token handoff file",
        "SystemRoot 不可用;无法保护令牌交接文件",
        "SystemRoot を利用できないためトークン引き渡しファイルを保護できません",
    ))?;
    let system32 = PathBuf::from(system_root).join("System32");
    let whoami = std::process::Command::new(system32.join("whoami.exe"))
        .args(["/user", "/fo", "csv", "/nh"])
        .creation_flags(CREATE_NO_WINDOW)
        .output()
        .context(handoff_text(
            locale,
            "cannot determine the current Windows account SID",
            "无法确定当前 Windows 帐户 SID",
            "現在の Windows アカウント SID を取得できません",
        ))?;
    if !whoami.status.success() {
        bail!(handoff_text(
            locale,
            "cannot determine the current Windows account SID",
            "无法确定当前 Windows 帐户 SID",
            "現在の Windows アカウント SID を取得できません"
        ));
    }
    let output = String::from_utf8_lossy(&whoami.stdout);
    let sid = output
        .lines()
        .find_map(parse_whoami_sid)
        .context(handoff_text(
            locale,
            "whoami did not return a valid current-account SID",
            "whoami 未返回有效的当前帐户 SID",
            "whoami が有効な現在のアカウント SID を返しませんでした",
        ))?;

    // Create the file empty, then remove inherited access before writing any secret bytes.
    // Keep SYSTEM access for normal Windows servicing; the current account gets full control.
    let acl = std::process::Command::new(system32.join("icacls.exe"))
        .arg(path)
        .arg("/inheritance:r")
        .arg("/grant:r")
        .arg(format!("*{sid}:(F)"))
        .arg("*S-1-5-18:(F)")
        .creation_flags(CREATE_NO_WINDOW)
        .output()
        .with_context(|| {
            format!(
                "{} {}",
                handoff_text(
                    locale,
                    "cannot set permissions on token handoff file",
                    "无法设置令牌交接文件的权限",
                    "トークン引き渡しファイルの権限を設定できません"
                ),
                path.display()
            )
        })?;
    if !acl.status.success() {
        bail!(
            "{} {}",
            handoff_text(
                locale,
                "cannot set permissions on token handoff file",
                "无法设置令牌交接文件的权限",
                "トークン引き渡しファイルの権限を設定できません"
            ),
            path.display()
        );
    }
    Ok(())
}

fn handoff_text(
    locale: crate::locale::Locale,
    english: &'static str,
    chinese: &'static str,
    japanese: &'static str,
) -> &'static str {
    match locale {
        crate::locale::Locale::English => english,
        crate::locale::Locale::Chinese => chinese,
        crate::locale::Locale::Japanese => japanese,
    }
}

#[cfg(windows)]
fn parse_whoami_sid(line: &str) -> Option<String> {
    let (_, sid) = line.rsplit_once("\",\"")?;
    let sid = sid.trim().trim_matches('"').trim_start_matches('\u{feff}');
    let mut parts = sid.split('-');
    if parts.next()? != "S" || parts.next()? != "1" {
        return None;
    }
    let rest: Vec<_> = parts.collect();
    if rest.len() < 2
        || rest
            .iter()
            .any(|part| part.is_empty() || !part.bytes().all(|b| b.is_ascii_digit()))
    {
        return None;
    }
    Some(sid.to_owned())
}

#[cfg(test)]
mod tests {
    use super::TokenHandoff;

    #[test]
    fn handoff_contains_token_and_is_removed_on_drop() {
        let token = "credential-that-must-not-be-in-the-path";
        let handoff = TokenHandoff::create(token, crate::locale::Locale::English).unwrap();
        assert_ne!(handoff.path().file_name().unwrap().to_string_lossy(), token);
        assert_eq!(std::fs::read_to_string(handoff.path()).unwrap(), token);
        #[cfg(unix)]
        {
            use std::os::unix::fs::PermissionsExt;
            assert_eq!(
                std::fs::metadata(handoff.path())
                    .unwrap()
                    .permissions()
                    .mode()
                    & 0o777,
                0o600
            );
        }
        let path = handoff.path().to_owned();
        drop(handoff);
        assert!(!path.exists());
    }

    #[test]
    fn launcher_forwards_with_fragment_token_and_is_removed_on_drop() {
        let token = "0123456789abcdef";
        let launcher = TokenHandoff::launcher(
            "http://127.0.0.1:38082/",
            token,
            crate::locale::Locale::English,
        )
        .unwrap();
        let name = launcher.path().file_name().unwrap().to_string_lossy();
        assert!(name.starts_with("rdsh-open-") && name.ends_with(".html"));
        assert!(!name.contains(token));
        let page = std::fs::read_to_string(launcher.path()).unwrap();
        assert!(page.contains("location.replace(\"http://127.0.0.1:38082/#key=0123456789abcdef\")"));
        let path = launcher.path().to_owned();
        drop(launcher);
        assert!(!path.exists());
    }

    #[cfg(windows)]
    #[test]
    fn parses_current_user_sid_from_whoami_csv() {
        assert_eq!(
            super::parse_whoami_sid("\"DOMAIN\\\\user\",\"S-1-5-21-1-2-3-1000\""),
            Some("S-1-5-21-1-2-3-1000".to_owned())
        );
        assert_eq!(super::parse_whoami_sid("invalid"), None);
    }
}