0015-fediverse-http-signatures.md 9298 octets

RFC 0015 — Fediverse: signatures HTTP draft-cavage (RSA-SHA256)

  • Auteur : EBO (rustwarden)
  • Date : 2026-05-10
  • Statut : Accepted (auto-revue mainteneur unique 2026-05-10, score 27/35)
  • Apps consommatrices concernées : toute app utilisant rustwarden-core qui veut interopérer avec Mastodon, GoToSocial, Pleroma — c'est-à-dire 100 % des apps fediverse en prod.
  • Plan associé : docs/plans/2026-05-09-fediverse-identity-federation.md, Phase 5 — déblocage interop.
  • Dépendances : RFC 0005 (clés acteur), RFC 0010 (SignatureVerifier), RFC 0011 (OutboundDelivery).

Summary

Livrer deux implémentations cryptographiques branchées sur les traits déjà en place :

  • HttpSigVerifier — impl de SignatureVerifier qui valide le header HTTP Signature style draft-cavage-12 (utilisé par Mastodon) avec RSA-SHA256.
  • HttpSignedDelivery — impl de OutboundDelivery qui signe les activités sortantes avec la clé privée RFC 0005 du local actor avant le POST HTTP.

Aucune nouvelle dépendance externe : on réutilise rsa (RFC 0005) + sha2 + base64 + reqwest (déjà pulled par feature oauth).

Motivation

Sans cette RFC, AlwaysAcceptVerifier accepte n'importe quelle requête entrante sur /inbox et /fediverse/auth/inbox, et NoopDelivery n'envoie aucune requête sortante. Concrètement, Mastodon refuse toute fédération avec rustwarden tant que les signatures HTTP ne sont pas implémentées (Authorized Fetch + signed deliveries sont obligatoires sur Mastodon ≥ 3.0).

Sans cette RFC, l'objectif Phase 5 « bob@a.tld se connecte avec succès sur b.tld » reste impossible avec Mastodon comme a.tld.

Non-goals

  • Pas d'implémentation RFC 9421 complète (la version finale du standard). On vise draft-cavage-12 car c'est ce que Mastodon parle aujourd'hui ; un follow-up suivra quand Mastodon migrera.
  • Pas de support Ed25519 — Mastodon impose RSA-2048.
  • Pas de support algorithmes autres que rsa-sha256 / hs2019.
  • Pas de blob de "double-knocking" (essayer cavage puis 9421) — un seul format pour cette livraison.
  • Pas de cache de clé publique — le RemoteIdentityService (RFC 0003) gère déjà le cache via TTL.
  • Pas de gestion du Digest body header — on signe (request-target) host date minimum côté sortant ; côté entrant on vérifie ce que Mastodon envoie réellement.

API proposée

Module rustwarden_core::fediverse::http_signatures

/// Verifier production : valide le header Signature draft-cavage.
pub struct HttpSigVerifier {
    /// Resolver pour récupérer le PEM public de l'acteur signataire
    /// (via WebFinger + cache `remote_identities` de RFC 0003).
    fetcher: Arc<dyn RemoteFetcher>,
}

impl HttpSigVerifier {
    pub fn new(fetcher: Arc<dyn RemoteFetcher>) -> Self;
}

#[async_trait]
impl SignatureVerifier for HttpSigVerifier {
    async fn verify(
        &self,
        actor_ap_id: &str,
        signature_header: &str,
        body: &[u8],
        public_key_pem: &str,
    ) -> Result<(), AppError>;
}

/// Delivery production : signe + POST l'activité.
pub struct HttpSignedDelivery {
    db: DatabaseConnection,
    http: reqwest::Client,
    base_url: String,
}

impl HttpSignedDelivery {
    pub fn new(db: DatabaseConnection, base_url: String) -> Self;
}

#[async_trait]
impl OutboundDelivery for HttpSignedDelivery {
    async fn deliver(
        &self,
        item: &fediverse_outbound_queue::Model,
    ) -> Result<(), AppError>;
}

Format Signature header (draft-cavage)

Signature: keyId="https://a.tld/users/alice#main-key",
           algorithm="rsa-sha256",
           headers="(request-target) host date digest",
           signature="<base64>"

Algorithme de vérification

  1. Parser le header en map key=value (gestion des quotes).
  2. Vérifier algorithm == "rsa-sha256" (refuser hs2019 pour cette livraison — extension future).
  3. Reconstituer la signature input string à partir de headers (ex. (request-target): post /inbox\nhost: b.tld\ndate: …).
  4. Décoder le PEM public depuis public_key_pem (passé par le caller, qui l'a obtenu via RemoteIdentityService::resolve(actor_ap_id)).
  5. Appeler RsaPublicKey::verify(Pkcs1v15Sign::new::<Sha256>(), &input, &signature_bytes).
  6. Mapper succès → Ok(()), échec → AppError::Internal("HTTP signature mismatch").

Algorithme de signature sortante

  1. Charger le local actor (actor_user_id du row queue) via SeaORM.
  2. Récupérer sa paire RSA via ActorKeyService::ensure_keys(db, user_id) (RFC 0005).
  3. Construire la signature input string avec (request-target) host date.
  4. Calculer SHA-256, signer via RsaPrivateKey::sign(Pkcs1v15Sign::new::<Sha256>(), &hash).
  5. POSTer le body avec les headers Signature / Date / Host.

Analyse de compatibilité

  • Non-breaking : 2 nouveaux types publics qui s'enfichent sur les traits existants.
  • Pas de nouvelle dépendance majeure (rsa déjà tiré par RFC 0005).
  • reqwest activé par la feature oauth ; on peut soit le rendre optionnel pour experimental-fediverse, soit imposer la dépendance commune. Décision : reqwest est ajouté à experimental-fediverse deps (déjà optionnel).
  • Pas de schéma DB.

Alternatives considérées

Alternative A (recommandée) — Implémentation maison sur rsa + sha2

Pros : zéro nouvelle dep ; contrôle total ; Mastodon-compat.

Cons : ~250 lignes de parsing HTTP-Sig à maintenir.

Alternative B — Crate httpsig-rs ou équivalent

Cons : aucun crate Rust mature ne supporte draft-cavage et RFC 9421 en parallèle aujourd'hui ; risque d'abandon ; on ajoute une dep externe pour 250 lignes.

Rejeté.

Alternative C — Bloquer toute interop tant que Mastodon migre vers RFC 9421

Cons : interop = 0 pendant 1-2 ans.

Rejeté.

Alternative D — Ne rien faire (rester sur AlwaysAccept + Noop)

Cons : pas de prod possible.

Rejeté.

Testing plan

12 tests :

Pure (parsing + canonical string) :

  1. parse_signature_header_extracts_keyid_algorithm_headers_signature.
  2. parse_signature_header_rejects_missing_required_fields.
  3. build_signature_input_uses_lowercase_header_names.
  4. build_signature_input_handles_request_target.

Crypto round-trip : 5. sign_then_verify_round_trip — génère une paire, signe, vérifie : Ok. 6. verify_rejects_tampered_signature. 7. verify_rejects_wrong_public_key.

Verifier integration : 8. verifier_rejects_when_algorithm_not_rsa_sha256. 9. verifier_rejects_when_signature_invalid_base64. 10. verifier_accepts_well_formed_signature_with_test_key.

Delivery (mocked) : 11. delivery_skips_signing_when_actor_user_id_none — NoopDelivery-like fallback ; pas d'erreur silencieuse, retourne explicitement Err(Config). 12. delivery_returns_err_when_actor_keys_missing.

Pas de test contre reqwest réel : la livraison HTTP est testée via un mock-server dédié dans un follow-up tests/interop/ (Phase 5).

Migration guide

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

- `rustwarden_core::fediverse::http_signatures::{HttpSigVerifier, HttpSignedDelivery}`.
- `reqwest` ajouté aux deps gated par `experimental-fediverse`
  (déjà optionnel pour la feature `oauth`).

### Migration steps

1. Apps fediverse en dev : changement zéro ; `AlwaysAcceptVerifier` +
   `NoopDelivery` continuent de fonctionner.
2. Apps en prod : remplacer
   ```rust
   .with_fediverse()?            // garde AlwaysAccept + Noop

par :

.with_fediverse()?
    .with_signature_verifier(Arc::new(HttpSigVerifier::new(fetcher)))
    .with_delivery(Arc::new(HttpSignedDelivery::new(db, base_url)))

(les méthodes with_signature_verifier / with_delivery sont ajoutées par cette RFC sur RustwardenBuilder).


## Open questions

Toutes résolues à l'auto-revue :

- [x] **Tolerance horloge `Date` header** : 1 heure (Mastodon-compat).
- [x] **Refus de `hs2019`** : oui, le draft-cavage moderne. Si Mastodon migre, follow-up RFC.
- [x] **Caching de la clé publique** : déléguée à `RemoteIdentityService` qui a déjà un TTL.

## Tracking issue

À ouvrir au moment de la PR.

---

## Score d'évaluation

| Critère | Note (1-5) | Commentaire |
|---|---|---|
| Généralité | 4 | Trait pluggable, donc impl HTTP-Sig réelle facilement remplacée par RFC 9421. |
| Stabilité API | 4 | Additif, gated. |
| Performance | 3 | RSA-SHA256 côté hot path entrant ; cache key public via RFC 0003 limite. |
| Sécurité | 5 | Le seul vrai déblocage prod ; constant-time signature via `rsa`/`subtle`. |
| Complexité d'implémentation | 3 | ~600 lignes (parsing + canonical + sign + verify + 12 tests). |
| Maintenance | 4 | Pas de nouvelle dep, mais format à mettre à jour si/quand Mastodon migre. |
| Documentation | 4 | Rustdoc EN, doctest, CHANGELOG, migration guide. |
| **Total** | **27/35** | ≥ 25 → Accepted |