seher-sdk 0.0.32

Seher SDK: agent resolution, rate-limit checks, and provider clients
Documentation
use crate::crypto::{CryptoError, Result};
use cbc::cipher::{BlockModeDecrypt, KeyIvInit};
use hmac::{Hmac, KeyInit, Mac};
use security_framework::os::macos::keychain::SecKeychain;
use sha1::Sha1;

type HmacSha1 = Hmac<Sha1>;

type Aes128CbcDec = cbc::Decryptor<aes::Aes128>;

const SALT: &[u8] = b"saltysalt";
const IV: &[u8] = b"                "; // 16 spaces
const KEY_LENGTH: usize = 16;
const ITERATIONS: u32 = 1003;

/// # Errors
///
/// Returns an error if the encryption version is unsupported, decryption fails, or
/// the result is not valid UTF-8.
pub fn decrypt(encrypted_value: &[u8]) -> Result<String> {
    if encrypted_value.len() < 3 {
        return Ok(String::new());
    }

    let version = &encrypted_value[..3];
    match version {
        b"v10" | b"v11" => {
            let encrypted = &encrypted_value[3..];
            let key = get_encryption_key()?;
            decrypt_aes_cbc(&key, encrypted)
        }
        _ => String::from_utf8(encrypted_value.to_vec())
            .map_err(|e| CryptoError::DecryptionFailed(e.to_string())),
    }
}

fn get_encryption_key() -> Result<Vec<u8>> {
    let password = get_chrome_password()?;

    let mut key = vec![0u8; KEY_LENGTH];
    pbkdf2_hmac_sha1(&password, SALT, ITERATIONS, &mut key);

    Ok(key)
}

#[expect(clippy::expect_used)]
fn pbkdf2_hmac_sha1(password: &[u8], salt: &[u8], iterations: u32, output: &mut [u8]) {
    const SHA1_LEN: usize = 20;
    let block_count = output.len().div_ceil(SHA1_LEN);

    for block_idx in 1..=block_count {
        // U1 = HMAC(password, salt || INT(block_idx))
        let mut mac = HmacSha1::new_from_slice(password).expect("HMAC accepts any key size");
        mac.update(salt);
        mac.update(
            &u32::try_from(block_idx)
                .expect("block index overflow")
                .to_be_bytes(),
        );
        let mut u = mac.finalize().into_bytes();
        let mut t = u;

        for _ in 1..iterations {
            let mut mac = HmacSha1::new_from_slice(password).expect("HMAC accepts any key size");
            mac.update(&u);
            u = mac.finalize().into_bytes();
            for (t_val, u_val) in t.iter_mut().zip(u.iter()) {
                *t_val ^= u_val;
            }
        }

        let start = (block_idx - 1) * SHA1_LEN;
        let end = (start + SHA1_LEN).min(output.len());
        output[start..end].copy_from_slice(&t[..end - start]);
    }
}

fn get_chrome_password() -> Result<Vec<u8>> {
    // Prefer `security` CLI: its code signature is stable, so "Always Allow"
    // in the Keychain dialog persists across rebuilds of our binary.
    if let Ok(pw) = get_password_from_cli() {
        return Ok(pw);
    }

    // Fallback: security-framework API (prompts per-binary)
    get_password_from_keychain()
}

fn get_password_from_keychain() -> Result<Vec<u8>> {
    let keychain = SecKeychain::default()
        .map_err(|e| CryptoError::KeychainError(format!("Failed to access keychain: {e}")))?;

    let (password_data, _item) = keychain
        .find_generic_password("Chrome Safe Storage", "Chrome")
        .map_err(|e| {
            CryptoError::KeychainError(format!("Failed to find Chrome Safe Storage: {e}"))
        })?;

    Ok(password_data.as_ref().to_vec())
}

fn get_password_from_cli() -> Result<Vec<u8>> {
    let output = std::process::Command::new("security")
        .args([
            "find-generic-password",
            "-s",
            "Chrome Safe Storage",
            "-a",
            "Chrome",
            "-w",
        ])
        .output()
        .map_err(|e| CryptoError::KeychainError(format!("Failed to run security command: {e}")))?;

    if !output.status.success() {
        let stderr = String::from_utf8_lossy(&output.stderr);
        return Err(CryptoError::KeychainError(format!(
            "security command failed: {}",
            stderr.trim()
        )));
    }

    let password = String::from_utf8_lossy(&output.stdout);
    Ok(password.trim_end_matches('\n').as_bytes().to_vec())
}

fn decrypt_aes_cbc(key: &[u8], encrypted: &[u8]) -> Result<String> {
    let cipher = Aes128CbcDec::new_from_slices(key, IV)
        .map_err(|e| CryptoError::DecryptionFailed(format!("Invalid key or IV length: {e}")))?;

    let mut buffer = encrypted.to_vec();
    let decrypted = cipher
        .decrypt_padded::<cbc::cipher::block_padding::Pkcs7>(&mut buffer)
        .map_err(|e| CryptoError::DecryptionFailed(format!("AES-CBC decryption failed: {e:?}")))?;

    // Newer Chrome prepends a 32-byte binary header (31 bytes + 0x60 separator)
    // before the actual cookie value. Scan for valid UTF-8 and strip the header.
    if let Ok(s) = String::from_utf8(decrypted.to_vec()) {
        return Ok(s);
    }
    for offset in 1..decrypted.len().min(64) {
        if let Ok(s) = std::str::from_utf8(&decrypted[offset..]) {
            // Skip the 0x60 separator byte that Chrome uses as header terminator
            let s = s.strip_prefix('`').unwrap_or(s);
            return Ok(s.to_string());
        }
    }

    Err(CryptoError::DecryptionFailed(format!(
        "UTF-8 conversion failed (first 32 bytes: {:02x?})",
        &decrypted[..decrypted.len().min(32)]
    )))
}