use aes::cipher::{BlockDecryptMut, BlockEncryptMut, KeyIvInit, block_padding::Pkcs7};
use base64::Engine;
use crate::error::{AppError, Result};
const SALT: &[u8] = b"saltysalt";
const ROUNDS: u32 = 1003;
const KEY_LEN: usize = 16;
const IV: [u8; 16] = [b' '; 16];
const PREFIX: &[u8] = b"v10";
#[cfg(target_os = "macos")]
pub const SERVICE: &str = "Claude Safe Storage";
type Aes128CbcDec = cbc::Decryptor<aes::Aes128>;
type Aes128CbcEnc = cbc::Encryptor<aes::Aes128>;
pub fn derive_key(secret: &[u8]) -> [u8; KEY_LEN] {
let mut key = [0u8; KEY_LEN];
pbkdf2::pbkdf2_hmac::<sha1::Sha1>(secret, SALT, ROUNDS, &mut key);
key
}
pub fn decrypt(key: &[u8; KEY_LEN], value_b64: &str) -> Result<Vec<u8>> {
let raw = base64::engine::general_purpose::STANDARD
.decode(value_b64.trim())
.map_err(|e| AppError::Other(format!("safeStorage value is not base64: {e}")))?;
if raw.len() < PREFIX.len() || &raw[..PREFIX.len()] != PREFIX {
return Err(AppError::Other(
"safeStorage value is missing the v10 prefix".into(),
));
}
let ct = &raw[PREFIX.len()..];
Aes128CbcDec::new(key.into(), &IV.into())
.decrypt_padded_vec_mut::<Pkcs7>(ct)
.map_err(|e| AppError::Other(format!("safeStorage decrypt failed: {e}")))
}
pub fn encrypt(key: &[u8; KEY_LEN], plaintext: &[u8]) -> String {
let ct = Aes128CbcEnc::new(key.into(), &IV.into()).encrypt_padded_vec_mut::<Pkcs7>(plaintext);
let mut out = Vec::with_capacity(PREFIX.len() + ct.len());
out.extend_from_slice(PREFIX);
out.extend_from_slice(&ct);
base64::engine::general_purpose::STANDARD.encode(out)
}
#[cfg(target_os = "macos")]
pub fn macos_key() -> Result<[u8; KEY_LEN]> {
use std::process::Command;
let out = Command::new("/usr/bin/security")
.args(["find-generic-password", "-s", SERVICE, "-w"])
.output()
.map_err(|e| AppError::Other(format!("could not run `security`: {e}")))?;
if !out.status.success() {
return Err(AppError::Other(format!(
"no `{SERVICE}` item in the login Keychain (is Claude Desktop installed?)"
)));
}
let secret = String::from_utf8_lossy(&out.stdout);
Ok(derive_key(secret.trim().as_bytes()))
}
#[cfg(test)]
mod tests {
use super::*;
fn key() -> [u8; KEY_LEN] {
derive_key(b"not-a-real-secret")
}
#[test]
fn round_trips_plaintext() {
let k = key();
let msg = br#"{"token":"sk-ant-oat01-abc","refreshToken":"sk-ant-ort01-xyz"}"#;
let enc = encrypt(&k, msg);
assert!(
base64::engine::general_purpose::STANDARD
.decode(&enc)
.unwrap()
.starts_with(PREFIX)
);
assert_eq!(decrypt(&k, &enc).unwrap(), msg);
}
#[test]
fn encryption_is_deterministic() {
let k = key();
assert_eq!(encrypt(&k, b"same"), encrypt(&k, b"same"));
}
#[test]
fn rejects_a_value_without_the_v10_prefix() {
let k = key();
let no_prefix = base64::engine::general_purpose::STANDARD.encode(b"not-v10-data");
assert!(decrypt(&k, &no_prefix).is_err());
}
#[test]
fn rejects_non_base64() {
assert!(decrypt(&key(), "@@@not base64@@@").is_err());
}
#[test]
fn wrong_key_fails_rather_than_returning_garbage() {
let enc = encrypt(&key(), b"secret payload here, long enough to pad");
let other = derive_key(b"different-secret");
assert!(decrypt(&other, &enc).is_err());
}
}