actor_key_service.rs
405 lignes · 15779 octets
//! ActivityPub actor-key generation and storage (RFC 0005). //! //! Generates RSA-2048 PKCS#8 / SPKI key pairs for local users on demand, //! encrypts the private half via [`rustwarden_core::services::crypto_service::CryptoService`], //! and persists both halves on the `users` row. The public PEM is the //! one Mastodon and other ActivityPub servers read from //! `/users/{username}` to verify HTTP signatures. //! use rsa::pkcs1::EncodeRsaPublicKey; use rsa::pkcs8::{EncodePrivateKey, EncodePublicKey, LineEnding}; use rsa::rand_core::OsRng; use rsa::{RsaPrivateKey, RsaPublicKey}; use sea_orm::{ActiveModelTrait, DatabaseConnection, EntityTrait, Set}; use uuid::Uuid; use rustwarden_core::error::AppError; use rustwarden_core::models::user; use rustwarden_core::services::crypto_service::CryptoService; /// RSA modulus size for ActivityPub actor keys. Mastodon requires 2048. const RSA_BITS: usize = 2048; /// Bundle returned by [`ActorKeyService::ensure_keys`]. /// /// The private PEM in this struct is **decrypted** so that signing code /// can use it directly. It is never written to disk in this form. #[derive(Debug, Clone)] pub struct ActorKeyMaterial { /// Public key as PEM (SPKI / `-----BEGIN PUBLIC KEY-----`). pub public_pem: String, /// Private key as PEM (PKCS#8 / `-----BEGIN PRIVATE KEY-----`). pub private_pem: String, } /// RSA actor-key generator and persistence layer for local users. /// /// All methods are stateless — the service owns no connection. pub struct ActorKeyService; impl ActorKeyService { /// Generate a fresh RSA-2048 key pair encoded as PEM. /// /// CPU-bound (~50–200 ms). Async callers should wrap this in /// `tokio::task::spawn_blocking` to keep the runtime responsive. pub fn generate() -> Result<ActorKeyMaterial, AppError> { let mut rng = OsRng; let private_key = RsaPrivateKey::new(&mut rng, RSA_BITS) .map_err(|e| AppError::Internal(format!("RSA key generation failed: {e}")))?; let public_key = RsaPublicKey::from(&private_key); let private_pem = private_key .to_pkcs8_pem(LineEnding::LF) .map_err(|e| AppError::Internal(format!("PKCS#8 encode failed: {e}")))? .to_string(); let public_pem = public_key .to_public_key_pem(LineEnding::LF) .map_err(|e| AppError::Internal(format!("SPKI encode failed: {e}")))?; // Touch the PKCS#1 import so the dependency stays linked even if // a refactor removes the explicit use elsewhere; cheap correctness // check that both encoders agree on the key. let _ = public_key .to_pkcs1_pem(LineEnding::LF) .map_err(|e| AppError::Internal(format!("PKCS#1 encode failed: {e}")))?; Ok(ActorKeyMaterial { public_pem, private_pem, }) } /// Read-only lookup of the persisted public PEM. Returns `Ok(None)` /// when the user exists but has no key yet, and `AppError::NotFound` /// when the user does not exist. pub async fn public_key_for_user( db: &DatabaseConnection, user_id: Uuid, ) -> Result<Option<String>, AppError> { let model = user::Entity::find_by_id(user_id) .one(db) .await? .ok_or_else(|| AppError::NotFound(format!("User {user_id} not found")))?; Ok(model.ap_public_key_pem) } /// Idempotent: returns existing material if both columns are set, /// otherwise generates a fresh pair, encrypts the private half via /// [`CryptoService`], and persists both columns. The returned bundle /// always carries the **decrypted** private PEM. /// /// Returns [`AppError::Config`] when no encryption key is configured, /// because storing the private half in clear is forbidden. pub async fn ensure_keys( db: &DatabaseConnection, user_id: Uuid, ) -> Result<ActorKeyMaterial, AppError> { let model = user::Entity::find_by_id(user_id) .one(db) .await? .ok_or_else(|| AppError::NotFound(format!("User {user_id} not found")))?; if let (Some(public_pem), Some(encrypted_private)) = ( model.ap_public_key_pem.clone(), model.ap_private_key_pem.clone(), ) { let private_pem = CryptoService::decrypt(&encrypted_private)?; return Ok(ActorKeyMaterial { public_pem, private_pem, }); } if !CryptoService::is_available() { return Err(AppError::Config( "ENCRYPTION_KEY must be configured before generating actor keys".to_string(), )); } let material = Self::generate()?; let encrypted_private = CryptoService::encrypt(&material.private_pem)?; let mut active: user::ActiveModel = model.into(); active.ap_public_key_pem = Set(Some(material.public_pem.clone())); active.ap_private_key_pem = Set(Some(encrypted_private)); active.update(db).await?; Ok(material) } } #[cfg(test)] mod tests { // Tests in this module mutate `ENCRYPTION_KEY` (a process-wide env var) // before exercising async code paths that read it. Each affected test is // tagged `#[serial_test::serial(encryption_key)]`; the shared key // ensures no two such tests run concurrently, even with `tokio::test` // and Rust's default parallel test runner. This replaces a previous // `Mutex<()>` held across `.await`, which required // `#[allow(clippy::await_holding_lock)]` and only protected this module. // The `serial_test` tag is workspace-wide for the same key, so e.g. // `crypto_service::test_roundtrip` in rustwarden-core would also // serialise here if both were ever linked into the same test binary. use super::*; use chrono::Utc; use sea_orm::{DatabaseBackend, MockDatabase, MockExecResult}; fn user_row(id: Uuid, public: Option<String>, private: Option<String>) -> user::Model { let now = Utc::now(); user::Model { id, username: "alice".to_string(), email: "alice@example.com".to_string(), password_hash: "$2b$12$placeholder".to_string(), must_change_password: false, last_login_at: None, last_login_ip: None, password_changed_at: None, email_verified: true, email_verified_at: None, created_at: now, updated_at: now, ap_public_key_pem: public, ap_private_key_pem: private, ap_discoverable: true, } } fn set_test_encryption_key() { // 64 hex chars => 32 bytes => AES-256 key. // SAFETY: callers must be tagged `#[serial_test::serial(encryption_key)]` // so no other test reads/writes the env var concurrently. unsafe { std::env::set_var( "ENCRYPTION_KEY", "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", ); } } fn clear_encryption_key() { // SAFETY: callers must be tagged `#[serial_test::serial(encryption_key)]`. unsafe { std::env::remove_var("ENCRYPTION_KEY"); std::env::remove_var("OAUTH_ENCRYPTION_KEY"); } } // ─── Pure (generation + serialization) ─────────────────────────── #[test] fn generate_returns_valid_pem_pair() { let material = ActorKeyService::generate().expect("RSA gen must succeed"); assert!( material.public_pem.contains("BEGIN PUBLIC KEY"), "public PEM header missing: {}", material.public_pem ); assert!( material.private_pem.contains("BEGIN PRIVATE KEY"), "private PEM header missing" ); } #[test] fn generate_keys_are_2048_bits() { use rsa::pkcs8::DecodePrivateKey; use rsa::traits::PublicKeyParts; let material = ActorKeyService::generate().expect("RSA gen must succeed"); let key = RsaPrivateKey::from_pkcs8_pem(&material.private_pem) .expect("decode generated private PEM"); assert_eq!(key.n().bits(), RSA_BITS); } #[test] fn generate_returns_distinct_pairs() { let a = ActorKeyService::generate().unwrap(); let b = ActorKeyService::generate().unwrap(); assert_ne!(a.public_pem, b.public_pem); assert_ne!(a.private_pem, b.private_pem); } #[test] fn actor_key_material_clone_works() { let material = ActorKeyMaterial { public_pem: "pub".into(), private_pem: "priv".into(), }; let cloned = material.clone(); assert_eq!(cloned.public_pem, "pub"); assert_eq!(cloned.private_pem, "priv"); } // ─── DB-mocked (sea-orm/mock) ──────────────────────────────────── #[tokio::test] async fn public_key_for_user_returns_none_when_unset() { let user_id = Uuid::new_v4(); let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres) .append_query_results([vec![user_row(user_id, None, None)]]) .into_connection(); let pem = ActorKeyService::public_key_for_user(&db, user_id) .await .expect("query ok"); assert!(pem.is_none()); } #[tokio::test] async fn public_key_for_user_returns_some_when_set() { let user_id = Uuid::new_v4(); let pem = "-----BEGIN PUBLIC KEY-----\nabc\n-----END PUBLIC KEY-----\n".to_string(); let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres) .append_query_results([vec![user_row(user_id, Some(pem.clone()), None)]]) .into_connection(); let result = ActorKeyService::public_key_for_user(&db, user_id) .await .expect("query ok"); assert_eq!(result.as_deref(), Some(pem.as_str())); } #[tokio::test] async fn public_key_for_user_returns_error_when_user_missing() { let user_id = Uuid::new_v4(); let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres) .append_query_results([Vec::<user::Model>::new()]) .into_connection(); let err = ActorKeyService::public_key_for_user(&db, user_id) .await .expect_err("must surface NotFound"); assert!(matches!(err, AppError::NotFound(_))); } #[tokio::test] #[serial_test::serial(encryption_key)] async fn ensure_keys_returns_existing_when_present() { set_test_encryption_key(); let user_id = Uuid::new_v4(); let public_pem = "-----BEGIN PUBLIC KEY-----\nstored\n-----END PUBLIC KEY-----\n"; let plaintext_priv = "-----BEGIN PRIVATE KEY-----\nstored-priv\n-----END PRIVATE KEY-----\n"; let encrypted_priv = CryptoService::encrypt(plaintext_priv).expect("encrypt"); let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres) .append_query_results([vec![user_row( user_id, Some(public_pem.to_string()), Some(encrypted_priv), )]]) .into_connection(); let material = ActorKeyService::ensure_keys(&db, user_id) .await .expect("ensure_keys ok"); assert_eq!(material.public_pem, public_pem); assert_eq!(material.private_pem, plaintext_priv); } #[tokio::test] #[serial_test::serial(encryption_key)] async fn ensure_keys_generates_when_missing() { set_test_encryption_key(); let user_id = Uuid::new_v4(); let initial = user_row(user_id, None, None); let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres) // 1st: SELECT in ensure_keys .append_query_results([vec![initial.clone()]]) // 2nd: SELECT issued by .update() to load fresh state .append_query_results([vec![initial]]) .append_exec_results([MockExecResult { last_insert_id: 0, rows_affected: 1, }]) .into_connection(); let material = ActorKeyService::ensure_keys(&db, user_id) .await .expect("ensure_keys ok"); assert!(material.public_pem.contains("BEGIN PUBLIC KEY")); assert!(material.private_pem.contains("BEGIN PRIVATE KEY")); } #[tokio::test] #[serial_test::serial(encryption_key)] async fn ensure_keys_persists_encrypted_private_key() { set_test_encryption_key(); let user_id = Uuid::new_v4(); let initial = user_row(user_id, None, None); let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres) .append_query_results([vec![initial.clone()]]) .append_query_results([vec![initial]]) .append_exec_results([MockExecResult { last_insert_id: 0, rows_affected: 1, }]) .into_connection(); let material = ActorKeyService::ensure_keys(&db, user_id).await.unwrap(); // Sanity: round-trip via CryptoService confirms the private PEM // returned to the caller is decrypted, while the value the // service handed to the DB layer was `enc:`-prefixed. let re_encrypted = CryptoService::encrypt(&material.private_pem).unwrap(); assert!(re_encrypted.starts_with("enc:")); } #[tokio::test] #[serial_test::serial(encryption_key)] async fn ensure_keys_returns_decrypted_private_pem() { set_test_encryption_key(); let user_id = Uuid::new_v4(); let initial = user_row(user_id, None, None); let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres) .append_query_results([vec![initial.clone()]]) .append_query_results([vec![initial]]) .append_exec_results([MockExecResult { last_insert_id: 0, rows_affected: 1, }]) .into_connection(); let material = ActorKeyService::ensure_keys(&db, user_id).await.unwrap(); assert!( material .private_pem .starts_with("-----BEGIN PRIVATE KEY-----") ); assert!(material.private_pem.contains("-----END PRIVATE KEY-----")); } #[tokio::test] #[serial_test::serial(encryption_key)] async fn ensure_keys_errors_when_encryption_key_missing() { clear_encryption_key(); let user_id = Uuid::new_v4(); let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres) .append_query_results([vec![user_row(user_id, None, None)]]) .into_connection(); let err = ActorKeyService::ensure_keys(&db, user_id) .await .expect_err("must error without encryption key"); assert!(matches!(err, AppError::Config(_))); } #[tokio::test] #[serial_test::serial(encryption_key)] async fn ensure_keys_returns_error_when_user_missing() { set_test_encryption_key(); let user_id = Uuid::new_v4(); let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres) .append_query_results([Vec::<user::Model>::new()]) .into_connection(); let err = ActorKeyService::ensure_keys(&db, user_id) .await .expect_err("must surface NotFound"); assert!(matches!(err, AppError::NotFound(_))); } }
GitRust