0016-fediverse-signed-delivery-and-verifier-widening.md 9042 octets

RFC 0016 — Fediverse: élargissement SignatureVerifier + HttpSignedDelivery complet

  • 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 fediverse en production qui veut activer la vérification HTTP-Sig réelle entrante et la livraison signée sortante.
  • Plan associé : Phase 5 — production. Suite directe de RFC 0015.
  • Dépendances : RFC 0010 (SignatureVerifier), RFC 0011 (OutboundDelivery), RFC 0015 (primitifs HTTP-Sig).

Summary

Deux changements techniquement liés :

  1. Élargir le trait SignatureVerifier : la signature actuelle ne porte que (actor_ap_id, signature_header, body, public_key_pem). La vérification draft-cavage exige aussi method, path et le HeaderMap (host, date, digest, etc.). Nouvelle signature :

    async fn verify(&self, ctx: &VerifyCtx<'_>) -> Result<(), AppError>;
    

    où VerifyCtx contient les champs nécessaires. Les impls existantes (AlwaysAcceptVerifier, mocks de tests) sont mises à jour. Les handlers inbox::handle et auth_routes::auth_inbox consomment désormais le contexte complet.

  2. Livrer HttpSignedDelivery — impl OutboundDelivery qui utilise reqwest::Client + rsa_sha256_sign (RFC 0015) pour signer puis POSTer chaque activité. Branchable sur RustwardenBuilder via with_delivery(Arc::new(HttpSignedDelivery::new(...))).

Motivation

Sans l'élargissement, parse_and_verify_cavage (RFC 0015) reste inutilisable depuis les inbox handlers — qui n'ont pas accès au HeaderMap à travers le trait. Sans HttpSignedDelivery, les activités sortantes restent en NoopDelivery.

Cette RFC est le dernier maillon avant que rustwarden parle réellement avec Mastodon.

Non-goals

  • Pas de support Digest body header dans cette RFC — Mastodon le set en sortant mais ne l'exige pas en entrant pour notre usage initial. Follow-up.
  • Pas de retry HTTP-spécifique (4xx vs 5xx, Retry-After) — RFC 0010 retry ladder s'applique.
  • Pas de pool de connexion HTTP custom — reqwest::Client par défaut.
  • Pas de migration des callers verify(actor, sig, body, pem) vers la nouvelle signature au-delà des sites internes ; les apps consommatrices qui implémentaient leur SignatureVerifier doivent migrer.

API proposée

Nouvelle struct VerifyCtx

/// Contexte d'une requête entrante à vérifier (RFC 0016).
pub struct VerifyCtx<'a> {
    /// AP id de l'actor signataire (extrait du body).
    pub actor_ap_id: &'a str,
    /// HTTP method en lowercase (`"post"`).
    pub method: &'a str,
    /// Path-and-query (`"/inbox"`, `"/fediverse/auth/inbox"`).
    pub path: &'a str,
    /// Tous les headers de la requête (lookup case-insensitive).
    pub headers: &'a axum::http::HeaderMap,
    /// Body brut (pour digest/audit).
    pub body: &'a [u8],
    /// PEM public résolu pour `actor_ap_id` (passé par le caller —
    /// laissé vide quand le resolveur est délégué à l'impl du trait).
    pub public_key_pem: &'a str,
}

Trait SignatureVerifier v2

#[async_trait]
pub trait SignatureVerifier: Send + Sync {
    async fn verify(&self, ctx: &VerifyCtx<'_>) -> Result<(), AppError>;
}

Impls livrées

  • AlwaysAcceptVerifier — refactor trivial.
  • HttpSigVerifier — nouvelle impl. Lit ctx.headers["signature"], construit request_target = format!("{} {}", ctx.method, ctx.path), appelle parse_and_verify_cavage(...). Quand public_key_pem est vide, l'impl peut consulter RemoteIdentityService (mais cette RFC reste minimaliste : si vide → Err(Validation("public key not resolved"))).

HttpSignedDelivery

pub struct HttpSignedDelivery {
    db: DatabaseConnection,
    http: reqwest::Client,
    base_url: String,
}

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

Algorithme :

  1. Si item.actor_user_id est None → Err(Config("HttpSignedDelivery requires actor_user_id")).
  2. Charger la paire RSA via ActorKeyService::ensure_keys(db, actor_user_id).
  3. Construire (request-target) host date + signer.
  4. POST item.payload_json à item.target_inbox avec headers Date, Host, Signature, Content-Type: application/activity+json.
  5. 2xx → Ok(()). Tout le reste → Err(Internal("HTTP <status>")). La logique retry/backoff de RFC 0010 reprend depuis drain_one.

Analyse de compatibilité

  • Breaking au niveau du trait SignatureVerifier — toute app consommatrice qui a implémenté son propre verifier doit migrer. Mitigation : experimental-fediverse gating + l'API était déjà labellisée expérimentale dans RFC 0010.
  • Pas de schéma DB.
  • HttpSignedDelivery est purement additif.

Alternatives considérées

Alternative A (recommandée) — Trait v2 + HttpSignedDelivery en une RFC

Pros : cohérence ; one-shot pour la prod ; les deux changements partagent le contexte.

Cons : breaking pour le trait. Mitigation par feature gating.

Alternative B — Garder le trait v1, ajouter une seconde méthode verify_with_request

Cons : surface API plus large à maintenir, deux chemins de vérif divergents.

Rejeté.

Alternative C — Ne rien faire (différer encore)

Cons : interop Mastodon impossible.

Rejeté.

Testing plan

8 tests :

Trait migration :

  1. always_accept_v2_accepts_any_ctx.
  2. http_sig_verifier_returns_err_when_signature_header_missing.
  3. http_sig_verifier_returns_err_when_public_key_pem_empty.
  4. http_sig_verifier_accepts_valid_signature — round-trip avec une paire générée.

Inbox/auth integration : 5. inbox_handle_uses_widened_verifier — smoke test que les handlers passent bien VerifyCtx. 6. auth_inbox_uses_widened_verifier.

HttpSignedDelivery (mocked) : 7. delivery_returns_err_when_actor_user_id_none. 8. delivery_signs_input_string_with_actor_keys — pas d'envoi HTTP réel ; on intercepte la signature via une impl test du reqwest::Client … en pratique on extrait la primitive sign pour la tester séparément (déjà couvert par RFC 0015).

Note : un test d'intégration end-to-end avec un fake HTTP server arrivera dans un follow-up tests/interop/.

Migration guide

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

- **Breaking** : `SignatureVerifier::verify(...)` change de signature.
  Avant :
  ```rust
  async fn verify(&self, actor_ap_id: &str, sig: &str, body: &[u8], pem: &str)
      -> Result<(), AppError>;

Après :

async fn verify(&self, ctx: &VerifyCtx<'_>) -> Result<(), AppError>;
  • Les handlers inbox::handle et auth_routes::auth_inbox reconstituent VerifyCtx à partir du Request.

Added

  • rustwarden_core::services::fediverse_signal_service::VerifyCtx.
  • rustwarden_core::fediverse::http_signatures::HttpSigVerifier.
  • rustwarden_core::fediverse::http_signatures::HttpSignedDelivery.
  • reqwest ajouté à experimental-fediverse dependencies.

Migration steps pour les apps consommatrices

  1. Mettre à jour les impls custom de SignatureVerifier :
    // Avant
    async fn verify(&self, actor: &str, sig: &str, body: &[u8], pem: &str) { … }
    // Après
    async fn verify(&self, ctx: &VerifyCtx<'_>) { … }
    
  2. Activer la production-grade verification :
    builder.with_signature_verifier(Arc::new(HttpSigVerifier::default()))
    
  3. Activer la livraison signée :
    builder.with_delivery(Arc::new(HttpSignedDelivery::new(db, base_url)))
    

## Open questions

Toutes résolues à l'auto-revue :

- [x] **Tolerance horloge `Date`** : 1 h, contrôlée par `HttpSigVerifier::clock_skew_secs` (paramétrable, 3600 par défaut).
- [x] **`actor_user_id` obligatoire pour HttpSignedDelivery** : oui, `Err(Config)` sinon. Les apps qui veulent que des activités non-attribuées soient livrées doivent piocher un actor system par défaut.
- [x] **`reqwest` partagé avec `oauth`** : oui, on ajoute `reqwest` à `experimental-fediverse` ; pas de duplication de version.

## Tracking issue

À ouvrir au moment de la PR.

---

## Score d'évaluation

| Critère | Note (1-5) | Commentaire |
|---|---|---|
| Généralité | 4 | `VerifyCtx` réutilisable pour autres protocoles signés. |
| Stabilité API | 3 | Breaking sur le trait, mitigé par gating expérimental. |
| Performance | 4 | Une signature RSA par activité — coût attendu de la fédération. |
| Sécurité | 5 | C'est **le** déblocage prod ; la TLS est gérée par `reqwest`. |
| Complexité d'implémentation | 3 | ~700 lignes (struct + 2 impls + 8 tests). |
| Maintenance | 4 | Pas de nouvelle dep majeure. |
| Documentation | 4 | Rustdoc EN, doctest, CHANGELOG, migration guide complet. |
| **Total** | **27/35** | ≥ 25 → Accepted |