diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml index 2e653dc..09b93d2 100644 --- a/.gitea/workflows/ci.yaml +++ b/.gitea/workflows/ci.yaml @@ -18,6 +18,7 @@ jobs: - run: npm run format:check - run: npm run lint - run: npm run check + - run: npm test - run: npm run build - uses: actions-rust-lang/setup-rust-toolchain@v1 - working-directory: native diff --git a/.gitea/workflows/release-check.yaml b/.gitea/workflows/release-check.yaml index 79bf997..4a0c580 100644 --- a/.gitea/workflows/release-check.yaml +++ b/.gitea/workflows/release-check.yaml @@ -15,3 +15,4 @@ jobs: node-version: 22 cache: npm - run: test -s CHANGELOG.md && npm ci && npm run format:check && npm run lint && npm run check && npm run build + - run: npm test diff --git a/docs/architecture.md b/docs/architecture.md index 8c583a0..a3aacc4 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -20,3 +20,8 @@ failures do not invalidate a successfully verified session. Run `node --test src/session-recovery.test.js` for response classification and retry-schedule coverage. This is not a substitute for native AppImage acceptance. + +Keyring writes use `keyctl padd` with the session supplied through standard input, +never process arguments. Helper stdout/stderr are discarded so credential payloads +cannot enter diagnostics. Native tests cover the argument boundary, stdin delivery, +and helper failure handling without reading or modifying real keyring entries. diff --git a/native/src/lib.rs b/native/src/lib.rs index b0dadde..f597cd8 100644 --- a/native/src/lib.rs +++ b/native/src/lib.rs @@ -9,8 +9,9 @@ use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; use std::{ fs, io, + io::Write, path::{Path, PathBuf}, - process::Command, + process::{Command, Stdio}, }; pub const KEYRING_SERVICE: &str = "trixie.server.session"; @@ -84,7 +85,34 @@ pub fn store_session(settings: &Settings, session: &str) -> io::Result<()> { return Err(io::Error::new(io::ErrorKind::InvalidInput, "empty session")); } let description = key_description(settings); - keyctl(&["add", "user", &description, session, "@s"]).map(|_| ()) + write_key_payload(key_write_command(&description), session.as_bytes()) +} + +// Secret payloads must never be arguments: process listings can expose argv. +fn key_write_command(description: &str) -> Command { + let mut command = Command::new("keyctl"); + command.args(["padd", "user", description, "@s"]); + command +} + +fn write_key_payload(mut command: Command, payload: &[u8]) -> io::Result<()> { + let mut child = command + .stdin(Stdio::piped()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn()?; + let result = child.stdin.take().expect("piped stdin").write_all(payload); + // Closing stdin above signals EOF. Reap the child even if writing failed. + let status = child.wait()?; + result?; + if status.success() { + Ok(()) + } else { + Err(io::Error::new( + io::ErrorKind::PermissionDenied, + "Linux kernel keyring access failed", + )) + } } pub fn load_session(settings: &Settings) -> io::Result> { @@ -117,6 +145,23 @@ pub fn clear_session(settings: &Settings) -> io::Result<()> { mod tests { use super::*; + #[test] + fn key_write_arguments_contain_only_metadata() { + let command = key_write_command("test-description"); + let args: Vec<_> = command.get_args().collect(); + assert_eq!(args, ["padd", "user", "test-description", "@s"]); + } + + #[test] + fn payload_is_delivered_via_stdin_and_failures_are_reported() { + let mut receiver = Command::new("sh"); + receiver.args(["-c", "test \"$(cat)\" = fixture-payload"]); + write_key_payload(receiver, b"fixture-payload").unwrap(); + let mut failure = Command::new("sh"); + failure.args(["-c", "cat >/dev/null; exit 1"]); + assert!(write_key_payload(failure, b"fixture-payload").is_err()); + } + fn settings() -> Settings { Settings { server_address: "https://trixie.example".into(), diff --git a/package.json b/package.json index 7642504..17ef223 100644 --- a/package.json +++ b/package.json @@ -4,6 +4,7 @@ "version": "0.1.0-dev", "type": "module", "scripts": { + "test": "node --test src/*.test.js", "dev": "vite", "tauri": "tauri", "tauri:dev": "tauri dev",