openlatch-provider 0.1.0

Self-service onboarding CLI + runtime daemon for OpenLatch Editors and Providers
//! Binding-secret store — the runtime daemon's HMAC verification keys.
//!
//! Per Codex review #7, binding secrets (`whsec_<base64>`) are revealed
//! exactly once at `register` or `bindings rotate-secret`. The runtime
//! (`listen`) needs them at startup for HMAC verification — without a local
//! store, every restart would require re-rotating.
//!
//! Kept separate from the editor-token store (service `openlatch-provider`)
//! so:
//!   - `logout` clears the editor token but NOT binding secrets (the runtime
//!     keeps serving traffic)
//!   - `bindings delete-secret <id>` clears one binding without touching the
//!     editor token
//!   - loss of editor token doesn't lose runtime state
//!
//! Storage:
//!   - keyring service: `openlatch-provider-binding`, username = `binding_id`
//!   - file fallback (when keyring is unavailable):
//!     `~/.openlatch/provider/binding-secrets/<binding_id>.enc`
//!     AES-256-GCM, key derived via HKDF-SHA256 over `machine_id`

use std::path::{Path, PathBuf};

use secrecy::{ExposeSecret, SecretString};

use crate::auth::file::{decrypt_credential, encrypt_credential};
use crate::error::{OlError, OL_4201_KEYRING_UNAVAILABLE, OL_4204_TOKEN_FILE_UNREADABLE};

const SERVICE_NAME: &str = "openlatch-provider-binding";
const SKIP_KEYRING_ENV: &str = "OPENLATCH_PROVIDER_SKIP_KEYRING";

pub trait BindingSecretStore: Send + Sync {
    fn store(&self, binding_id: &str, secret: SecretString) -> Result<(), OlError>;
    fn retrieve(&self, binding_id: &str) -> Result<SecretString, OlError>;
    fn list_known(&self) -> Result<Vec<String>, OlError>;
    fn delete(&self, binding_id: &str) -> Result<(), OlError>;
}

// ---------------------------------------------------------------------------
// Keyring backend
// ---------------------------------------------------------------------------

pub struct KeyringBindingSecretStore;

impl Default for KeyringBindingSecretStore {
    fn default() -> Self {
        Self::new()
    }
}

impl KeyringBindingSecretStore {
    pub fn new() -> Self {
        Self
    }
}

fn keyring_disabled_by_env() -> bool {
    match std::env::var(SKIP_KEYRING_ENV) {
        Ok(v) => {
            let v = v.trim().to_ascii_lowercase();
            !matches!(v.as_str(), "" | "0" | "false" | "no" | "off")
        }
        Err(_) => false,
    }
}

impl BindingSecretStore for KeyringBindingSecretStore {
    fn store(&self, binding_id: &str, secret: SecretString) -> Result<(), OlError> {
        if keyring_disabled_by_env() {
            return Err(OlError::new(
                OL_4201_KEYRING_UNAVAILABLE,
                "keyring disabled via OPENLATCH_PROVIDER_SKIP_KEYRING",
            ));
        }
        let entry = keyring::Entry::new(SERVICE_NAME, binding_id)
            .map_err(|e| OlError::new(OL_4201_KEYRING_UNAVAILABLE, format!("keyring open: {e}")))?;
        entry
            .set_password(secret.expose_secret())
            .map_err(|e| OlError::new(OL_4201_KEYRING_UNAVAILABLE, format!("keyring set: {e}")))
    }

    fn retrieve(&self, binding_id: &str) -> Result<SecretString, OlError> {
        if keyring_disabled_by_env() {
            return Err(OlError::new(
                OL_4201_KEYRING_UNAVAILABLE,
                "keyring disabled via OPENLATCH_PROVIDER_SKIP_KEYRING",
            ));
        }
        let entry = keyring::Entry::new(SERVICE_NAME, binding_id)
            .map_err(|e| OlError::new(OL_4201_KEYRING_UNAVAILABLE, format!("keyring open: {e}")))?;
        let pwd = entry
            .get_password()
            .map_err(|e| OlError::new(OL_4201_KEYRING_UNAVAILABLE, format!("keyring get: {e}")))?;
        Ok(SecretString::from(pwd))
    }

    /// Listing keyring entries for a service is non-portable — keyring v3 has
    /// no API for it. We return an empty list and rely on the manifest to
    /// know which binding ids exist; the runtime then calls `retrieve` on
    /// each. Use the file fallback's `list_known` when keyring is skipped.
    fn list_known(&self) -> Result<Vec<String>, OlError> {
        Ok(Vec::new())
    }

    fn delete(&self, binding_id: &str) -> Result<(), OlError> {
        let entry = keyring::Entry::new(SERVICE_NAME, binding_id)
            .map_err(|e| OlError::new(OL_4201_KEYRING_UNAVAILABLE, format!("keyring open: {e}")))?;
        match entry.delete_credential() {
            Ok(()) => Ok(()),
            Err(keyring::Error::NoEntry) => Ok(()), // idempotent
            Err(e) => Err(OlError::new(
                OL_4201_KEYRING_UNAVAILABLE,
                format!("keyring delete: {e}"),
            )),
        }
    }
}

// ---------------------------------------------------------------------------
// File backend
// ---------------------------------------------------------------------------

pub struct FileBindingSecretStore {
    dir: PathBuf,
    machine_id: String,
}

impl FileBindingSecretStore {
    pub fn new(dir: impl Into<PathBuf>, machine_id: impl Into<String>) -> Self {
        Self {
            dir: dir.into(),
            machine_id: machine_id.into(),
        }
    }

    fn entry_path(&self, binding_id: &str) -> PathBuf {
        self.dir.join(format!("{binding_id}.enc"))
    }

    fn ensure_dir(&self) -> Result<(), OlError> {
        std::fs::create_dir_all(&self.dir).map_err(|e| {
            OlError::new(
                OL_4204_TOKEN_FILE_UNREADABLE,
                format!("cannot create {}: {e}", self.dir.display()),
            )
        })?;
        #[cfg(unix)]
        {
            use std::os::unix::fs::PermissionsExt;
            let _ = std::fs::set_permissions(&self.dir, std::fs::Permissions::from_mode(0o700));
        }
        Ok(())
    }
}

impl BindingSecretStore for FileBindingSecretStore {
    fn store(&self, binding_id: &str, secret: SecretString) -> Result<(), OlError> {
        self.ensure_dir()?;
        let plaintext = secret.expose_secret().as_bytes().to_vec();
        let blob = encrypt_credential(&plaintext, &self.machine_id);
        let path = self.entry_path(binding_id);
        let tmp = path.with_extension("enc.tmp");
        std::fs::write(&tmp, &blob).map_err(|e| {
            OlError::new(
                OL_4204_TOKEN_FILE_UNREADABLE,
                format!("write {}: {e}", tmp.display()),
            )
        })?;
        std::fs::rename(&tmp, &path).map_err(|e| {
            OlError::new(
                OL_4204_TOKEN_FILE_UNREADABLE,
                format!("rename {}: {e}", path.display()),
            )
        })?;
        Ok(())
    }

    fn retrieve(&self, binding_id: &str) -> Result<SecretString, OlError> {
        let path = self.entry_path(binding_id);
        let blob = std::fs::read(&path).map_err(|e| {
            OlError::new(
                OL_4204_TOKEN_FILE_UNREADABLE,
                format!("read {}: {e}", path.display()),
            )
        })?;
        let plaintext = decrypt_credential(&blob, &self.machine_id)?;
        let s = String::from_utf8(plaintext).map_err(|e| {
            OlError::new(
                OL_4204_TOKEN_FILE_UNREADABLE,
                format!("non-utf8 secret: {e}"),
            )
        })?;
        Ok(SecretString::from(s))
    }

    fn list_known(&self) -> Result<Vec<String>, OlError> {
        if !self.dir.is_dir() {
            return Ok(Vec::new());
        }
        let mut out = Vec::new();
        for entry in std::fs::read_dir(&self.dir).map_err(|e| {
            OlError::new(
                OL_4204_TOKEN_FILE_UNREADABLE,
                format!("read {}: {e}", self.dir.display()),
            )
        })? {
            let entry = entry.map_err(|e| {
                OlError::new(OL_4204_TOKEN_FILE_UNREADABLE, format!("read entry: {e}"))
            })?;
            let p = entry.path();
            if p.is_file() && p.extension().and_then(|e| e.to_str()) == Some("enc") {
                if let Some(stem) = p.file_stem().and_then(|s| s.to_str()) {
                    out.push(stem.to_string());
                }
            }
        }
        out.sort();
        Ok(out)
    }

    fn delete(&self, binding_id: &str) -> Result<(), OlError> {
        let path = self.entry_path(binding_id);
        match std::fs::remove_file(&path) {
            Ok(()) => Ok(()),
            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
            Err(e) => Err(OlError::new(
                OL_4204_TOKEN_FILE_UNREADABLE,
                format!("delete {}: {e}", path.display()),
            )),
        }
    }
}

/// Default location for the file backend.
pub fn default_file_dir(provider_dir: &Path) -> PathBuf {
    provider_dir.join("binding-secrets")
}

#[cfg(test)]
mod tests {
    use super::*;
    use tempfile::TempDir;

    #[test]
    fn file_store_round_trips_secret() {
        let tmp = TempDir::new().unwrap();
        let store = FileBindingSecretStore::new(tmp.path(), "mach_test");
        let secret = SecretString::from("whsec_live_abc123".to_string());
        store.store("bnd_42", secret).unwrap();
        let got = store.retrieve("bnd_42").unwrap();
        assert_eq!(got.expose_secret(), "whsec_live_abc123");
    }

    #[test]
    fn file_store_list_known_returns_stored_ids() {
        let tmp = TempDir::new().unwrap();
        let store = FileBindingSecretStore::new(tmp.path(), "mach_test");
        store
            .store("bnd_a", SecretString::from("s1".to_string()))
            .unwrap();
        store
            .store("bnd_b", SecretString::from("s2".to_string()))
            .unwrap();
        let ids = store.list_known().unwrap();
        assert_eq!(ids, vec!["bnd_a".to_string(), "bnd_b".to_string()]);
    }

    #[test]
    fn file_store_delete_is_idempotent() {
        let tmp = TempDir::new().unwrap();
        let store = FileBindingSecretStore::new(tmp.path(), "mach_test");
        store.delete("bnd_missing").unwrap();
        store
            .store("bnd_x", SecretString::from("x".to_string()))
            .unwrap();
        store.delete("bnd_x").unwrap();
        store.delete("bnd_x").unwrap(); // second call still succeeds
    }

    #[test]
    fn file_store_decrypt_fails_with_wrong_machine_id() {
        let tmp = TempDir::new().unwrap();
        let s1 = FileBindingSecretStore::new(tmp.path(), "mach_a");
        s1.store("bnd_a", SecretString::from("secret".to_string()))
            .unwrap();
        let s2 = FileBindingSecretStore::new(tmp.path(), "mach_DIFFERENT");
        let err = s2.retrieve("bnd_a").unwrap_err();
        assert_eq!(err.code.code, "OL-4204");
    }
}