Feature chore bug 005 #19

Merged
iamdoubz merged 106 commits from feature_chore_bug_005 into main 2026-07-07 22:02:41 -05:00
Showing only changes of commit 851e89720d - Show all commits
+90
View File
@@ -0,0 +1,90 @@
//! MCP auth-token storage (FR-MCP-1/6). The token itself is **never** written
//! to `settings.json`/`wa.db`/logs — only the OS credential store, exactly
//! like sync secrets (`sync::credentials`) and hosted-AI API keys.
use crate::mcp::McpError;
const SERVICE: &str = "WhispAssist-mcp";
const ACCOUNT: &str = "token";
const TOKEN_BYTES: usize = 32;
fn entry() -> Result<keyring::Entry, McpError> {
keyring::Entry::new(SERVICE, ACCOUNT).map_err(|e| McpError::Server(e.to_string()))
}
/// Generates a fresh random token (hex-encoded, 64 chars) and persists it,
/// replacing whatever was there before (each `set_mcp_enabled` mints a new
/// one — there is no "reveal the existing token" path, same treatment as a
/// password).
pub fn mint_and_store() -> Result<String, McpError> {
let mut buf = [0u8; TOKEN_BYTES];
getrandom::getrandom(&mut buf).map_err(|e| McpError::Server(e.to_string()))?;
let token = hex_encode(&buf);
entry()?
.set_password(&token)
.map_err(|e| McpError::Server(e.to_string()))?;
Ok(token)
}
/// Best-effort read for `mcp_status`'s `tokenSet` flag — never returned to
/// the frontend as a value, only whether one exists.
pub fn is_set() -> bool {
entry().and_then(|e| e.get_password().map_err(|e| McpError::Server(e.to_string()))).is_ok()
}
pub fn get() -> Result<String, McpError> {
entry()?
.get_password()
.map_err(|e| McpError::Server(e.to_string()))
}
/// Best-effort cleanup on disable — a missing entry is not an error.
pub fn delete() {
if let Ok(e) = entry() {
match e.delete_credential() {
Ok(()) | Err(keyring::Error::NoEntry) => {}
Err(err) => tracing::warn!("failed to delete MCP token: {err}"),
}
}
}
/// Constant-time comparison so token checking doesn't leak timing
/// information about how many leading bytes matched (NFR-SEC-5).
pub fn verify(presented: &str, expected: &str) -> bool {
let a = presented.as_bytes();
let b = expected.as_bytes();
if a.len() != b.len() {
return false;
}
let mut diff = 0u8;
for (x, y) in a.iter().zip(b.iter()) {
diff |= x ^ y;
}
diff == 0
}
fn hex_encode(bytes: &[u8]) -> String {
let mut s = String::with_capacity(bytes.len() * 2);
for b in bytes {
s.push_str(&format!("{b:02x}"));
}
s
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn verify_requires_exact_match() {
assert!(verify("abc123", "abc123"));
assert!(!verify("abc123", "abc124"));
assert!(!verify("abc12", "abc123"));
assert!(!verify("", "abc123"));
}
#[test]
fn hex_encode_is_lowercase_and_fixed_width() {
assert_eq!(hex_encode(&[0, 255, 16]), "00ff10");
}
}