crates/veilvoice-watch/src/input.rs

what this file is for · veilvoice-watch · 605 lines · the same file on GitHub

The file as it is in the tree, in the colours you chose. A line number is a link, and so is every box in this file’s diagram: it opens here with the function it names marked.


// SPDX-License-Identifier: GPL-3.0-or-later
//! What on this machine could be watching the keyboard and the mouse.
//!
//! # This is a heuristic, and the crate is built to keep saying so
//!
//! There is no way to ask an operating system "is anything logging my
//! keystrokes" and get a true answer. The mechanisms a keylogger uses are the
//! same ones accessibility software, password managers, remote-support tools,
//! macro utilities and games legitimately use, and the good ones are written
//! not to be found. A tool that claimed to detect keyloggers would be making a
//! promise nothing can keep.
//!
//! So this does the one thing that *can* be done honestly: it names the
//! programs running right now that are **able** to see your input, says what
//! each one is for, and leaves the judgement where it belongs. Every finding is
//! phrased as capability, never as an accusation, and [`Finding::phrasing`]
//! exists so that no front end has to invent that wording and get it wrong.
//!
//! [`LIMITS`] is the paragraph a front end must show beside any result. It says
//! outright that a clean result proves nothing. That is not a disclaimer bolted
//! on; it is the most important thing this crate outputs, because somebody who
//! reads "nothing found" as "nothing there" has been made *less* safe by
//! running it.
//!
//! # What it does not do, deliberately
//!
//! It does not hook the keyboard, read input, count keystrokes, time them, or
//! watch the mouse. A program that monitored input to detect input monitoring
//! would be the thing it warns about, and on Windows it would need the same
//! `SetWindowsHookEx` that `#![forbid(unsafe_code)]` rules out anyway.
//!
//! It also does not scan memory, inspect other processes' handles or read the
//! registry's autostart keys. `veilvoice-watch` already covers persistence,
//! and duplicating it here would give two answers to one question.
//!
//! # In plain words
//!
//! Software that records what you type is real, and there is no honest way for
//! any program to tell you for certain whether it is on your computer. Anything
//! that claims otherwise is guessing and not admitting it.
//!
//! What this does instead: it looks at which programs are open, and tells you
//! which of them *could* see your typing or your mouse -- remote-support tools,
//! macro recorders, accessibility software, and so on. Most of the time these
//! are things you installed on purpose and there is nothing wrong. The point is
//! that you get to know they are running and decide for yourself.
//!
//! If it finds nothing, that does **not** mean nothing is watching. It means
//! nothing it knows how to recognise is open, which is a much smaller claim,
//! and this crate will keep saying so every time.


/// Why a program is in the table.
///
/// The distinction decides how loudly a front end should speak, and getting it
/// wrong in either direction is a real failure: treating a password manager as
/// a threat trains people to ignore the warning, and treating a remote-access
/// tool as background noise wastes the one finding that mattered.
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
pub enum Reach {
    /// Seeing input on other programs' windows is what it is for.
    ///
    /// Remote-control software, macro recorders, dedicated key-display tools.
    /// Running one of these is worth a line whatever else is true.
    Purpose,
    /// It reaches input to do its own job, and that job is not watching you.
    ///
    /// Password managers with global hotkeys, screen readers, streaming
    /// overlays. Ordinary, and named rather than hidden, because "ordinary" is
    /// a judgement for the person reading -- not for this table.
    Incidental,
}


impl Reach {

    /// The wording a front end should use, and the reason this is not left to
    /// each caller to phrase.
    ///
    /// Every sentence here is about *capability*. None of them says a program
    /// is doing anything, because this crate cannot know that and neither can
    /// anything else that only reads a process list.
    pub fn phrasing(self) -> &'static str {
        match self {
            Self::Purpose => {
                "is running, and reading input from other programs is what it is for -- \
                 which is not the same as it doing so now"
            }
            Self::Incidental => {
                "is running, and can see input as part of its own job -- which is \
                 usually exactly why you installed it"
            }
        }
    }

}


/// One program able to observe keyboard or mouse input.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Watcher {
    /// A stable identifier, and what an allowlist entry would name.
    pub key: &'static str,
    /// What to call it.
    pub name: &'static str,
    /// What the program is, in one clause, for somebody who has not met it.
    pub what: &'static str,
    /// Why it can see input at all.
    pub how: &'static str,
    /// Whether watching input is the point of it.
    pub reach: Reach,
    /// Executable names, lower-case and without a path.
    ///
    /// At least one of every Unix name must survive the kernel's fifteen
    /// character truncation of `/proc/<pid>/comm`, or the entry can never match
    /// on Linux. A test holds it.
    pub processes: &'static [&'static str],
}



/// Every program this build knows how to recognise.
///
/// **Not a list of keyloggers.** Almost everything here is software somebody
/// installed on purpose and uses every day. It is a list of things that *can*
/// see input, so that a person deciding whether to speak freely knows what is
/// open.
///
/// Ordered with [`Reach::Purpose`] first, so a front end showing a few shows
/// the ones that carry the most information.
pub const ALL: &[Watcher] = &[
    Watcher {
        key: "anydesk",
        name: "AnyDesk",
        what: "Remote desktop software.",
        how: "A remote operator sees the screen and sends keyboard and mouse \
              events, so input is carried by design.",
        reach: Reach::Purpose,
        processes: &["anydesk.exe", "anydesk"],
    },

    Watcher {
        key: "teamviewer",
        name: "TeamViewer",
        what: "Remote desktop and support software.",
        how: "Same as any remote-control tool: input is the channel.",
        reach: Reach::Purpose,
        processes: &["teamviewer.exe", "teamviewer_service.exe", "teamviewer"],
    },
    Watcher {
        key: "rustdesk",
        name: "RustDesk",
        what: "Open-source remote desktop software.",
        how: "Carries keyboard and mouse to whoever is connected.",
        reach: Reach::Purpose,
        processes: &["rustdesk.exe", "rustdesk"],
    },
    Watcher {
        key: "vnc",
        name: "A VNC server",
        what: "Remote screen and input sharing.",
        how: "A VNC server exists to accept keyboard and mouse from elsewhere.",
        reach: Reach::Purpose,
        // `x11vnc` and `tigervncserver` both fit fifteen characters; the longer
        // spellings are here for `ps` and for Windows.
        processes: &[
            "winvnc.exe",
            "tvnserver.exe",
            "vncserver.exe",
            "x11vnc",
            "tigervncserver",
            "vncserver",
        ],
    },
    Watcher {
        key: "autohotkey",
        name: "AutoHotkey",
        what: "A scripting language for automating keyboard and mouse.",
        how: "Its scripts register global hotkeys and can record input. \
              Enormously useful, and the same mechanism a logger would use.",
        reach: Reach::Purpose,
        processes: &["autohotkey.exe", "autohotkey64.exe", "autohotkeyu64.exe"],
    },
    Watcher {
        key: "keyviewer",
        name: "Keystroke display",
        what: "Shows what you type on screen, for demonstrations and streaming.",
        how: "It reads every key globally in order to draw it.",
        reach: Reach::Purpose,
        processes: &["carnac.exe", "keycastr", "screenkey", "showmethekey"],
    },
    Watcher {
        key: "keepass",
        name: "KeePass",
        what: "A password manager.",
        how: "Global auto-type sends your credentials into other programs, so \
              it registers a system-wide hotkey.",
        reach: Reach::Incidental,
        processes: &["keepass.exe", "keepassxc.exe", "keepassxc", "keepass"],
    },
    Watcher {
        key: "1password",
        name: "1Password",
        what: "A password manager.",
        how: "Fills credentials into other programs and listens for a global \
              shortcut to do it.",
        reach: Reach::Incidental,
        processes: &["1password.exe", "1password", "1passwordd"],
    },
    Watcher {
        key: "bitwarden",
        name: "Bitwarden",
        what: "A password manager.",
        how: "Auto-fill and a global shortcut, the same as any of them.",
        reach: Reach::Incidental,
        processes: &["bitwarden.exe", "bitwarden"],
    },
    Watcher {
        key: "nvda",
        name: "NVDA",
        what: "A screen reader.",
        how: "Accessibility software must see input to describe what it does. \
              This is the clearest case where the answer is simply that \
              somebody needs it.",
        reach: Reach::Incidental,
        processes: &["nvda.exe", "nvda"],
    },
    Watcher {
        key: "orca",
        name: "Orca",
        what: "The screen reader shipped with GNOME.",
        how: "Reads input through the accessibility bus, for the same reason.",
        reach: Reach::Incidental,
        processes: &["orca"],
    },
    Watcher {
        key: "streamdeck",
        name: "Stream Deck",
        what: "Elgato's macro keypad software.",
        how: "Binds global shortcuts so a key on the pad can act anywhere.",
        reach: Reach::Incidental,
        processes: &["streamdeck.exe", "stream deck.exe", "streamdeck"],
    },
    Watcher {
        key: "obs",
        name: "OBS Studio",
        what: "Recording and streaming software.",
        how: "Its global hotkeys work while another program has focus, which \
              means it is watching for them. Listed for completeness: \
              `crate::capture` is what has something to say about OBS.",
        reach: Reach::Incidental,
        processes: &["obs64.exe", "obs32.exe", "obs"],
    },
];


/// The program with this identifier.
pub fn by_key(key: &str) -> Option<&'static Watcher> {
    ALL.iter().find(|watcher| watcher.key == key)
}



/// The entry a process name belongs to, if any.
pub fn matching(process: &str) -> Option<&'static Watcher> {
    let name = process.trim().to_ascii_lowercase();
    ALL.iter()
        .find(|watcher| watcher.processes.iter().any(|known| *known == name))
}



/// One program found running, and how to describe it.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Finding {
    /// Which entry matched.
    pub watcher: &'static Watcher,
}


impl Finding {

    /// The whole sentence to show, capability and all.
    pub fn phrasing(&self) -> String {
        format!("{} {}", self.watcher.name, self.watcher.reach.phrasing())
    }

}


/// What was found, and everything that qualifies it.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct Report {
    /// Programs running that can see input, most significant first.
    pub findings: Vec<Finding>,
    /// Anything that went wrong while looking.
    ///
    /// A short list because a tool failed is **not** a short list. Without
    /// this, "nothing found" and "I could not look" are the same output, and
    /// they mean opposite things.
    pub problems: Vec<String>,
}


impl Report {

    /// Whether anything at all could be established.
    ///
    /// False when the process listing itself failed. A caller must not print a
    /// reassuring summary in that case, and this is how it knows.
    pub fn is_answerable(&self) -> bool {
        self.problems.is_empty() || !self.findings.is_empty()
    }



    /// A one-line summary, phrased so it cannot be read as a clean bill of
    /// health.
    pub fn summary(&self) -> String {
        if !self.is_answerable() {
            return "Could not read the list of running programs, so nothing was \
                    checked."
                .to_string();
        }
        match self.findings.len() {
            0 => "Nothing this build recognises is running. That is not the same \
                  as nothing watching."
                .to_string(),
            1 => "1 running program can see keyboard or mouse input.".to_string(),
            many => format!("{many} running programs can see keyboard or mouse input."),
        }
    }

}


/// Look, and report. Changes nothing and reads no input.
pub fn look() -> Report {
    let (names, problems) = crate::proc::running();
    let mut findings: Vec<Finding> = Vec::new();
    for name in &names {
        if let Some(watcher) = matching(name) {
            // One line per program, not per process: several helper processes
            // of one application would otherwise read as several findings.
            if !findings.iter().any(|f| f.watcher.key == watcher.key) {
                findings.push(Finding { watcher });
            }
        }
    }
    // Purpose before incidental, then by name, so the order is stable and the
    // informative entries come first.
    findings.sort_by(|a, b| {
        a.watcher
            .reach
            .cmp(&b.watcher.reach)
            .then_with(|| a.watcher.name.cmp(b.watcher.name))
    });
    Report { findings, problems }
}



/// What a reader must be told, in the words to tell them.
///
/// Shown beside every result rather than behind a link. The sentence that
/// matters most is the one about a clean result: somebody who reads "nothing
/// found" as "nothing there" has been made less safe by running this.
pub const LIMITS: &str = "\
This cannot tell you whether anything is logging your keystrokes, and nothing \
can. The ways a program reads input are the same ways accessibility software, \
password managers and remote-support tools read it, and software written to \
hide is written to hide from this too. So a result of nothing found does not \
mean nothing is watching -- it means nothing this build recognises is running, \
which is a much smaller claim. What is listed is what is *able* to see input, \
never what is doing so; most of it is software you installed on purpose.";



/// Why this crate does not watch input in order to detect input watching.
pub const WHY_NOT_HOOKING: &str = "\
VeilVoice does not hook the keyboard, read what you type, or time your \
keystrokes. Detecting input monitoring by monitoring input would make this the \
thing it warns about, and it would need exactly the mechanism a logger uses.";


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

    #[test]
    fn every_entry_is_complete_and_uniquely_keyed() {
        let mut keys: Vec<&str> = ALL.iter().map(|w| w.key).collect();
        let count = keys.len();
        keys.sort_unstable();
        keys.dedup();
        assert_eq!(keys.len(), count, "two programs share a key");

        for watcher in ALL {
            assert!(!watcher.name.is_empty(), "{}", watcher.key);
            assert!(watcher.what.len() > 10, "{}", watcher.key);
            assert!(
                watcher.how.len() > 25,
                "{}: say why it can see input at all",
                watcher.key
            );
            assert!(!watcher.processes.is_empty(), "{}", watcher.key);
            for process in watcher.processes {
                assert_eq!(
                    *process,
                    process.to_lowercase(),
                    "{}: names are matched lower-case",
                    watcher.key
                );
                assert!(!process.contains('/') && !process.contains('\\'));
            }
            assert_eq!(by_key(watcher.key), Some(watcher));
        }
        assert_eq!(by_key("nothing-like-this"), None);
    }

    /// The same fifteen-character rule `proc` documents, tested next
    /// to the table it applies to -- because the table is where somebody adds a
    /// row, and a sixteen-character name would stop matching on Linux only.
    #[test]
    fn every_entry_has_a_name_that_survives_the_linux_truncation() {
        for watcher in ALL {
            let unix: Vec<&&str> = watcher
                .processes
                .iter()
                .filter(|process| !process.ends_with(".exe"))
                .collect();
            if unix.is_empty() {
                continue;
            }
            assert!(
                unix.iter().any(|process| process.len() <= 15),
                "{} has no name of fifteen characters or fewer, so it can never \
                 match on Linux: {unix:?}",
                watcher.key
            );
        }
    }

    /// **The most important test in this crate.**
    ///
    /// Every sentence a front end shows has to describe a capability. The
    /// moment one of them says a program *is* watching, this crate is making a
    /// claim it cannot support, and somebody acts on it.
    #[test]
    fn nothing_this_crate_says_accuses_a_program_of_anything() {
        // Deliberately **not** LIMITS. That paragraph has to name the thing
        // it cannot detect -- "cannot tell you whether anything is logging your
        // keystrokes" -- and a blunt search for "is logging" flags the denial
        // as though it were the accusation. It was written that way first and
        // failed on its own honesty. LIMITS has its own test below, which
        // checks it for the denials rather than against them.
        let mut sentences: Vec<String> = vec![
            Reach::Purpose.phrasing().to_string(),
            Reach::Incidental.phrasing().to_string(),
        ];
        for watcher in ALL {
            sentences.push(Finding { watcher }.phrasing());
            sentences.push(watcher.how.to_string());
        }
        sentences.push(Report::default().summary());

        for sentence in &sentences {
            let lower = sentence.to_lowercase();
            for accusation in [
                "is logging",
                "is recording your",
                "is watching you",
                "is stealing",
                "is spying",
                "keylogger detected",
            ] {
                assert!(
                    !lower.contains(accusation),
                    "\"{accusation}\" is a claim this crate cannot support:\n{sentence}"
                );
            }
        }

        // And the two that describe a kind of program have to say so outright.
        assert!(Reach::Purpose
            .phrasing()
            .contains("not the same as it doing so"));
        assert!(Reach::Incidental
            .phrasing()
            .contains("why you installed it"));
    }

    /// A clean result must never read as a clean machine. This is the sentence
    /// that decides whether running this makes somebody safer or less safe.
    #[test]
    fn a_clean_result_states_plainly_that_it_proves_nothing() {
        let empty = Report::default();
        let summary = empty.summary().to_lowercase();
        assert!(
            summary.contains("not the same as nothing watching"),
            "{summary}"
        );

        let limits = LIMITS.to_lowercase();
        assert!(limits.contains("and nothing can"), "{limits}");
        assert!(
            limits.contains("does not mean nothing is watching"),
            "{limits}"
        );
        assert!(limits.contains("never what is doing so"), "{limits}");
        assert!(
            limits.contains("installed on purpose"),
            "most of it is ordinary software and the note has to say so: {limits}"
        );
    }

    /// "I could not look" and "I looked and found nothing" are opposite
    /// answers. Reporting the first as the second is the failure this whole
    /// crate exists to avoid making.
    #[test]
    fn a_failed_look_is_not_reported_as_a_clean_one() {
        let broken = Report {
            findings: Vec::new(),
            problems: vec!["tasklist.exe: not found".to_string()],
        };
        assert!(!broken.is_answerable());
        let summary = broken.summary().to_lowercase();
        assert!(summary.contains("nothing was checked"), "{summary}");
        assert!(
            !summary.contains("not the same as nothing watching"),
            "a failed look must not borrow the clean-result wording: {summary}"
        );

        // A partial answer -- some findings *and* a problem -- is still an
        // answer, and must not be thrown away.
        let partial = Report {
            findings: vec![Finding {
                watcher: by_key("anydesk").unwrap(),
            }],
            problems: vec!["one reader failed".to_string()],
        };
        assert!(partial.is_answerable());
        assert!(partial.summary().contains("1 running program"));
    }

    #[test]
    fn a_process_name_finds_its_entry_however_it_is_written() {
        assert_eq!(matching("AnyDesk.exe").map(|w| w.key), Some("anydesk"));
        assert_eq!(matching("  anydesk  ").map(|w| w.key), Some("anydesk"));
        assert_eq!(matching("orca").map(|w| w.key), Some("orca"));
        assert_eq!(matching("notepad.exe"), None);
        assert_eq!(matching(""), None);
    }

    /// The dedicated tools sort first, so a front end showing three shows the
    /// three worth showing.
    #[test]
    fn the_informative_findings_come_first() {
        assert!(Reach::Purpose < Reach::Incidental);

        let mut report = Report {
            findings: vec![
                Finding {
                    watcher: by_key("keepass").unwrap(),
                },
                Finding {
                    watcher: by_key("anydesk").unwrap(),
                },
            ],
            problems: Vec::new(),
        };
        report.findings.sort_by(|a, b| {
            a.watcher
                .reach
                .cmp(&b.watcher.reach)
                .then_with(|| a.watcher.name.cmp(b.watcher.name))
        });
        assert_eq!(report.findings[0].watcher.key, "anydesk");
    }

    /// Looking at the real machine must not panic, hang, or change anything.
    #[test]
    fn looking_is_safe_wherever_this_runs() {
        let report = look();
        for finding in &report.findings {
            assert!(by_key(finding.watcher.key).is_some());
            assert!(!finding.phrasing().is_empty());
        }
        for problem in &report.problems {
            assert!(!problem.is_empty());
        }
        assert!(!report.summary().is_empty());
    }

    /// This crate must not become the thing it warns about. Checked against the
    /// source, because the argument for not doing it is only as good as the
    /// code continuing not to.
    #[test]
    fn this_crate_never_reads_input_itself() {
        let source = include_str!("input.rs").replace("\r\n", "\n");
        // Code only. The module documentation *names* these mechanisms in order
        // to say it does not use them, and the first version of this test read
        // that explanation and reported it as the offence.
        let body: String = source
            .split("#[cfg(test)]")
            .next()
            .unwrap()
            .lines()
            .filter(|line| {
                let trimmed = line.trim_start();
                !trimmed.starts_with("//") && !trimmed.starts_with("///")
            })
            .collect::<Vec<_>>()
            .join("\n");
        for mechanism in [
            "SetWindowsHookEx",
            "GetAsyncKeyState",
            "XGrabKeyboard",
            "CGEventTap",
            "/dev/input",
            "evdev",
        ] {
            assert!(
                !body.contains(mechanism),
                "{mechanism} is how a logger works, not how one is found"
            );
        }
        assert!(
            WHY_NOT_HOOKING.contains("would make this the thing it warns about"),
            "the reason has to be written down, not just followed"
        );
    }
}