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-coreavec la featureexperimental-fediversequi 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_keyactuellementNone).
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) avecRemoteFetcher. - 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-uitraitera 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_keysest 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é viaCryptoService(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} :
- Si
ap_public_key_pemestSome(pem)→ on l'utilise directement, pas de génération. - Sinon, si
FediverseConfigest actif et que le user existe → appel àActorKeyService::ensure_keyspour le générer dans unespawn_blockingafin 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.
Modelgagne 3 champs en fin de struct (pattern matching exhaustif des consommateurs casserait, mais SeaORM n'expose pasModelderriè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) :
generate_returns_valid_pem_pair—generate()retourne deux PEM dont les headers attendus (BEGIN PUBLIC KEY+BEGIN PRIVATE KEY).generate_keys_are_2048_bits— la clé privée a bienn.bits() == 2048.generate_returns_distinct_pairs— deux appels successifs produisent des modulus différents.actor_key_material_clone_works— la struct estClonepour 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
CryptoServiceexistant — pas de nouveau secret. -
Comportement si
ENCRYPTION_KEYabsent : on ne génère pas et on retourneNonepourap_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 degenerate().
Tracking issue
À ouvrir à la création de la PR — pas encore numéro alloué.
Score d'évaluation
| Critère | Note (1-5) | Commentaire |
|---|---|---|
| Généralité | 4 | Service réutilisable au-delà du fediverse pour toute appli RSA-on-user, mais reste tied au schéma users. |
| Stabilité API | 4 | Non-breaking, additif derrière feature ; 3 colonnes ajoutées au modèle public. |
| Performance | 3 | Génération RSA ~100 ms, payée une seule fois par user, off-runtime via spawn_blocking. |
| Sécurité | 5 | Hardening net : clé privée chiffrée at-rest, jamais loggée, dépend de CryptoService audité. |
| Complexité d'implémentation | 3 | ~400 lignes (1 migration + 3 colonnes Model + 1 service + 13 tests + wiring resolver). |
| Maintenance | 3 | Nouvelle dépendance rsa + pkcs8, surface upstream stable. |
| Documentation | 4 | Rustdoc EN, doctest, CHANGELOG, guide migration ; pas encore d'exemple examples/. |
| Total | 26/35 | ≥ 25 → Accepted |
GitRust