feat(backend): support des providers OpenCode cloud (#92)

Ajoute le catalogue statique de providers OpenCode (lot B3), le stockage
sécurisé des secrets (SecretStore + adapter infrastructure), et les
use cases SaveOpenCodeProviderProfile/DeleteProfile câblés en composition
root. Couvre le fix B1 et les tests de régression demandés par QA.

cargo build --workspace propre, cargo test --workspace -- --test-threads=1
intégralement vert.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-23 08:03:03 +02:00
parent bece7c92c5
commit 23a3c2788f
20 changed files with 1311 additions and 55 deletions

View File

@ -0,0 +1,320 @@
//! [`FsSecretStore`] — encrypted-at-rest [`SecretStore`] adapter (ticket #92, lot B2).
//!
//! ```text
//! <app_data_dir>/
//! ├── secret.key # 32 raw bytes, AES-256-GCM key material, chmod 0600 (Unix)
//! └── secrets.json # { version, entries: { <secretRefId>: "<hex nonce+ciphertext>" } }
//! ```
//!
//! Unlike `profiles.json` (plain JSON), `secrets.json` never carries a plaintext
//! value: each entry is `nonce (12B) || AES-256-GCM(value)`, hex-encoded. The key
//! is generated once (random, `ring::rand::SystemRandom`) and persisted next to
//! it. No OS keyring integration in v1 (assumed debt, documented by Architect) —
//! a local attacker with read access to `secret.key` recovers every secret; the
//! win over `profiles.json` is that `secrets.json` alone (e.g. leaked in a backup
//! that excludes `secret.key`, or read by a process without access to the key
//! file's restrictive permissions) is inert ciphertext.
use std::sync::Arc;
use async_trait::async_trait;
use ring::aead::{self, BoundKey, Nonce, NonceSequence, OpeningKey, SealingKey, UnboundKey};
use ring::rand::{SecureRandom, SystemRandom};
use serde::{Deserialize, Serialize};
use domain::ports::{FileSystem, FsError, RemotePath, SecretRef, SecretStore, SecretStoreError};
const SECRETS_FILE: &str = "secrets.json";
const KEY_FILE: &str = "secret.key";
const SECRETS_VERSION: u32 = 1;
const KEY_LEN: usize = 32; // AES-256
const NONCE_LEN: usize = 12;
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct SecretsDoc {
version: u32,
/// `SecretRef` id -> hex(nonce || ciphertext || tag).
entries: std::collections::BTreeMap<String, String>,
}
impl Default for SecretsDoc {
fn default() -> Self {
Self {
version: SECRETS_VERSION,
entries: std::collections::BTreeMap::new(),
}
}
}
/// Single-use nonce sequence wrapping one 12-byte value (ring's streaming AEAD
/// API insists on a `NonceSequence`, but every seal/open here uses exactly one
/// fresh/parsed nonce, never a stream).
struct OnceNonce(Option<[u8; NONCE_LEN]>);
impl NonceSequence for OnceNonce {
fn advance(&mut self) -> Result<Nonce, ring::error::Unspecified> {
let bytes = self.0.take().ok_or(ring::error::Unspecified)?;
Ok(Nonce::assume_unique_for_key(bytes))
}
}
/// Encrypted-at-rest [`SecretStore`] port implementation.
///
/// Cheap to clone (everything behind `Arc`); built once at the composition root.
#[derive(Clone)]
pub struct FsSecretStore {
fs: Arc<dyn FileSystem>,
app_data_dir: String,
}
impl FsSecretStore {
/// Builds the store from an injected [`FileSystem`] and the app-data dir
/// (same machine-local directory as `profiles.json`).
#[must_use]
pub fn new(fs: Arc<dyn FileSystem>, app_data_dir: impl Into<String>) -> Self {
Self {
fs,
app_data_dir: app_data_dir.into(),
}
}
fn path(&self, file: &str) -> RemotePath {
let base = self.app_data_dir.trim_end_matches(['/', '\\']);
RemotePath::new(format!("{base}/{file}"))
}
/// Loads the key material, generating and persisting a fresh random key on
/// first use. The key file is written with `0600` permissions on Unix
/// (best-effort no-op elsewhere).
async fn load_or_init_key(&self) -> Result<[u8; KEY_LEN], SecretStoreError> {
let key_path = self.path(KEY_FILE);
match self.fs.read(&key_path).await {
Ok(bytes) => {
let key: [u8; KEY_LEN] = bytes
.as_slice()
.try_into()
.map_err(|_| SecretStoreError::Crypto("secret.key has wrong length".into()))?;
Ok(key)
}
Err(FsError::NotFound(_)) => {
let mut key = [0_u8; KEY_LEN];
SystemRandom::new()
.fill(&mut key)
.map_err(|_| SecretStoreError::Crypto("failed to generate secret key".into()))?;
let dir = RemotePath::new(self.app_data_dir.trim_end_matches(['/', '\\']).to_owned());
self.fs
.create_dir_all(&dir)
.await
.map_err(|e| SecretStoreError::Io(e.to_string()))?;
self.fs
.write(&key_path, &key)
.await
.map_err(|e| SecretStoreError::Io(e.to_string()))?;
restrict_permissions(key_path.as_str());
Ok(key)
}
Err(e) => Err(SecretStoreError::Io(e.to_string())),
}
}
async fn read_doc(&self) -> Result<SecretsDoc, SecretStoreError> {
match self.fs.read(&self.path(SECRETS_FILE)).await {
Ok(bytes) => serde_json::from_slice(&bytes)
.map_err(|e| SecretStoreError::Io(format!("secrets.json parse error: {e}"))),
Err(FsError::NotFound(_)) => Ok(SecretsDoc::default()),
Err(e) => Err(SecretStoreError::Io(e.to_string())),
}
}
async fn write_doc(&self, doc: &SecretsDoc) -> Result<(), SecretStoreError> {
let dir = RemotePath::new(self.app_data_dir.trim_end_matches(['/', '\\']).to_owned());
self.fs
.create_dir_all(&dir)
.await
.map_err(|e| SecretStoreError::Io(e.to_string()))?;
let bytes = serde_json::to_vec_pretty(doc)
.map_err(|e| SecretStoreError::Io(format!("secrets.json serialise error: {e}")))?;
self.fs
.write(&self.path(SECRETS_FILE), &bytes)
.await
.map_err(|e| SecretStoreError::Io(e.to_string()))
}
fn seal(key: &[u8; KEY_LEN], plaintext: &str) -> Result<String, SecretStoreError> {
let mut nonce_bytes = [0_u8; NONCE_LEN];
SystemRandom::new()
.fill(&mut nonce_bytes)
.map_err(|_| SecretStoreError::Crypto("failed to generate nonce".into()))?;
let unbound = UnboundKey::new(&aead::AES_256_GCM, key)
.map_err(|_| SecretStoreError::Crypto("invalid key material".into()))?;
let mut sealing = SealingKey::new(unbound, OnceNonce(Some(nonce_bytes)));
let mut in_out = plaintext.as_bytes().to_vec();
sealing
.seal_in_place_append_tag(aead::Aad::empty(), &mut in_out)
.map_err(|_| SecretStoreError::Crypto("seal failed".into()))?;
let mut out = Vec::with_capacity(NONCE_LEN + in_out.len());
out.extend_from_slice(&nonce_bytes);
out.extend_from_slice(&in_out);
Ok(hex::encode(out))
}
fn open(key: &[u8; KEY_LEN], encoded: &str) -> Result<String, SecretStoreError> {
let bytes =
hex::decode(encoded).map_err(|e| SecretStoreError::Crypto(format!("bad hex: {e}")))?;
if bytes.len() < NONCE_LEN {
return Err(SecretStoreError::Crypto("ciphertext too short".into()));
}
let (nonce_bytes, ciphertext) = bytes.split_at(NONCE_LEN);
let mut nonce_arr = [0_u8; NONCE_LEN];
nonce_arr.copy_from_slice(nonce_bytes);
let unbound = UnboundKey::new(&aead::AES_256_GCM, key)
.map_err(|_| SecretStoreError::Crypto("invalid key material".into()))?;
let mut opening = OpeningKey::new(unbound, OnceNonce(Some(nonce_arr)));
let mut in_out = ciphertext.to_vec();
let plaintext = opening
.open_in_place(aead::Aad::empty(), &mut in_out)
.map_err(|_| SecretStoreError::Crypto("decryption failed".into()))?;
String::from_utf8(plaintext.to_vec())
.map_err(|e| SecretStoreError::Crypto(format!("decrypted value is not utf8: {e}")))
}
}
#[async_trait]
impl SecretStore for FsSecretStore {
async fn put(&self, key: &SecretRef, value: &str) -> Result<(), SecretStoreError> {
let enc_key = self.load_or_init_key().await?;
let mut doc = self.read_doc().await?;
let sealed = Self::seal(&enc_key, value)?;
doc.entries.insert(key.as_str().to_owned(), sealed);
self.write_doc(&doc).await
}
async fn get(&self, key: &SecretRef) -> Result<Option<String>, SecretStoreError> {
let doc = self.read_doc().await?;
let Some(sealed) = doc.entries.get(key.as_str()) else {
return Ok(None);
};
let enc_key = self.load_or_init_key().await?;
Self::open(&enc_key, sealed).map(Some)
}
async fn delete(&self, key: &SecretRef) -> Result<(), SecretStoreError> {
let mut doc = self.read_doc().await?;
doc.entries.remove(key.as_str());
self.write_doc(&doc).await
}
}
#[cfg(unix)]
fn restrict_permissions(path: &str) {
use std::os::unix::fs::PermissionsExt;
if let Ok(metadata) = std::fs::metadata(path) {
let mut perms = metadata.permissions();
perms.set_mode(0o600);
let _ = std::fs::set_permissions(path, perms);
}
}
#[cfg(not(unix))]
fn restrict_permissions(_path: &str) {}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
struct TempDir(PathBuf);
impl TempDir {
fn new(label: &str) -> Self {
let root = std::env::temp_dir().join(format!("idea-secrets-store-{label}-{}", uuid::Uuid::new_v4()));
std::fs::create_dir_all(&root).unwrap();
Self(root)
}
}
impl Drop for TempDir {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
fn store(dir: &TempDir) -> FsSecretStore {
FsSecretStore::new(
Arc::new(crate::fs::LocalFileSystem::new()),
dir.0.to_string_lossy().into_owned(),
)
}
#[tokio::test]
async fn put_then_get_round_trips_plaintext() {
let dir = TempDir::new("roundtrip");
let store = store(&dir);
let key = SecretRef::new("secret-a");
store.put(&key, "sk-live-abc123").await.unwrap();
let got = store.get(&key).await.unwrap();
assert_eq!(got, Some("sk-live-abc123".to_owned()));
}
#[tokio::test]
async fn get_missing_key_returns_none() {
let dir = TempDir::new("missing");
let store = store(&dir);
let got = store.get(&SecretRef::new("nope")).await.unwrap();
assert_eq!(got, None);
}
#[tokio::test]
async fn delete_removes_the_entry() {
let dir = TempDir::new("delete");
let store = store(&dir);
let key = SecretRef::new("secret-b");
store.put(&key, "value").await.unwrap();
store.delete(&key).await.unwrap();
assert_eq!(store.get(&key).await.unwrap(), None);
}
#[tokio::test]
async fn secrets_file_never_contains_the_plaintext_value() {
let dir = TempDir::new("plaintext-leak");
let store = store(&dir);
store
.put(&SecretRef::new("secret-c"), "super-secret-literal")
.await
.unwrap();
let raw = tokio::fs::read_to_string(dir.0.join(SECRETS_FILE))
.await
.unwrap();
assert!(!raw.contains("super-secret-literal"));
}
#[tokio::test]
async fn key_file_has_owner_only_permissions_on_unix() {
let dir = TempDir::new("perms");
let store = store(&dir);
store.put(&SecretRef::new("secret-d"), "value").await.unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let metadata = std::fs::metadata(dir.0.join(KEY_FILE)).unwrap();
assert_eq!(metadata.permissions().mode() & 0o777, 0o600);
}
}
#[tokio::test]
async fn put_reuses_the_same_key_across_calls() {
let dir = TempDir::new("reuse-key");
let store = store(&dir);
store.put(&SecretRef::new("a"), "value-a").await.unwrap();
store.put(&SecretRef::new("b"), "value-b").await.unwrap();
assert_eq!(
store.get(&SecretRef::new("a")).await.unwrap(),
Some("value-a".to_owned())
);
assert_eq!(
store.get(&SecretRef::new("b")).await.unwrap(),
Some("value-b".to_owned())
);
}
}