0005-actor-key-storage.md 14256 octets

RFC 0005 — Fediverse: stockage des clés d'acteur (RSA) chiffrées

  • Auteur : EBO (rustwarden)
  • Date : 2026-05-09
  • Statut : Accepted (auto-revue mainteneur unique 2026-05-09, score 26/35)
  • Apps consommatrices concernées : toute app utilisant rustwarden-core avec la feature experimental-fediverse qui veut être réellement fédérable (au-delà de la simple découverte WebFinger livrée par RFC 0002).
  • Plan associé : docs/plans/2026-05-09-fediverse-identity-federation.md, Phase 1 — clôture.
  • Dépendances : RFC 0001 (FediverseConfig), RFC 0002 (PersonJson::public_key actuellement None).

Summary

Étendre la table users avec trois colonnes (ap_public_key_pem, ap_private_key_pem chiffré via CryptoService, ap_discoverable), introduire un ActorKeyService qui génère paresseusement une paire RSA-2048 (PKCS#1) lors de la première résolution AP de l'utilisateur, et brancher le SeaOrmLocalUserResolver pour exposer la clé publique dans PersonJson::public_key afin que Mastodon et compagnie puissent vérifier les signatures HTTP de nos acteurs.

Motivation

Aujourd'hui, après RFC 0002, les routes /.well-known/webfinger et /users/{username} sont opérationnelles mais PersonJson::public_key est systématiquement None (crates/rustwarden-core/src/fediverse/types.rs:68). Conséquence : les serveurs distants (Mastodon, GoToSocial, Pleroma) refusent de parler à nos acteurs car ils ne peuvent vérifier aucune Signature HTTP RFC 9421. La fédération réelle reste impossible tant que cette RFC n'est pas livrée.

Sans cette RFC, RFC 0006 (auth AP-native, Phase 2) n'a rien à signer pour le AuthRequest sortant et les apps consommatrices n'ont aucun moyen de prouver qu'une assertion provient bien de leur instance.

Non-goals

  • Pas de signature HTTP entrante (vérification des signatures Mastodon arrivantes) — RFC 0010 (fediverse-lifecycle-signals) avec RemoteFetcher.
  • Pas de signature HTTP sortante (signer nos requêtes vers Mastodon) — RFC 0011 (fediverse-outbound-worker).
  • Pas de rotation de clé automatique — opération manuelle via CLI ou endpoint admin futur. La RFC livre seulement la génération initiale + persistance.
  • Pas de support Ed25519 — Mastodon impose RSA-2048 PKCS#1 SHA-256 ; on ne livre que ce profil.
  • Pas d'UI admin pour visualiser/regénérer les clés — rustwarden-ui traitera plus tard.
  • Pas de backfill de masse au démarrage — génération lazy au premier accès AP de l'utilisateur. Une commande CLI backfill_actor_keys est prévue (non livrée par cette RFC) pour les admins qui voudront pré-générer.

API proposée

Migration m20260510_000011_extend_users_with_actor_keys

Étend users :

  • ap_public_key_pem TEXT NULL — PEM SPKI public, généré lazy.
  • ap_private_key_pem TEXT NULL — PEM PKCS#8 privé chiffré via CryptoService (enc: prefix + AES-256-GCM), jamais stocké en clair en DB.
  • ap_discoverable BOOLEAN NOT NULL DEFAULT TRUE — l'utilisateur veut-il apparaître dans les recherches inter-instance.

Migration up purement additive (3 colonnes nullable + une avec défaut) ; down symétrique. Aucune donnée existante n'est touchée.

Modèle users::Model

3 nouveaux champs publics gated cfg(feature = "experimental-fediverse") (sinon ils restent dans le schéma DB mais ne sont pas exposés côté Rust pour minimiser le poids de la struct hors fediverse). Décision : on expose toujours dans le modèle SeaORM (sinon les apps non-fediverse qui font User::find_by_id casseraient au runtime SeaORM par mismatch de colonnes). Ils restent simplement inutilisés.

pub struct Model {
    // … champs existants …
    /// Public key PEM (SPKI) used by remote fediverse servers to verify
    /// HTTP signatures from this actor. `None` until first AP resolution.
    pub ap_public_key_pem: Option<String>,
    /// Private key PEM (PKCS#8) **encrypted** via `CryptoService`. Never
    /// returned in clear by any service except `ActorKeyService::ensure_keys`.
    pub ap_private_key_pem: Option<String>,
    /// Whether this user wants to be discoverable by remote search.
    pub ap_discoverable: bool,
}

Service ActorKeyService

Nouveau module crates/rustwarden-core/src/services/actor_key_service.rs (gated). Le service est sans état : pas de cache, juste DB + crypto + génération RSA.

use rsa::{RsaPrivateKey, pkcs1::EncodeRsaPublicKey, pkcs8::EncodePrivateKey};
use sea_orm::DatabaseConnection;
use uuid::Uuid;

/// Bundle returned by [`ActorKeyService::ensure_keys`].
#[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`), **decrypted**.
    pub private_pem: String,
}

/// RSA-2048 actor-key generator and persistence layer for ActivityPub.
///
/// Keys are generated lazily on first request and the private key is
/// encrypted at rest via [`CryptoService`].
pub struct ActorKeyService;

impl ActorKeyService {
    /// Generate a fresh RSA-2048 key pair. CPU-bound (~50-200 ms): callers
    /// must invoke from `tokio::task::spawn_blocking` when on an async path.
    pub fn generate() -> Result<ActorKeyMaterial, AppError>;

    /// Idempotent: returns existing material if the user already has both
    /// `ap_public_key_pem` and `ap_private_key_pem` set, otherwise generates
    /// a new pair, encrypts the private half, and persists both columns.
    /// The private PEM in the returned bundle is **decrypted** (caller may
    /// sign with it). A `Mutex`-style race is impossible because the update
    /// is gated by a SELECT-with-`FOR UPDATE` semantic via `OnConflict`.
    pub async fn ensure_keys(
        db: &DatabaseConnection,
        user_id: Uuid,
    ) -> Result<ActorKeyMaterial, AppError>;

    /// Read-only fast path used by the actor JSON resolver: returns the
    /// public PEM if persisted, else `None`. Does not generate.
    pub async fn public_key_for_user(
        db: &DatabaseConnection,
        user_id: Uuid,
    ) -> Result<Option<String>, AppError>;
}

Wiring du SeaOrmLocalUserResolver

Méthode resolve (dans crates/rustwarden-core/src/fediverse/resolver.rs) modifiée pour, lors d'une requête AP entrante sur /users/{username} :

  1. Si ap_public_key_pem est Some(pem) → on l'utilise directement, pas de génération.
  2. Sinon, si FediverseConfig est actif et que le user existe → appel à ActorKeyService::ensure_keys pour le générer dans une spawn_blocking afin de ne pas bloquer le runtime tokio.

L'ancienne forme du resolver (qui retournait ap_public_key_pem: None) reste compatible : un consommateur qui injecte sa propre impl de LocalUserResolver peut continuer à laisser None ; seul le default impl SeaORM gagne la génération lazy.

Exemple d'usage côté consommateur

use rustwarden_core::services::actor_key_service::ActorKeyService;

// Backfill manuel pour un user spécifique (CLI admin).
let material = ActorKeyService::ensure_keys(&db, user_id).await?;
println!("{}", material.public_pem);
// `material.private_pem` est déchiffré, en mémoire seulement.

Analyse de compatibilité

  • Non-breaking : 3 colonnes ajoutées, toutes nullable ou avec défaut.
  • Migration up/down réversible.
  • Pas de modification d'API publique existante. Model gagne 3 champs en fin de struct (pattern matching exhaustif des consommateurs casserait, mais SeaORM n'expose pas Model derrière un pattern destructuring usuel).
  • Format sérialisé : PEM standard, pas de format custom à versionner.
  • Le wiring resolver est interne, derrière feature.

Risque résiduel : la première résolution AP d'un user après upgrade ajoute ~100 ms de latence (génération RSA). Mitigation : doc + commande de backfill en option.

Alternatives considérées

Alternative A (recommandée) — Lazy gen + chiffrement par CryptoService

Pros :

  • Coût zéro pour les users qui ne fédèrent jamais.
  • Stockage chiffré par défaut, cohérent avec OAuth/TOTP.
  • Compatible 100 % du write-path actuel (pas de hook au signup).

Cons :

  • Latence ponctuelle ~100 ms à la première résolution AP du user. Acceptable (un agrégateur retry).

Alternative B — Backfill au démarrage de l'app

Génération de toutes les paires manquantes lors du RustwardenBuilder::with_fediverse().

Pros : pas de latence à la résolution.

Cons :

  • Démarrage très lent sur un dump existant (1000 users × 100 ms = 100 s).
  • Génère des clés pour 99 % d'users qui ne fédéreront jamais.
  • Force les apps consommatrices à payer le coût même en environnement test.

Rejeté. Une commande CLI optionnelle est meilleure.

Alternative C — Génération au signup uniquement

Hook dans le user_service pour générer les clés à la création.

Pros : pas de latence, pas de retro-actif.

Cons :

  • Casse la rétrocompatibilité : les users pré-existants n'ont jamais de clés.
  • Force toutes les apps consommatrices à payer le coût RSA même quand fediverse est désactivé runtime.

Rejeté.

Alternative D — Stockage en clair de la clé privée

Pas de chiffrement, juste la colonne PEM.

Cons :

  • Une fuite DB = compromission totale des identités fediverse.
  • Incohérent avec le pattern OAuth/TOTP existant.

Rejeté pour raisons de sécurité.

Alternative E — Ne rien faire

Pros : zéro coût.

Cons : la fédération AP réelle reste impossible, RFC 0006+ bloquées.

Rejeté.

Testing plan

13 tests :

Pure (génération + sérialisation) :

  1. generate_returns_valid_pem_pair — generate() retourne deux PEM dont les headers attendus (BEGIN PUBLIC KEY + BEGIN PRIVATE KEY).
  2. generate_keys_are_2048_bits — la clé privée a bien n.bits() == 2048.
  3. generate_returns_distinct_pairs — deux appels successifs produisent des modulus différents.
  4. actor_key_material_clone_works — la struct est Clone pour faciliter le passage entre tâches.

DB-mocked (sea-orm/mock) : 5. public_key_for_user_returns_none_when_unset — user sans clé → Ok(None). 6. public_key_for_user_returns_some_when_set — user avec PEM → Ok(Some(...)). 7. public_key_for_user_returns_error_when_user_missing — id inconnu → AppError::NotFound. 8. ensure_keys_returns_existing_when_present — keys déjà persistées → pas de régénération (vérifié via 0 RSA gen et même PEM en sortie). 9. ensure_keys_generates_when_missing — keys absentes → 1 INSERT/UPDATE émis et PEM cohérent. 10. ensure_keys_persists_encrypted_private_key — la valeur écrite côté DB commence par enc: (chiffré). 11. ensure_keys_returns_decrypted_private_pem — le private_pem retourné est en clair (BEGIN PRIVATE KEY).

Resolver wiring : 12. seaorm_resolver_populates_ap_public_key_pem_when_present — quand le user a déjà la clé, LocalUserActor.ap_public_key_pem est Some. 13. seaorm_resolver_returns_none_when_keys_unset_and_crypto_unavailable — sans ENCRYPTION_KEY configuré, on ne génère pas ; on retourne None pour ne pas casser la résolution. La latence n'est payée que si crypto est dispo.

Migration guide

À copier dans CHANGELOG.md au moment du merge :

### Added (rustwarden-core, feature `experimental-fediverse`)

- Migration `m20260510_000011_extend_users_with_actor_keys` ajoutant
  trois colonnes à `users` :
  - `ap_public_key_pem` (PEM public AP, nullable, lazy-generated)
  - `ap_private_key_pem` (PEM privé chiffré via `CryptoService`)
  - `ap_discoverable` (bool, défaut `true`)
- Service `ActorKeyService` (génération RSA-2048, ensure idempotent,
  lecture rapide de la clé publique).
- `PersonJson::public_key` est maintenant `Some(...)` quand l'utilisateur
  a déjà une paire (générée à la première résolution AP).

### Migration steps

1. Configurer `ENCRYPTION_KEY` (64 hex chars) dans l'environnement avant
   d'activer la feature, sinon les clés ne seront pas générées.
2. Lancer `cargo run --bin migrate` pour appliquer la migration 11.
3. Optionnel : exécuter une commande de backfill (à venir RFC 0011) pour
   pré-générer les clés sur tous les users existants.

Open questions

Toutes résolues à l'auto-revue :

  • Génération lazy vs eager : lazy (Alternative A retenue).
  • Format de clé : RSA-2048 PKCS#8 (privé) + SPKI (public), conforme Mastodon.
  • Crypto reuse : on réutilise CryptoService existant — pas de nouveau secret.
  • Comportement si ENCRYPTION_KEY absent : on ne génère pas et on retourne None pour ap_public_key_pem. Test 13 verrouille ce contrat.
  • spawn_blocking : oui, la génération RSA est CPU-bound (>10 ms), elle doit sortir du runtime tokio. Documenté dans la rustdoc de generate().

Tracking issue

À ouvrir à la création de la PR — pas encore numéro alloué.


Score d'évaluation

CritèreNote (1-5)Commentaire
Généralité4Service réutilisable au-delà du fediverse pour toute appli RSA-on-user, mais reste tied au schéma users.
Stabilité API4Non-breaking, additif derrière feature ; 3 colonnes ajoutées au modèle public.
Performance3Génération RSA ~100 ms, payée une seule fois par user, off-runtime via spawn_blocking.
Sécurité5Hardening net : clé privée chiffrée at-rest, jamais loggée, dépend de CryptoService audité.
Complexité d'implémentation3~400 lignes (1 migration + 3 colonnes Model + 1 service + 13 tests + wiring resolver).
Maintenance3Nouvelle dépendance rsa + pkcs8, surface upstream stable.
Documentation4Rustdoc EN, doctest, CHANGELOG, guide migration ; pas encore d'exemple examples/.
Total26/35≥ 25 → Accepted