shex 2.0.1

An OPAQUE-authenticated, end-to-end encrypted Redis remote shell
use std::{
    fs,
    io::Write,
    path::{Path, PathBuf},
};

use anyhow::{Context, Result, bail};
use chacha20poly1305::{
    ChaCha20Poly1305, KeyInit,
    aead::{Aead, Payload},
};
use keyring::v1::Entry;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};

const AUTH_VERSION: u8 = 2;
const KEYRING_SERVICE: &str = "shex";
const AUTH_DIRECTORY: &str = "auth";

#[derive(Serialize, Deserialize)]
pub struct StoredAuth {
    pub redis_url: String,
    pub hostname: String,
    pub server_signature: String,
    pub code: Vec<u8>,
    pub last_session: Option<String>,
}

#[derive(Serialize, Deserialize)]
struct Envelope {
    version: u8,
    key_id: String,
    nonce: [u8; 12],
    ciphertext: Vec<u8>,
}

pub struct AuthFile {
    pub data: StoredAuth,
    path: PathBuf,
    key_id: String,
}

impl AuthFile {
    pub fn create_for_host(data: StoredAuth, store_dir: &Path) -> Result<Self> {
        ensure_private_directory(store_dir)?;
        let auth_dir = store_dir.join(AUTH_DIRECTORY);
        ensure_private_directory(&auth_dir)?;
        let path = host_path(store_dir, &data.hostname);

        if path.exists() {
            let mut existing = Self::load(&path)?;
            let mut data = data;
            if existing.data.server_signature == data.server_signature
                && existing.data.redis_url == data.redis_url
            {
                data.last_session = existing.data.last_session.clone();
            }
            existing.data = data;
            existing.save()?;
            return Ok(existing);
        }

        let key_id = random_hex::<16>();
        let key: [u8; 32] = rand::random();
        keyring_entry(&key_id)?
            .set_secret(&key)
            .context("could not store the auth encryption key in the OS credential store")?;

        let auth = Self { data, path, key_id };
        if let Err(error) = auth.write_new(&key) {
            let _ = keyring_entry(&auth.key_id)
                .and_then(|entry| entry.delete_credential().map_err(Into::into));
            return Err(error);
        }
        Ok(auth)
    }

    pub fn load_for_host(store_dir: &Path, hostname: &str) -> Result<Self> {
        let path = host_path(store_dir, hostname);
        Self::load(&path).with_context(|| {
            format!("no saved authentication for `{hostname}`; run `shex auth {hostname}` first")
        })
    }

    pub fn load(path: &Path) -> Result<Self> {
        check_private_permissions(path)?;
        let bytes = fs::read(path)
            .with_context(|| format!("could not read auth file {}", path.display()))?;
        let envelope: Envelope =
            serde_json::from_slice(&bytes).context("invalid shex auth file")?;
        if envelope.version != AUTH_VERSION {
            bail!("unsupported shex auth file version {}", envelope.version);
        }
        let key = keyring_entry(&envelope.key_id)?
            .get_secret()
            .context("could not retrieve this auth file's key from the OS credential store")?;
        let data = decrypt(&envelope, &key)?;
        Ok(Self {
            data,
            path: path.to_owned(),
            key_id: envelope.key_id,
        })
    }

    pub fn save(&self) -> Result<()> {
        let key = keyring_entry(&self.key_id)?
            .get_secret()
            .context("could not retrieve this auth file's key from the OS credential store")?;
        let bytes = encrypt(&self.data, &self.key_id, &key)?;
        write_private_replace(&self.path, &bytes)
    }

    pub fn path(&self) -> &Path {
        &self.path
    }

    fn write_new(&self, key: &[u8]) -> Result<()> {
        let bytes = encrypt(&self.data, &self.key_id, key)?;
        write_private_new(&self.path, &bytes)
    }
}

pub fn default_store_dir() -> PathBuf {
    std::env::var_os("HOME")
        .or_else(|| std::env::var_os("USERPROFILE"))
        .map(PathBuf::from)
        .unwrap_or_else(|| PathBuf::from("."))
        .join(".shex")
}

fn host_path(store_dir: &Path, hostname: &str) -> PathBuf {
    let digest = Sha256::digest(hostname.as_bytes());
    let name: String = digest.iter().map(|byte| format!("{byte:02x}")).collect();
    store_dir.join(AUTH_DIRECTORY).join(format!("{name}.auth"))
}

fn keyring_entry(key_id: &str) -> Result<Entry> {
    Entry::new(KEYRING_SERVICE, key_id).context("OS credential store is unavailable")
}

#[cfg(unix)]
fn ensure_private_directory(path: &Path) -> Result<()> {
    use std::os::unix::fs::PermissionsExt;
    fs::create_dir_all(path).with_context(|| format!("could not create {}", path.display()))?;
    fs::set_permissions(path, fs::Permissions::from_mode(0o700))
        .with_context(|| format!("could not secure {}", path.display()))?;
    Ok(())
}

#[cfg(not(unix))]
fn ensure_private_directory(path: &Path) -> Result<()> {
    fs::create_dir_all(path).with_context(|| format!("could not create {}", path.display()))?;
    Ok(())
}

fn encrypt(data: &StoredAuth, key_id: &str, key: &[u8]) -> Result<Vec<u8>> {
    if key.len() != 32 {
        bail!("auth encryption key has an invalid length");
    }
    let nonce: [u8; 12] = rand::random();
    let cipher = ChaCha20Poly1305::new_from_slice(key)
        .map_err(|_| anyhow::anyhow!("invalid auth encryption key"))?;
    let plaintext = serde_json::to_vec(data)?;
    let ciphertext = cipher
        .encrypt(
            (&nonce).into(),
            Payload {
                msg: &plaintext,
                aad: key_id.as_bytes(),
            },
        )
        .map_err(|_| anyhow::anyhow!("could not encrypt auth file"))?;
    Ok(serde_json::to_vec(&Envelope {
        version: AUTH_VERSION,
        key_id: key_id.to_owned(),
        nonce,
        ciphertext,
    })?)
}

fn decrypt(envelope: &Envelope, key: &[u8]) -> Result<StoredAuth> {
    let cipher = ChaCha20Poly1305::new_from_slice(key)
        .map_err(|_| anyhow::anyhow!("invalid auth encryption key"))?;
    let plaintext = cipher
        .decrypt(
            (&envelope.nonce).into(),
            Payload {
                msg: &envelope.ciphertext,
                aad: envelope.key_id.as_bytes(),
            },
        )
        .map_err(|_| anyhow::anyhow!("auth file authentication failed"))?;
    serde_json::from_slice(&plaintext).context("invalid decrypted auth data")
}

fn random_hex<const N: usize>() -> String {
    let bytes: [u8; N] = rand::random();
    bytes.iter().map(|byte| format!("{byte:02x}")).collect()
}

#[cfg(unix)]
fn check_private_permissions(path: &Path) -> Result<()> {
    use std::os::unix::fs::PermissionsExt;
    let mode = fs::metadata(path)?.permissions().mode();
    if mode & 0o077 != 0 {
        bail!(
            "auth file {} is not private; run `chmod 600 {}`",
            path.display(),
            path.display()
        );
    }
    Ok(())
}

#[cfg(not(unix))]
fn check_private_permissions(_path: &Path) -> Result<()> {
    Ok(())
}

#[cfg(unix)]
fn write_private_new(path: &Path, bytes: &[u8]) -> Result<()> {
    use std::os::unix::fs::OpenOptionsExt;
    let mut file = fs::OpenOptions::new()
        .write(true)
        .create_new(true)
        .mode(0o600)
        .open(path)?;
    file.write_all(bytes)?;
    Ok(())
}

#[cfg(not(unix))]
fn write_private_new(path: &Path, bytes: &[u8]) -> Result<()> {
    let mut file = fs::OpenOptions::new()
        .write(true)
        .create_new(true)
        .open(path)?;
    file.write_all(bytes)?;
    Ok(())
}

#[cfg(unix)]
fn write_private_replace(path: &Path, bytes: &[u8]) -> Result<()> {
    use std::os::unix::fs::OpenOptionsExt;
    let mut file = fs::OpenOptions::new()
        .write(true)
        .truncate(true)
        .mode(0o600)
        .open(path)?;
    file.write_all(bytes)?;
    Ok(())
}

#[cfg(not(unix))]
fn write_private_replace(path: &Path, bytes: &[u8]) -> Result<()> {
    fs::write(path, bytes)?;
    Ok(())
}

#[cfg(test)]
mod tests {
    use super::{Envelope, StoredAuth, decrypt, encrypt, host_path};
    use std::path::Path;

    #[test]
    fn auth_payload_is_encrypted_and_authenticated() {
        let key = [7u8; 32];
        let data = StoredAuth {
            redis_url: "redis://127.0.0.1/".into(),
            hostname: "quiet-fox:1738".into(),
            server_signature: "server-a".into(),
            code: b"secret-code".to_vec(),
            last_session: Some("session-a".into()),
        };
        let bytes = encrypt(&data, "key-a", &key).unwrap();
        assert!(!bytes.windows(data.code.len()).any(|part| part == data.code));

        let envelope: Envelope = serde_json::from_slice(&bytes).unwrap();
        let restored = decrypt(&envelope, &key).unwrap();
        assert_eq!(restored.code, data.code);
        assert_eq!(restored.last_session, data.last_session);
        assert!(decrypt(&envelope, &[8u8; 32]).is_err());
    }

    #[test]
    fn host_auth_paths_are_deterministic_and_do_not_expose_hostnames() {
        let first = host_path(Path::new("/tmp/store"), "quiet-fox:1738");
        let second = host_path(Path::new("/tmp/store"), "quiet-fox:1738");
        assert_eq!(first, second);
        assert!(!first.to_string_lossy().contains("quiet-fox"));
        assert_eq!(
            first.extension().and_then(|value| value.to_str()),
            Some("auth")
        );
    }
}