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(_)));
    }
}