libsignify_rs/
utils.rs

1//
2// signify-rs: cryptographically sign and verify files
3// lib/src/utils.rs: Utility functions
4//
5// Copyright (c) 2025, 2026 Ali Polatel <alip@chesswob.org>
6// Based in part upon OpenBSD's signify which is:
7//   Copyright (c) 2013 Ted Unangst <tedu@openbsd.org>
8//   Copyright (c) 2016 Marc Espie <espie@openbsd.org>
9//   Copyright (c) 2019 Adrian Perez de Castro <aperez@igalia.com>
10//   Copyright (c) 2019 Scott Bennett and other contributors
11//   SPDX-License-Identifier: ISC
12//
13// SPDX-License-Identifier: ISC
14
15use std::path::Path;
16
17use crate::error::{Error, Result};
18use data_encoding::HEXLOWER;
19use rpassword::prompt_password;
20use std::fs::File;
21
22use zeroize::Zeroizing;
23
24#[cfg(any(target_os = "linux", target_os = "android"))]
25use linux_keyutils::Key;
26
27/// Read a password from the keyring or stdin.
28///
29/// Use `tty` to read from a specific TTY file handle (Unix only), or `stdin` if `None`.
30///
31/// # Errors
32///
33/// Returns `Error::Io` if reading fails.
34pub fn read_password(
35    prompt: &str,
36    key_id: Option<i32>,
37    _tty: Option<&File>,
38) -> Result<Zeroizing<Vec<u8>>> {
39    #[cfg(any(target_os = "linux", target_os = "android"))]
40    if let Some(id) = key_id {
41        // Only check session keyring if we have a key ID to look up.
42        let key = Key::from_id(linux_keyutils::KeySerialId(id));
43
44        // Read key payload directly into a Zeroizing buffer.
45        // We allocate 1KB, read into it, and then truncate.
46        // This avoids intermediate copies of the secret.
47        // The buffer size is consistent with OpenBSD.
48        let mut buf = Zeroizing::new(vec![0u8; 1024]);
49        let len = key.read(&mut buf).map_err(Error::Keyring)?;
50
51        if len > buf.len() {
52            return Err(Error::InvalidKeyLength);
53        }
54
55        buf.truncate(len);
56        return Ok(buf);
57    }
58
59    #[cfg(not(any(target_os = "linux", target_os = "android")))]
60    if key_id.is_some() {
61        return Err(Error::KeyringDisabled);
62    }
63
64    #[cfg(unix)]
65    if let Some(tty) = _tty {
66        return read_password_unix(prompt, tty);
67    }
68
69    // Fallback to prompt_password.
70    let pass = prompt_password(prompt).map_err(|_e| Error::PasswordReadFailed)?;
71    Ok(Zeroizing::new(pass.into_bytes()))
72}
73
74#[cfg(unix)]
75fn read_password_unix<Fd: std::os::fd::AsFd>(prompt: &str, tty: Fd) -> Result<Zeroizing<Vec<u8>>> {
76    use nix::errno::Errno;
77    use nix::sys::termios::{tcgetattr, tcsetattr, LocalFlags, SetArg};
78    use nix::unistd::{read, write};
79
80    let oterm = tcgetattr(&tty)?;
81    let mut nterm = oterm.clone();
82
83    // Disable ECHO.
84    nterm.local_flags.remove(LocalFlags::ECHO);
85    // Ensure ECHONL is set so newline is echoed.
86    nterm.local_flags.insert(LocalFlags::ECHONL);
87    tcsetattr(&tty, SetArg::TCSANOW, &nterm)?;
88
89    // Prompt.
90    let mut nwrite = 0;
91    let bytes = prompt.as_bytes();
92    while nwrite < bytes.len() {
93        match write(&tty, &bytes[nwrite..]) {
94            Ok(n) => nwrite = nwrite.checked_add(n).ok_or(Error::Overflow)?,
95            Err(Errno::EINTR) => {}
96            Err(errno) => {
97                // Restore terminal before returning error.
98                let _ = tcsetattr(&tty, SetArg::TCSANOW, &oterm);
99                return Err(errno.into());
100            }
101        }
102    }
103
104    // Read password byte-by-byte.
105    let mut buf = Zeroizing::new([0u8; 1]);
106    let mut pass = Zeroizing::new(Vec::with_capacity(128));
107    loop {
108        match read(tty.as_fd(), buf.as_mut()) {
109            Ok(0) => break, // EOF
110            Ok(_) => {
111                let c = buf[0];
112                if matches!(c, b'\n' | b'\r') {
113                    break;
114                }
115                pass.push(c);
116            }
117            Err(Errno::EINTR) => {}
118            Err(errno) => {
119                // Restore terminal before returning error.
120                let _ = tcsetattr(&tty, SetArg::TCSANOW, &oterm);
121                return Err(errno.into());
122            }
123        }
124    }
125
126    // Restore terminal.
127    let _ = tcsetattr(&tty, SetArg::TCSANOW, &oterm);
128
129    Ok(pass)
130}
131
132/// Check password strength using zxcvbn (native) or password-strength (WASM).
133///
134/// Native: Requires zxcvbn score of 4 (strong) to pass.
135/// WASM: Requires password-strength score >= 0.8 to pass.
136///
137/// # Errors
138///
139/// Returns `Error::InvalidPasswordUtf8` if password is not valid UTF-8.
140/// Returns `Error::WeakPassword` with feedback if the password is too weak.
141#[cfg(not(any(target_arch = "wasm32", target_arch = "wasm64")))]
142pub fn check_password_strength(password: &[u8]) -> Result<()> {
143    let password = std::str::from_utf8(password).or(Err(Error::InvalidPasswordUtf8))?;
144
145    let entropy = zxcvbn::zxcvbn(password, &[]);
146    if entropy.score() < zxcvbn::Score::Four {
147        let feedback = entropy.feedback().map(|feedback| feedback.to_string());
148        return Err(Error::WeakPassword(feedback));
149    }
150
151    Ok(())
152}
153
154/// Check password strength using password-strength crate (WASM version).
155#[cfg(any(target_arch = "wasm32", target_arch = "wasm64"))]
156pub fn check_password_strength(password: &[u8]) -> Result<()> {
157    let password = std::str::from_utf8(password).or(Err(Error::InvalidPasswordUtf8))?;
158
159    let strength = password_strength::estimate_strength(password);
160    if strength < 0.8 {
161        let feedback = if strength < 0.3 {
162            Some("very weak - use a longer passphrase with mixed characters".to_string())
163        } else if strength < 0.6 {
164            Some("weak - add more characters and avoid common patterns".to_string())
165        } else {
166            Some("moderate - add uncommon words or symbols".to_string())
167        };
168        return Err(Error::WeakPassword(feedback));
169    }
170
171    Ok(())
172}
173
174/// Check that key file names result in the same base name and have correct extensions.
175///
176/// Returns the base name on success.
177///
178/// # Errors
179///
180/// Returns `Error::InvalidKeyName` if extensions don't match or basenames mismatch.
181/// Returns `Error::InvalidPath` if paths are invalid UTF-8.
182pub fn check_keyname_compliance(pubkey_path: Option<&Path>, seckey_path: &Path) -> Result<String> {
183    if !seckey_path
184        .extension()
185        .is_some_and(|ext| ext.eq_ignore_ascii_case("sec"))
186    {
187        return Err(Error::InvalidKeyName);
188    }
189
190    let seckey_stem = seckey_path.file_stem().ok_or(Error::InvalidPath)?;
191
192    if let Some(pk_path) = pubkey_path {
193        if !pk_path
194            .extension()
195            .is_some_and(|ext| ext.eq_ignore_ascii_case("pub"))
196        {
197            return Err(Error::InvalidKeyName);
198        }
199
200        let pubkey_stem = pk_path.file_stem().ok_or(Error::InvalidPath)?;
201
202        if seckey_stem != pubkey_stem {
203            return Err(Error::InvalidKeyName);
204        }
205    }
206
207    seckey_stem
208        .to_str()
209        .map(ToOwned::to_owned)
210        .ok_or(Error::InvalidPath)
211}
212
213/// Logs an untrusted buffer, escaping it as hex if it contains control characters.
214pub fn log_untrusted_buf(buf: &[u8]) -> String {
215    if contains_ascii_unprintable(buf) {
216        HEXLOWER.encode(buf)
217    } else if let Ok(s) = std::str::from_utf8(buf) {
218        s.to_string()
219    } else {
220        HEXLOWER.encode(buf)
221    }
222}
223
224// Checks if the buffer contains ASCII unprintable characters.
225fn contains_ascii_unprintable(buf: &[u8]) -> bool {
226    buf.iter().any(|byte| !is_ascii_printable(*byte))
227}
228
229// Checks if the given character is ASCII printable.
230fn is_ascii_printable(byte: u8) -> bool {
231    (0x20..=0x7e).contains(&byte)
232}
233
234#[cfg(test)]
235mod tests {
236    use super::*;
237    use std::path::PathBuf;
238
239    #[test]
240    fn test_compliance_seckey_only() -> std::result::Result<(), Box<dyn std::error::Error>> {
241        let sec = PathBuf::from("test.sec");
242        assert_eq!(check_keyname_compliance(None, &sec)?, "test");
243
244        let sec = PathBuf::from("foo/bar/baz.sec");
245        assert_eq!(check_keyname_compliance(None, &sec)?, "baz");
246        Ok(())
247    }
248
249    #[test]
250    fn test_compliance_invalid_seckey() {
251        let sec = PathBuf::from("test.key");
252        check_keyname_compliance(None, &sec).unwrap_err();
253
254        let sec = PathBuf::from("test");
255        check_keyname_compliance(None, &sec).unwrap_err();
256    }
257
258    #[test]
259    fn test_compliance_pair_match() -> std::result::Result<(), Box<dyn std::error::Error>> {
260        let sec = PathBuf::from("test.sec");
261        let pubk = PathBuf::from("test.pub");
262        assert_eq!(check_keyname_compliance(Some(&pubk), &sec)?, "test");
263        Ok(())
264    }
265
266    #[test]
267    fn test_compliance_pair_mismatch() {
268        let sec = PathBuf::from("test.sec");
269        let pubk = PathBuf::from("other.pub");
270        check_keyname_compliance(Some(&pubk), &sec).unwrap_err();
271    }
272
273    #[test]
274    fn test_compliance_invalid_pubkey() {
275        let sec = PathBuf::from("test.sec");
276        let pubk = PathBuf::from("test.key");
277        check_keyname_compliance(Some(&pubk), &sec).unwrap_err();
278    }
279
280    #[test]
281    fn test_compliance_invalid_path() {
282        let sec = PathBuf::from("foo/bar/..");
283        assert!(matches!(
284            check_keyname_compliance(None, &sec),
285            Err(Error::InvalidKeyName) | Err(Error::InvalidPath)
286        ));
287    }
288
289    #[test]
290    fn test_log_untrusted_buf() {
291        let buf = b"hello\x00world";
292        assert_eq!(log_untrusted_buf(buf), "68656c6c6f00776f726c64");
293
294        let buf = b"hello world";
295        assert_eq!(log_untrusted_buf(buf), "hello world");
296    }
297
298    #[test]
299    fn test_password_strength_invalid_utf8() {
300        let password = b"\xff\xfe";
301        assert!(matches!(
302            check_password_strength(password),
303            Err(Error::InvalidPasswordUtf8)
304        ));
305    }
306
307    #[test]
308    fn test_password_strength_weak() {
309        let password = b"sekrit";
310        assert!(matches!(
311            check_password_strength(password),
312            Err(Error::WeakPassword(_))
313        ));
314    }
315
316    #[test]
317    fn test_password_strength_strong() {
318        let password = b"Shine-On-You-Crazy-Diamond-1975!";
319        assert!(check_password_strength(password).is_ok());
320    }
321}