remem-ai 0.6.24

Local-first coding agent memory for Claude Code and OpenAI Codex
Documentation
use anyhow::{Context, Result};
use ring::aead::{Aad, LessSafeKey, Nonce, UnboundKey, AES_256_GCM};
use serde::{de::DeserializeOwned, Deserialize, Serialize};
use sha2::{Digest, Sha256};

use super::CipherKey;

const SPILL_PROTECTION: &str = "remem-spill-v1";
const SPILL_AAD: &[u8] = b"remem-spill-v1";
const NONCE_LEN: usize = 12;

#[derive(Debug, Serialize, Deserialize)]
struct SpillEnvelope {
    version: u32,
    protected: String,
    nonce_hex: String,
    ciphertext_hex: String,
}

pub(crate) fn encode_json_line<T: Serialize>(value: &T) -> Result<String> {
    let plaintext = serde_json::to_vec(value)?;
    let Some(key) = super::load_cipher_key()? else {
        if !super::plaintext_db_allowed() {
            anyhow::bail!(
                "spill payload requires SQLCipher key or explicit plaintext database override"
            );
        }
        return Ok(serde_json::to_string(value)?);
    };
    let envelope = protect_bytes(&plaintext, &key)?;
    Ok(serde_json::to_string(&envelope)?)
}

pub(crate) fn decode_json_line<T: DeserializeOwned>(line: &str) -> Result<T> {
    let value: serde_json::Value = serde_json::from_str(line)?;
    if value
        .get("protected")
        .and_then(|protected| protected.as_str())
        == Some(SPILL_PROTECTION)
    {
        let envelope: SpillEnvelope = serde_json::from_value(value)?;
        let plaintext = open_envelope(&envelope)?;
        return Ok(serde_json::from_slice(&plaintext)?);
    }
    Ok(serde_json::from_value(value)?)
}

fn protect_bytes(plaintext: &[u8], key: &CipherKey) -> Result<SpillEnvelope> {
    let mut nonce = [0_u8; NONCE_LEN];
    getrandom::fill(&mut nonce)
        .map_err(|error| anyhow::anyhow!("generate spill encryption nonce: {error}"))?;
    let key = LessSafeKey::new(
        UnboundKey::new(&AES_256_GCM, &spill_key_bytes(key)?)
            .map_err(|_| anyhow::anyhow!("initialize spill encryption key"))?,
    );
    let mut ciphertext = plaintext.to_vec();
    key.seal_in_place_append_tag(
        Nonce::assume_unique_for_key(nonce),
        Aad::from(SPILL_AAD),
        &mut ciphertext,
    )
    .map_err(|_| anyhow::anyhow!("encrypt spill payload"))?;
    Ok(SpillEnvelope {
        version: 1,
        protected: SPILL_PROTECTION.to_string(),
        nonce_hex: hex_encode(&nonce),
        ciphertext_hex: hex_encode(&ciphertext),
    })
}

fn open_envelope(envelope: &SpillEnvelope) -> Result<Vec<u8>> {
    if envelope.version != 1 || envelope.protected != SPILL_PROTECTION {
        anyhow::bail!("unsupported spill protection envelope");
    }
    let key =
        super::load_cipher_key()?.context("encrypted spill payload requires SQLCipher key")?;
    let nonce = fixed_hex::<NONCE_LEN>(&envelope.nonce_hex).context("decode spill nonce")?;
    let mut ciphertext = hex_decode(&envelope.ciphertext_hex).context("decode spill ciphertext")?;
    let key = LessSafeKey::new(
        UnboundKey::new(&AES_256_GCM, &spill_key_bytes(&key)?)
            .map_err(|_| anyhow::anyhow!("initialize spill decryption key"))?,
    );
    let plaintext = key
        .open_in_place(
            Nonce::assume_unique_for_key(nonce),
            Aad::from(SPILL_AAD),
            &mut ciphertext,
        )
        .map_err(|_| anyhow::anyhow!("decrypt spill payload"))?;
    Ok(plaintext.to_vec())
}

fn spill_key_bytes(key: &CipherKey) -> Result<[u8; 32]> {
    match key {
        CipherKey::Raw(hex) => fixed_hex::<32>(hex),
        CipherKey::Passphrase(passphrase) => {
            let digest = Sha256::digest(passphrase.as_bytes());
            let mut bytes = [0_u8; 32];
            bytes.copy_from_slice(&digest);
            Ok(bytes)
        }
    }
}

fn fixed_hex<const N: usize>(value: &str) -> Result<[u8; N]> {
    let bytes = hex_decode(value)?;
    bytes
        .try_into()
        .map_err(|_| anyhow::anyhow!("expected {} hex-decoded bytes", N))
}

fn hex_encode(bytes: &[u8]) -> String {
    const HEX: &[u8; 16] = b"0123456789abcdef";
    let mut out = String::with_capacity(bytes.len() * 2);
    for byte in bytes {
        out.push(HEX[(byte >> 4) as usize] as char);
        out.push(HEX[(byte & 0x0f) as usize] as char);
    }
    out
}

fn hex_decode(value: &str) -> Result<Vec<u8>> {
    let bytes = value.as_bytes();
    if !bytes.len().is_multiple_of(2) {
        anyhow::bail!("hex value must have even length");
    }
    bytes
        .chunks_exact(2)
        .map(|pair| Ok((hex_nibble(pair[0])? << 4) | hex_nibble(pair[1])?))
        .collect()
}

fn hex_nibble(byte: u8) -> Result<u8> {
    match byte {
        b'0'..=b'9' => Ok(byte - b'0'),
        b'a'..=b'f' => Ok(byte - b'a' + 10),
        b'A'..=b'F' => Ok(byte - b'A' + 10),
        _ => anyhow::bail!("invalid hex byte"),
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::db::test_support::ScopedTestDataDir;

    #[test]
    fn protected_json_line_round_trips_without_plaintext() -> Result<()> {
        let _test_dir = ScopedTestDataDir::new("spill-crypto-protected");
        std::env::set_var("REMEM_CIPHER_KEY", format!("v2:{}", "1".repeat(64)));
        let value = serde_json::json!({"message": "assistant fallback content"});

        let line = encode_json_line(&value)?;

        assert!(line.contains(SPILL_PROTECTION));
        assert!(!line.contains("assistant fallback content"));
        let decoded: serde_json::Value = decode_json_line(&line)?;
        assert_eq!(decoded, value);
        Ok(())
    }

    #[test]
    fn plaintext_json_line_keeps_legacy_shape_without_key() -> Result<()> {
        let _test_dir = ScopedTestDataDir::new("spill-crypto-plaintext");
        std::env::remove_var("REMEM_CIPHER_KEY");
        let value = serde_json::json!({"message": "legacy plaintext"});

        let line = encode_json_line(&value)?;

        assert!(line.contains("legacy plaintext"));
        let decoded: serde_json::Value = decode_json_line(&line)?;
        assert_eq!(decoded, value);
        Ok(())
    }

    #[test]
    fn no_key_without_plaintext_override_refuses_plaintext_spill() {
        let _test_dir = ScopedTestDataDir::new("spill-crypto-no-plaintext");
        std::env::remove_var("REMEM_CIPHER_KEY");
        std::env::remove_var(crate::db::ALLOW_PLAINTEXT_ENV);

        let err = encode_json_line(&serde_json::json!({"message": "private"}))
            .expect_err("plaintext spill should require explicit override");

        assert!(err.to_string().contains("explicit plaintext"));
    }
}