diff --git a/Cargo.lock b/Cargo.lock index 7eb635ef6..d02ab04fc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8,6 +8,41 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common", + "generic-array", +] + +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures 0.2.17", +] + +[[package]] +name = "aes-gcm" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" +dependencies = [ + "aead", + "aes", + "cipher", + "ctr", + "ghash", + "subtle", +] + [[package]] name = "aho-corasick" version = "1.1.5" @@ -396,6 +431,16 @@ dependencies = [ "phf", ] +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", +] + [[package]] name = "concurrent-queue" version = "2.5.0" @@ -501,9 +546,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" dependencies = [ "generic-array", + "rand_core 0.6.4", "typenum", ] +[[package]] +name = "ctr" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" +dependencies = [ + "cipher", +] + [[package]] name = "der" version = "0.7.10" @@ -783,6 +838,7 @@ dependencies = [ name = "devforge-server" version = "2.0.234" dependencies = [ + "aes-gcm", "async-trait", "axum", "base64", @@ -810,15 +866,19 @@ dependencies = [ "devforge-wireguard", "dotenvy", "futures-util", + "hmac", + "jsonwebtoken", "once_cell", "png", "qrcode", + "rand 0.8.8", "regex", "reqwest", "serde", "serde_json", "sha2", "sqlx", + "subtle", "tokio", "tokio-util", "tower-http", @@ -828,6 +888,7 @@ dependencies = [ "urlencoding", "uuid", "windows-sys 0.61.2", + "zeroize", ] [[package]] @@ -1222,6 +1283,16 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "ghash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1" +dependencies = [ + "opaque-debug", + "polyval", +] + [[package]] name = "hashbrown" version = "0.15.5" @@ -1587,6 +1658,15 @@ dependencies = [ "hashbrown 0.17.1", ] +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "generic-array", +] + [[package]] name = "ipnet" version = "2.12.2" @@ -1610,6 +1690,21 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "jsonwebtoken" +version = "9.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a87cc7a48537badeae96744432de36f4be2b4a34a05a5ef32e9dd8a1c169dde" +dependencies = [ + "base64", + "js-sys", + "pem", + "ring", + "serde", + "serde_json", + "simple_asn1", +] + [[package]] name = "keyboard-types" version = "0.8.3" @@ -1846,6 +1941,16 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "num-bigint" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" +dependencies = [ + "num-integer", + "num-traits", +] + [[package]] name = "num-bigint-dig" version = "0.8.6" @@ -1963,6 +2068,12 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + [[package]] name = "ordered-stream" version = "0.2.0" @@ -2019,6 +2130,16 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2ee67f1008b1ba2321834326597b8e186293b049a023cdef258527550b9935b4" +[[package]] +name = "pem" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" +dependencies = [ + "base64", + "serde_core", +] + [[package]] name = "pem-rfc7468" version = "0.7.0" @@ -2129,6 +2250,18 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "polyval" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "opaque-debug", + "universal-hash", +] + [[package]] name = "powerfmt" version = "0.2.0" @@ -2657,6 +2790,18 @@ version = "0.3.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" +[[package]] +name = "simple_asn1" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d585997b0ac10be3c5ee635f1bab02d512760d14b7c468801ac8a01d9ae5f1d" +dependencies = [ + "num-bigint", + "num-traits", + "thiserror", + "time", +] + [[package]] name = "siphasher" version = "1.0.3" @@ -3351,6 +3496,16 @@ version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common", + "subtle", +] + [[package]] name = "untrusted" version = "0.9.0" diff --git a/apps/server/Cargo.toml b/apps/server/Cargo.toml index 397b11850..e1bef7044 100644 --- a/apps/server/Cargo.toml +++ b/apps/server/Cargo.toml @@ -50,6 +50,13 @@ base64 = "0.22" reqwest = { workspace = true } urlencoding = "2.1" once_cell = "1.20" +# Coffre de secrets (GitHub App P0) : AEAD au repos + primitives pour JWT/HMAC des phases suivantes. +aes-gcm = "0.10" +rand = "0.8" +zeroize = { workspace = true } +jsonwebtoken = "9" +hmac = "0.12" +subtle = "2.6" png = "0.18" [target.'cfg(target_os = "linux")'.dependencies] diff --git a/apps/server/src/db.rs b/apps/server/src/db.rs index fae037e6e..586e0db4e 100644 --- a/apps/server/src/db.rs +++ b/apps/server/src/db.rs @@ -1017,9 +1017,100 @@ pub async fn migrate(pool: &PgPool) -> Result<(), sqlx::Error> { // Mémoire durable des agents (P3-01) : table, index, pg_trgm facultatif. devforge_memory::migrate(pool).await?; + // GitHub App (P0) : schéma + scellement des PAT existants. + migrate_github_app(pool).await?; + Ok(()) } +/// GitHub App (P0-03/P0-04) : tables dédiées, colonnes projet, PAT scellés au repos. +pub async fn migrate_github_app(pool: &PgPool) -> Result<(), sqlx::Error> { + for sql in [ + r#"CREATE TABLE IF NOT EXISTS github_app ( + id INTEGER PRIMARY KEY CHECK (id = 1), + app_id BIGINT NOT NULL, slug TEXT NOT NULL, client_id TEXT NOT NULL, + client_secret_enc TEXT NOT NULL, webhook_secret_enc TEXT NOT NULL, + private_key_enc TEXT NOT NULL, html_url TEXT NOT NULL, + owner_login TEXT NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT now())"#, + r#"CREATE TABLE IF NOT EXISTS github_installations ( + installation_id BIGINT PRIMARY KEY, account_login TEXT NOT NULL, + account_type TEXT NOT NULL, repository_selection TEXT NOT NULL, + permissions JSONB NOT NULL DEFAULT '{}', suspended_at TIMESTAMPTZ, + created_by_user_uuid TEXT, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now())"#, + r#"CREATE TABLE IF NOT EXISTS github_installation_repos ( + installation_id BIGINT NOT NULL REFERENCES github_installations ON DELETE CASCADE, + repo_id BIGINT NOT NULL, full_name TEXT NOT NULL, private BOOLEAN NOT NULL, + default_branch TEXT, PRIMARY KEY (installation_id, repo_id))"#, + r#"CREATE TABLE IF NOT EXISTS github_webhook_deliveries ( + delivery_id TEXT PRIMARY KEY, event TEXT NOT NULL, + received_at TIMESTAMPTZ NOT NULL DEFAULT now(), status TEXT NOT NULL)"#, + r#"CREATE TABLE IF NOT EXISTS github_manifest_states ( + state TEXT PRIMARY KEY, user_uuid TEXT NOT NULL, + expires_at TIMESTAMPTZ NOT NULL)"#, + r#"CREATE TABLE IF NOT EXISTS pr_previews ( + project_uuid TEXT NOT NULL, pr_number INTEGER NOT NULL, head_sha TEXT NOT NULL, + url TEXT, deployment_uuid TEXT, comment_id BIGINT, check_run_id BIGINT, + status TEXT NOT NULL, updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (project_uuid, pr_number))"#, + ] { + sqlx::query(sql).execute(pool).await?; + } + // Échec toléré : colonne déjà présente. + for (col, def) in [ + ("github_installation_id", "BIGINT"), + ("github_repo_id", "BIGINT"), + ("pr_previews_enabled", "BOOLEAN NOT NULL DEFAULT true"), + ("legacy_webhook_id", "BIGINT"), + ] { + let sql = format!("ALTER TABLE projects ADD COLUMN {col} {def}"); + let _ = sqlx::query(&sql).execute(pool).await; + } + seal_plain_github_tokens(pool).await; + Ok(()) +} + +/// Scelle (`v1:`) les PAT encore en clair. Sans coffre disponible, rien n'est modifié. +async fn seal_plain_github_tokens(pool: &PgPool) { + if crate::secret_box::global().is_err() { + return; + } + let users: Vec<(String, String)> = sqlx::query_as( + "SELECT user_uuid, github_token FROM user_settings WHERE github_token <> '' AND github_token NOT LIKE 'v1:%'", + ) + .fetch_all(pool) + .await + .unwrap_or_default(); + for (uuid, tok) in users { + let sealed = crate::secret_box::seal_compat(&tok); + let _ = sqlx::query( + "UPDATE user_settings SET github_token = $1 WHERE user_uuid = $2 AND github_token = $3", + ) + .bind(&sealed) + .bind(&uuid) + .bind(&tok) + .execute(pool) + .await; + } + let inst: Option<(String,)> = sqlx::query_as( + "SELECT github_token FROM instance_settings WHERE id = 1 AND github_token <> '' AND github_token NOT LIKE 'v1:%'", + ) + .fetch_optional(pool) + .await + .ok() + .flatten(); + if let Some((tok,)) = inst { + let sealed = crate::secret_box::seal_compat(&tok); + let _ = sqlx::query( + "UPDATE instance_settings SET github_token = $1 WHERE id = 1 AND github_token = $2", + ) + .bind(&sealed) + .bind(&tok) + .execute(pool) + .await; + } +} + /// Remove leftover demo fixtures from earlier greenfield seeds. pub async fn purge_demo_data(pool: &PgPool) -> Result<(), sqlx::Error> { let demo_uuid = "uji97r70f1jaq9m7l9btm61d"; diff --git a/apps/server/src/dev_env.rs b/apps/server/src/dev_env.rs index 85650b10f..806ea1422 100644 --- a/apps/server/src/dev_env.rs +++ b/apps/server/src/dev_env.rs @@ -541,7 +541,7 @@ pub(crate) async fn run_dev_deploy( .await .ok() .flatten() - .map(|(t,)| t) + .map(|(t,)| crate::secret_box::open_compat(&t)) .filter(|t| !t.trim().is_empty()); // Mêmes variables que la prod (jamais loguées). diff --git a/apps/server/src/main.rs b/apps/server/src/main.rs index ebf55c7a0..1458e20c7 100644 --- a/apps/server/src/main.rs +++ b/apps/server/src/main.rs @@ -49,6 +49,7 @@ mod run_events; mod run_routes; mod runner_routes; mod runner_store; +mod secret_box; mod security; mod spec_routes; mod sso; diff --git a/apps/server/src/routes.rs b/apps/server/src/routes.rs index ac32a5d65..9a7ee2d65 100644 --- a/apps/server/src/routes.rs +++ b/apps/server/src/routes.rs @@ -1470,7 +1470,7 @@ pub(crate) async fn run_real_deploy( .await .ok() .flatten() - .map(|(t,)| t) + .map(|(t,)| crate::secret_box::open_compat(&t)) .filter(|t| !t.trim().is_empty()); match crate::project_oidc::sync_project_oidc_client(&state.pool, project, true).await { diff --git a/apps/server/src/secret_box.rs b/apps/server/src/secret_box.rs new file mode 100644 index 000000000..d0f6fecf7 --- /dev/null +++ b/apps/server/src/secret_box.rs @@ -0,0 +1,237 @@ +//! Coffre de secrets : chiffrement au repos AES-256-GCM. +//! +//! Clé maître : `DEVFORGE_SECRET_KEY` (32 octets encodés en base64). À défaut, une clé est +//! générée au premier lancement dans `$DEVFORGE_DATA_DIR/secret.key` (permissions 0600). +//! Format stocké : `v1::`. +//! +//! Aucune valeur secrète (clé ou clair) n'est jamais journalisée. + +use std::fmt; +use std::path::{Path, PathBuf}; + +use aes_gcm::aead::{Aead, AeadCore, KeyInit, OsRng}; +use aes_gcm::{Aes256Gcm, Key, Nonce}; +use base64::engine::general_purpose::STANDARD as B64; +use base64::Engine; +use once_cell::sync::OnceCell; +use rand::RngCore; +use zeroize::Zeroizing; + +pub const PREFIX: &str = "v1:"; +pub const KEY_ENV: &str = "DEVFORGE_SECRET_KEY"; +pub const KEY_FILE: &str = "secret.key"; + +#[derive(Debug, PartialEq, Eq)] +pub enum SecretError { + /// Clé maître absente ou invalide (message sans secret). + Key(String), + /// Valeur mal formée (préfixe, base64, nonce). + Format, + /// Échec d'authentification : valeur altérée ou mauvaise clé. + Decrypt, +} + +impl fmt::Display for SecretError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + SecretError::Key(m) => write!(f, "clé maître indisponible : {m}"), + SecretError::Format => write!(f, "secret scellé mal formé"), + SecretError::Decrypt => write!( + f, + "déchiffrement impossible (secret altéré ou mauvaise clé)" + ), + } + } +} + +impl std::error::Error for SecretError {} + +/// Enveloppe dont `Debug`/`Display` affichent `***`, pour ne jamais logguer un secret. +#[derive(Clone, Default, PartialEq, Eq)] +pub struct Redacted(pub T); + +impl Redacted { + pub fn new(v: T) -> Self { + Self(v) + } + pub fn expose(&self) -> &T { + &self.0 + } +} + +impl fmt::Debug for Redacted { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("***") + } +} + +impl fmt::Display for Redacted { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("***") + } +} + +pub struct SecretBox { + cipher: Aes256Gcm, +} + +impl fmt::Debug for SecretBox { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("SecretBox(***)") + } +} + +impl SecretBox { + pub fn from_key(key: &[u8; 32]) -> Self { + Self { + cipher: Aes256Gcm::new(Key::::from_slice(key)), + } + } + + /// Clé encodée base64 (env ou fichier) → coffre. + pub fn from_b64(raw: &str) -> Result { + let bytes = Zeroizing::new( + B64.decode(raw.trim()) + .map_err(|_| SecretError::Key("base64 invalide".into()))?, + ); + if bytes.len() != 32 { + return Err(SecretError::Key("la clé doit faire 32 octets".into())); + } + let mut key = Zeroizing::new([0u8; 32]); + key.copy_from_slice(&bytes); + Ok(Self::from_key(&key)) + } + + /// Charge la clé depuis `DEVFORGE_SECRET_KEY`, sinon depuis/vers `/secret.key`. + pub fn load(env_value: Option<&str>, data_dir: &Path) -> Result { + if let Some(v) = env_value.map(str::trim).filter(|v| !v.is_empty()) { + return Self::from_b64(v); + } + let path = data_dir.join(KEY_FILE); + if path.is_file() { + let raw = Zeroizing::new( + std::fs::read_to_string(&path) + .map_err(|e| SecretError::Key(format!("lecture {} : {e}", path.display())))?, + ); + return Self::from_b64(&raw); + } + let mut key = Zeroizing::new([0u8; 32]); + rand::rngs::OsRng.fill_bytes(key.as_mut()); + let encoded = Zeroizing::new(B64.encode(key.as_ref())); + write_key_file(&path, encoded.as_bytes())?; + tracing::info!("clé maître du coffre générée dans {}", path.display()); + Ok(Self::from_key(&key)) + } + + pub fn seal(&self, plaintext: &[u8]) -> String { + let nonce = Aes256Gcm::generate_nonce(&mut OsRng); + // AES-GCM ne peut échouer qu'au-delà de ~64 Gio de clair. + let ct = self + .cipher + .encrypt(&nonce, plaintext) + .expect("AES-GCM encrypt"); + format!("{PREFIX}{}:{}", B64.encode(nonce), B64.encode(ct)) + } + + pub fn open(&self, sealed: &str) -> Result>, SecretError> { + let rest = sealed.strip_prefix(PREFIX).ok_or(SecretError::Format)?; + let (n, c) = rest.split_once(':').ok_or(SecretError::Format)?; + let nonce = B64.decode(n).map_err(|_| SecretError::Format)?; + if nonce.len() != 12 { + return Err(SecretError::Format); + } + let ct = B64.decode(c).map_err(|_| SecretError::Format)?; + self.cipher + .decrypt(Nonce::from_slice(&nonce), ct.as_ref()) + .map(Zeroizing::new) + .map_err(|_| SecretError::Decrypt) + } +} + +fn write_key_file(path: &Path, content: &[u8]) -> Result<(), SecretError> { + use std::io::Write; + if let Some(dir) = path.parent() { + std::fs::create_dir_all(dir) + .map_err(|e| SecretError::Key(format!("création {} : {e}", dir.display())))?; + } + let mut opts = std::fs::OpenOptions::new(); + opts.write(true).create_new(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + opts.mode(0o600); + } + let mut f = opts + .open(path) + .map_err(|e| SecretError::Key(format!("écriture {} : {e}", path.display())))?; + f.write_all(content) + .and_then(|_| f.sync_all()) + .map_err(|e| SecretError::Key(format!("écriture {} : {e}", path.display())))?; + Ok(()) +} + +pub fn is_sealed(v: &str) -> bool { + v.starts_with(PREFIX) +} + +fn data_dir() -> PathBuf { + PathBuf::from(std::env::var("DEVFORGE_DATA_DIR").unwrap_or_else(|_| "/data".into())) +} + +static GLOBAL: OnceCell = OnceCell::new(); + +/// Coffre global de l'instance (initialisé au démarrage, ou paresseusement). +pub fn global() -> Result<&'static SecretBox, SecretError> { + GLOBAL.get_or_try_init(|| { + let env = std::env::var(KEY_ENV).ok(); + SecretBox::load(env.as_deref(), &data_dir()) + }) +} + +/// Initialise le coffre au démarrage ; une erreur est journalisée sans secret. +pub fn init() { + if let Err(e) = global() { + tracing::error!("coffre de secrets indisponible : {e} — les tokens restent en clair"); + } +} + +pub fn seal(plaintext: &[u8]) -> Result { + Ok(global()?.seal(plaintext)) +} + +pub fn open(sealed: &str) -> Result>, SecretError> { + global()?.open(sealed) +} + +/// Lecture rétro-compatible : valeur sans préfixe `v1:` = clair historique. +/// Un secret scellé illisible renvoie une chaîne vide (jamais la valeur brute). +pub fn open_compat(stored: &str) -> String { + if !is_sealed(stored) { + return stored.to_string(); + } + match open(stored) { + Ok(b) => String::from_utf8_lossy(&b).into_owned(), + Err(e) => { + tracing::warn!("secret scellé illisible : {e}"); + String::new() + } + } +} + +/// Écriture : scelle une valeur non vide (repli en clair si le coffre est indisponible). +pub fn seal_compat(plain: &str) -> String { + if plain.is_empty() || is_sealed(plain) { + return plain.to_string(); + } + match seal(plain.as_bytes()) { + Ok(s) => s, + Err(e) => { + tracing::warn!("coffre indisponible, secret stocké en clair : {e}"); + plain.to_string() + } + } +} + +#[cfg(test)] +#[path = "secret_box_tests.rs"] +mod tests; diff --git a/apps/server/src/secret_box_tests.rs b/apps/server/src/secret_box_tests.rs new file mode 100644 index 000000000..6afd4ad20 --- /dev/null +++ b/apps/server/src/secret_box_tests.rs @@ -0,0 +1,165 @@ +use super::*; + +fn key(b: u8) -> SecretBox { + SecretBox::from_key(&[b; 32]) +} + +#[test] +fn roundtrip() { + let sb = key(7); + let sealed = sb.seal(b"-----BEGIN RSA PRIVATE KEY-----\nabc"); + assert!(sealed.starts_with("v1:")); + assert!(!sealed.contains("BEGIN RSA")); + assert_eq!( + sb.open(&sealed).unwrap().as_slice(), + b"-----BEGIN RSA PRIVATE KEY-----\nabc" + ); +} + +#[test] +fn nonce_differs_each_seal() { + let sb = key(7); + let a = sb.seal(b"same"); + let b = sb.seal(b"same"); + assert_ne!(a, b); + let na = a.split(':').nth(1).unwrap(); + let nb = b.split(':').nth(1).unwrap(); + assert_ne!(na, nb); +} + +#[test] +fn tampered_byte_fails() { + let sb = key(7); + let sealed = sb.seal(b"secret value"); + let ct_b64 = sealed.rsplit(':').next().unwrap(); + let mut ct = B64.decode(ct_b64).unwrap(); + ct[0] ^= 0x01; + let prefix = &sealed[..sealed.len() - ct_b64.len()]; + let tampered = format!("{prefix}{}", B64.encode(ct)); + assert_eq!(sb.open(&tampered).unwrap_err(), SecretError::Decrypt); +} + +#[test] +fn wrong_key_fails() { + let sealed = key(1).seal(b"secret value"); + assert_eq!(key(2).open(&sealed).unwrap_err(), SecretError::Decrypt); +} + +#[test] +fn malformed_is_format_error() { + let sb = key(1); + assert_eq!(sb.open("ghp_plain").unwrap_err(), SecretError::Format); + assert_eq!(sb.open("v1:nope").unwrap_err(), SecretError::Format); +} + +#[test] +fn env_key_must_be_32_bytes() { + assert!(matches!( + SecretBox::from_b64(&B64.encode([0u8; 16])), + Err(SecretError::Key(_)) + )); + assert!(SecretBox::from_b64(&B64.encode([0u8; 32])).is_ok()); +} + +#[test] +fn key_file_created_0600_and_reused() { + let dir = std::env::temp_dir().join(format!("df-sb-{}", uuid::Uuid::new_v4())); + let a = SecretBox::load(None, &dir).unwrap(); + let path = dir.join(KEY_FILE); + assert!(path.is_file()); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777; + assert_eq!(mode, 0o600); + } + let sealed = a.seal(b"x"); + let b = SecretBox::load(None, &dir).unwrap(); + assert_eq!(b.open(&sealed).unwrap().as_slice(), b"x"); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn env_key_takes_precedence_over_file() { + let dir = std::env::temp_dir().join(format!("df-sb-{}", uuid::Uuid::new_v4())); + let env = B64.encode([9u8; 32]); + let a = SecretBox::load(Some(&env), &dir).unwrap(); + assert!(!dir.join(KEY_FILE).exists()); + assert_eq!(key(9).open(&a.seal(b"y")).unwrap().as_slice(), b"y"); +} + +#[test] +fn redacted_debug_hides_value() { + let r = Redacted::new("ghs_supersecret".to_string()); + assert_eq!(format!("{r:?}"), "***"); + assert_eq!(format!("{r}"), "***"); + assert_eq!(r.expose(), "ghs_supersecret"); +} + +#[tokio::test] +async fn github_app_migration_idempotent_and_pat_sealed() { + if std::env::var(KEY_ENV).is_err() { + std::env::set_var(KEY_ENV, B64.encode([3u8; 32])); + } + global().expect("coffre de test"); + let pool = devforge_database::ephemeral_pg().await; + crate::db::migrate(&pool).await.unwrap(); + sqlx::query("INSERT INTO user_settings (user_uuid, wildcard_domain, github_token, updated_at) VALUES ('u1', '', 'ghp_legacyplain', '')") + .execute(&pool).await.unwrap(); + sqlx::query("UPDATE instance_settings SET github_token = 'ghp_instanceplain' WHERE id = 1") + .execute(&pool) + .await + .unwrap(); + // Deuxième passage : aucune erreur, scelle les PAT en clair. + crate::db::migrate(&pool).await.unwrap(); + crate::db::migrate(&pool).await.unwrap(); + + let (stored,): (String,) = + sqlx::query_as("SELECT github_token FROM user_settings WHERE user_uuid = 'u1'") + .fetch_one(&pool) + .await + .unwrap(); + assert!(stored.starts_with("v1:") && !stored.contains("ghp_")); + assert_eq!( + crate::user_prefs::github_token(&pool, "u1").await, + "ghp_legacyplain" + ); + let (inst,): (String,) = + sqlx::query_as("SELECT github_token FROM instance_settings WHERE id = 1") + .fetch_one(&pool) + .await + .unwrap(); + assert!(inst.starts_with("v1:")); + assert_eq!(open_compat(&inst), "ghp_instanceplain"); + + // Écriture : re-scellée. + crate::user_prefs::set_github_token(&pool, "u2", "ghp_new") + .await + .unwrap(); + let (s2,): (String,) = + sqlx::query_as("SELECT github_token FROM user_settings WHERE user_uuid = 'u2'") + .fetch_one(&pool) + .await + .unwrap(); + assert!(s2.starts_with("v1:")); + assert_eq!( + crate::user_prefs::github_token(&pool, "u2").await, + "ghp_new" + ); + + // Validation P0 : une clé privée stockée scellée ne contient jamais `BEGIN RSA`. + let pem = "-----BEGIN RSA PRIVATE KEY-----\nMIIEfake\n-----END RSA PRIVATE KEY-----\n"; + sqlx::query("INSERT INTO github_app (id, app_id, slug, client_id, client_secret_enc, webhook_secret_enc, private_key_enc, html_url, owner_login) VALUES (1, 1, 's', 'c', $1, $2, $3, 'h', 'o')") + .bind(seal(b"cs").unwrap()).bind(seal(b"ws").unwrap()).bind(seal(pem.as_bytes()).unwrap()) + .execute(&pool).await.unwrap(); + let (pk,): (String,) = sqlx::query_as("SELECT private_key_enc FROM github_app") + .fetch_one(&pool) + .await + .unwrap(); + assert!(!pk.contains("BEGIN RSA")); + assert_eq!(open(&pk).unwrap().as_slice(), pem.as_bytes()); + + let (n,): (i64,) = sqlx::query_as("SELECT count(*) FROM information_schema.columns WHERE table_name = 'projects' AND column_name IN ('github_installation_id','github_repo_id','pr_previews_enabled','legacy_webhook_id')") + .fetch_one(&pool).await.unwrap(); + assert_eq!(n, 4); +} diff --git a/apps/server/src/state.rs b/apps/server/src/state.rs index b3397d8a8..e02a3d65c 100644 --- a/apps/server/src/state.rs +++ b/apps/server/src/state.rs @@ -637,6 +637,7 @@ impl AppState { crate::control_pg::import_legacy_sqlite(&pool, &db_path).await?; } + crate::secret_box::init(); crate::db::migrate(&pool).await?; let cluster_store: Arc = @@ -808,7 +809,7 @@ impl AppState { let t = token.trim().to_string(); let now = now_str(); sqlx::query("UPDATE instance_settings SET github_token = $1, updated_at = $2 WHERE id = 1") - .bind(&t) + .bind(crate::secret_box::seal_compat(&t)) .bind(&now) .execute(&self.pool) .await?; @@ -1277,7 +1278,8 @@ async fn resolve_github_client( .await .ok() .flatten(); - if let Some((token,)) = row { + if let Some((stored,)) = row { + let token = crate::secret_box::open_compat(&stored); if !token.trim().is_empty() { let (c, m) = client_from_token(&token); return (c, m, Some(token)); diff --git a/apps/server/src/user_prefs.rs b/apps/server/src/user_prefs.rs index f21fa9ba0..a0965658d 100644 --- a/apps/server/src/user_prefs.rs +++ b/apps/server/src/user_prefs.rs @@ -15,7 +15,9 @@ pub async fn github_token(pool: &PgPool, user_uuid: &str) -> String { .await .ok() .flatten(); - row.map(|(t,)| t).unwrap_or_default() + // Rétro-compat : valeur sans préfixe `v1:` = PAT historique en clair. + row.map(|(t,)| crate::secret_box::open_compat(&t)) + .unwrap_or_default() } pub async fn set_github_token( @@ -33,7 +35,7 @@ pub async fn set_github_token( "#, ) .bind(user_uuid) - .bind(token) + .bind(crate::secret_box::seal_compat(token)) .bind(&now) .execute(pool) .await?;