Skip to content

Lemmy's federation signs and verifies with rind - #468

Closed
kixelated wants to merge 1 commit into
mainfrom
quest/m2/adoption/lemmy
Closed

kixelated wants to merge 1 commit into
mainfrom
quest/m2/adoption/lemmy

Conversation

@kixelated

@kixelated kixelated commented Nov 7, 2026 •

Copy link
Copy Markdown
Collaborator

Claims quest/m2/adoption/lemmy.md.

A port of LemmyNet/activitypub-federation-rust (the crate under Lemmy's federation) from the rsa crate to rind is prepared and tested, with numbers. Nothing lands in rind; the upstream PR waits on the maintainer's go-ahead to post.

Waiting

  • Maintainer: approve posting the PR below to LemmyNet/activitypub-federation-rust (outside moq-dev/kixelated, so not posted). Options: post now (recommended), stating that rind covers x86_64, aarch64, and armv7, which covers Lemmy's amd64/arm64 Docker images, while riscv64, ppc64, and s390x wait on the targets epic (moq-dev/rind#458); or hold it until that epic lands, so the PR carries no platform caveat. I recommend posting now: LemmyNet/lemmy#4900's portability was about dropping OpenSSL's C build and custom Docker images, and rind keeps both. LemmyNet has no AI-contribution policy (checked lemmy-docs' contributor overview and the repos' CONTRIBUTING files), so the text below is ready to paste; it suggests discussing bigger changes in an issue first, which this ~100-line diff doesn't need. To post: apply the diff to main (dd44ca3), run cargo build to refresh Cargo.lock, push to a fork, and open the PR with the text below. Check it's done when the PR URL is linked here.

Upstream PR

Title: Use rind instead of the rsa crate for HTTP signatures

Body, ready to paste
Lemmy moved from OpenSSL to the `rsa` crate for portability ([LemmyNet/lemmy#4900](https://github.com/LemmyNet/lemmy/issues/4900), [#116](https://github.com/LemmyNet/activitypub-federation-rust/pull/116)). This PR keeps that (no C toolchain: rind's assembly is in Rust source, so `cargo build` needs only rustc and a linker) and swaps `rsa` for [rind](https://github.com/moq-dev/rind), which is several times faster at the two operations every federated request pays for, and is constant-time where `rsa` 0.9 carries [RUSTSEC-2023-0071](https://rustsec.org/advisories/RUSTSEC-2023-0071) (Marvin timing side channel, no fixed release).

## Numbers

Criterion on an AMD Ryzen 9 5900XT (Zen 3), RSA-2048, each row the exact calls this crate makes before and after (harness under Benchmark harness below):

| Operation | When | `rsa` 0.9.10 | rind 0.2.1 | Speedup |
|---|---|---|---|---|
| Parse the sender's PEM public key and verify | every incoming activity | 153 µs | 14.0 µs | 10.9x |
| Sign | every outgoing request, once per inbox | 1.22 ms | 417 µs | 2.9x |
| Generate a key pair and encode it as PEM | every new user or community | 150 ms | 36 ms | 4.2x |
| Load a PKCS#8 PEM private key | once per actor, then cached | 34.4 µs | 60.7 µs | 0.57x |

In a debug build with each crate's `opt-level = 3` override (this PR moves the override from `num-bigint-dig` to `rind`), key generation takes 28 ms instead of 193 ms (median of 10).

## What changes

- Signing (`sign_request`), verification (`verify_signature_inner`), and key generation (`generate_actor_keypair`) use `rind::signature` (RSASSA-PKCS1-v1_5 with SHA-256, as before).
- Keys are still PEM: PKCS#8 (`BEGIN PRIVATE KEY`) and SPKI (`BEGIN PUBLIC KEY`), parsed with `pem-rfc7468`, the same PEM parser `rsa` used through `pkcs8`/`spki`. Stored keys load unchanged, and keys generated after this PR load in `rsa` 0.9 too, so a downgrade is safe.
- PKCS#1 v1.5 signatures are deterministic, and the existing `test_sign` vector passes unchanged: the bytes on the wire are identical.
- Parsed private keys are cached as `Arc<RsaKeyPair>`, so each `SendActivityTask` clones a pointer instead of the whole key's bignums.
- `rsa` is gone, and the dev-profile `opt-level = 3` override moves from `num-bigint-dig` to `rind`; `sha2` drops its `oid` feature; `rand` moves to dev-dependencies (only the examples use it).

## Compatibility

Checked with a harness that runs both the old and new code side by side:

- Public keys fetched from live Lemmy (user and community), Mastodon, Misskey, PeerTube, Pixelfed, Mbin, Hubzilla, PieFed, Mitra, Smithereen, WriteFreely, and Bridgy Fed actors parse identically in both (2048- and 4096-bit).
- 45 private keys (20 generated by `rsa` 0.9, 20 by rind, 5 by OpenSSL as Lemmy used to) load in both, sign byte-identically, and cross-verify.
- Lemmy `main` builds (`cargo check --workspace --all-targets`) against this branch with no changes. Lemmy can swap its own `num-bigint-dig` override for `rind` the same way.
- Most of the `Cargo.lock` additions are rind's 32-bit ARM backend and `cfg(hax)`/`cfg(valgrind_ct_test)`-gated crates, which Cargo locks for every target; on x86_64 and aarch64 rind builds only `cfg-if` and `getrandom`.
- Two deliberate differences in what verification accepts: public keys of 1024 to 8192 bits are accepted (`rsa` 0.9 accepted up to 4096, and keys under 1024 bits). No live server above uses either edge. (Pleroma serves its PEM with a trailing blank line, which both the old and new code refuse; that is unchanged here.)

## Breaking change

`Error` loses its `From<rsa::errors::Error>`, `From<pkcs8::Error>`, and `From<spki::Error>` impls. rind errors map to `Error::Other` internally, so rind's types stay out of this crate's public API. Lemmy uses none of these impls.

## Platforms

rind builds on x86_64 and aarch64 (Linux, macOS, Windows, Android, iOS) and 32-bit ARM, which covers Lemmy's Docker images. It does not yet build on riscv64, powerpc64, s390x, or other architectures that the `rsa` crate supports; a portable backend for those is in progress in rind.

## Testing

`cargo fmt --check` (nightly), `taplo format --check`, `cargo shear --deny-warnings`, `cargo clippy --all-targets --all-features`, `cargo test --all-features`, `cargo doc --all-features`, and both `local_federation` examples (actix-web and axum), which sign and verify between two instances.

## Benchmark harness

<details><summary><code>harness/src/lib.rs</code>, <code>harness/benches/apf.rs</code></summary>

```toml
[dependencies]
rsa = { version = "0.9.10", features = ["pem", "sha2"] }
sha2 = { version = "0.10.9", features = ["oid"] }
rand = "0.8.5"
rind = "0.2.1"
pem-rfc7468 = { version = "1.0.0", features = ["alloc"] }

[dev-dependencies]
criterion = "0.7"
```

```rust
//! The RSA operations activitypub-federation-rust performs, once on the `rsa`
//! crate as it does today and once on rind as the port does.

pub mod old {
    use rsa::{
        pkcs8::{DecodePrivateKey, DecodePublicKey, EncodePrivateKey, EncodePublicKey, LineEnding},
        Pkcs1v15Sign, RsaPrivateKey, RsaPublicKey,
    };
    use sha2::{Digest, Sha256};

    pub fn generate() -> (String, String) {
        let rsa = RsaPrivateKey::new(&mut rand::thread_rng(), 2048).unwrap();
        let public = RsaPublicKey::from(&rsa).to_public_key_pem(LineEnding::default()).unwrap();
        (rsa.to_pkcs8_pem(LineEnding::default()).unwrap().to_string(), public)
    }
    pub fn load(pem: &str) -> RsaPrivateKey {
        RsaPrivateKey::from_pkcs8_pem(pem).unwrap()
    }
    pub fn sign(key: &RsaPrivateKey, msg: &[u8]) -> Vec<u8> {
        key.sign(Pkcs1v15Sign::new::<Sha256>(), &Sha256::digest(msg)).unwrap()
    }
    pub fn parse_public(pem: &str) -> Result<RsaPublicKey, String> {
        RsaPublicKey::from_public_key_pem(pem).map_err(|e| e.to_string())
    }
    pub fn verify(public_pem: &str, msg: &[u8], sig: &[u8]) -> bool {
        let Ok(key) = parse_public(public_pem) else { return false };
        key.verify(Pkcs1v15Sign::new::<Sha256>(), &Sha256::digest(msg), sig).is_ok()
    }
}

pub mod new {
    use rind::{
        rsa::KeySize,
        signature::{sign::RSA_PKCS1_SHA256, verify::legacy::RSA_PKCS1_1024_SHA256, PublicKey, RsaKeyPair},
    };

    fn decode(label: &str, pem: &str) -> Result<Vec<u8>, String> {
        let (found, der) = pem_rfc7468::decode_vec(pem.as_bytes()).map_err(|e| e.to_string())?;
        if found != label {
            return Err(format!("label {found}"));
        }
        Ok(der)
    }
    fn encode(label: &str, der: &[u8]) -> String {
        pem_rfc7468::encode_string(label, pem_rfc7468::LineEnding::LF, der).unwrap()
    }
    pub fn generate() -> (String, String) {
        let key = RsaKeyPair::generate(KeySize::Rsa2048).unwrap();
        (encode("PRIVATE KEY", key.to_pkcs8().as_ref()), encode("PUBLIC KEY", key.to_spki().as_ref()))
    }
    pub fn load(pem: &str) -> RsaKeyPair {
        RsaKeyPair::from_pkcs8(&decode("PRIVATE KEY", pem).unwrap()).unwrap()
    }
    pub fn sign(key: &RsaKeyPair, msg: &[u8]) -> Vec<u8> {
        key.sign(&RSA_PKCS1_SHA256, msg).unwrap().as_ref().to_vec()
    }
    pub fn parse_public(pem: &str) -> Result<PublicKey, String> {
        PublicKey::from_spki(&RSA_PKCS1_1024_SHA256, &decode("PUBLIC KEY", pem)?).map_err(|e| e.to_string())
    }
    pub fn verify(public_pem: &str, msg: &[u8], sig: &[u8]) -> bool {
        let Ok(key) = parse_public(public_pem) else { return false };
        key.verify(msg, sig).is_ok()
    }
}

/// A signing string as http-signature-normalization builds it for a POST.
pub const SIGNING_STRING: &[u8] = b"(request-target): post /inbox\nhost: lemmy.example\ndate: Tue, 28 Mar 2023 21:03:44 GMT\ndigest: SHA-256=lzFT+G7C2hdI5j8M+FuJg1tC+O6AGMVJhooTCKGfbKM=\ncontent-type: application/activity+json";
```

```rust
use criterion::{criterion_group, criterion_main, Criterion};
use harness::{new, old, SIGNING_STRING};
use std::hint::black_box;

fn bench(c: &mut Criterion) {
    let (private_pem, public_pem) = old::generate();
    let sig = old::sign(&old::load(&private_pem), SIGNING_STRING);

    let mut g = c.benchmark_group("keygen RSA-2048 + PEM");
    g.sample_size(100);
    g.bench_function("rsa 0.9", |b| b.iter(old::generate));
    g.bench_function("rind", |b| b.iter(new::generate));
    g.finish();

    let mut g = c.benchmark_group("load PKCS#8 PEM private key");
    g.bench_function("rsa 0.9", |b| b.iter(|| old::load(black_box(&private_pem))));
    g.bench_function("rind", |b| b.iter(|| new::load(black_box(&private_pem))));
    g.finish();

    let old_key = old::load(&private_pem);
    let new_key = new::load(&private_pem);
    let mut g = c.benchmark_group("sign (per destination)");
    g.bench_function("rsa 0.9", |b| b.iter(|| old::sign(&old_key, black_box(SIGNING_STRING))));
    g.bench_function("rind", |b| b.iter(|| new::sign(&new_key, black_box(SIGNING_STRING))));
    g.finish();

    let mut g = c.benchmark_group("parse SPKI PEM + verify (per incoming request)");
    g.bench_function("rsa 0.9", |b| b.iter(|| assert!(old::verify(black_box(&public_pem), SIGNING_STRING, &sig))));
    g.bench_function("rind", |b| b.iter(|| assert!(new::verify(black_box(&public_pem), SIGNING_STRING, &sig))));
    g.finish();
}

criterion_group!(benches, bench);
criterion_main!(benches);
```

</details>

Disclosure: I work on rind.

(Written by Claude Opus 5.5 with high)
Diff against LemmyNet/activitypub-federation-rust dd44ca3 (Cargo.lock omitted; cargo build regenerates it)
From d54c23f3a5e31dbcbfd3956bafe04be829341c2e Mon Sep 17 00:00:00 2001
From: Luke Curley <kixelated@gmail.com>
Date: Sat, 7 Nov 2026 00:21:50 -0800
Subject: [PATCH] Use rind instead of rsa for HTTP signatures

Signing, verification, and key generation move from the rsa crate to
rind, keeping PEM (PKCS#8 and SPKI) storage and the same
RSASSA-PKCS1-v1_5 SHA-256 signatures. Parsed private keys are cached as
Arc<RsaKeyPair>, so each delivery task clones a pointer instead of the
key.

Error loses its From impls for rsa, pkcs8, and spki errors; rind errors
map to Error::Other so rind stays out of the public API.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---
 Cargo.toml              |  12 ++---
 src/activity_sending.rs |  14 ++---
 src/config.rs           |  22 ++++----
 src/error.rs            |  22 --------
 src/fetch/mod.rs        |   2 +-
 src/http_signatures.rs  | 112 +++++++++++++++++++++++++---------------
 6 files changed, 93 insertions(+), 91 deletions(-)

diff --git a/Cargo.toml b/Cargo.toml
index b248378..85aff44 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -40,10 +40,8 @@ reqwest = { version = "0.13.5", default-features = false, features = [
 reqwest-middleware = "0.5.2"
 tracing = "0.1.44"
 base64 = "0.23.1"
-rand = "0.8.5"
-rsa = "0.9.10"
 http = "1.5.0"
-sha2 = { version = "0.10.9", features = ["oid"] }
+sha2 = "0.10.9"
 thiserror = "2.0.21"
 derive_builder = "0.20.2"
 itertools = "0.15.0"
@@ -72,6 +70,8 @@ tokio = { version = "1.54.1", features = [
 ] }
 futures = "0.3.34"
 moka = { version = "0.12.16", features = ["future"] }
+pem-rfc7468 = { version = "1.0.0", features = ["alloc"] }
+rind = "0.2.1"
 either = "1.19.0"
 
 # Actix-web
@@ -86,6 +86,7 @@ tower = { version = "0.5.3", optional = true }
 
 [dev-dependencies]
 anyhow = "1.0.104"
+rand = "0.8.5"
 axum = { version = "0.8.9", features = ["macros"] }
 env_logger = "0.11.11"
 tokio = { version = "1.54.1", features = ["full"] }
@@ -103,7 +104,6 @@ path = "examples/local_federation/main.rs"
 name = "live_federation"
 path = "examples/live_federation/main.rs"
 
-# Speedup RSA key generation
-# https://github.com/RustCrypto/RSA/blob/master/README.md#example
-[profile.dev.package.num-bigint-dig]
+# Speed up RSA key generation in debug builds
+[profile.dev.package.rind]
 opt-level = 3
diff --git a/src/activity_sending.rs b/src/activity_sending.rs
index 14466e7..49c6dbd 100644
--- a/src/activity_sending.rs
+++ b/src/activity_sending.rs
@@ -5,7 +5,7 @@
 use crate::{
     config::Data,
     error::Error,
-    http_signatures::sign_request,
+    http_signatures::{private_key_from_pem, sign_request},
     reqwest_shim::ResponseExt,
     traits::{Activity, Actor},
     FEDERATION_CONTENT_TYPE,
@@ -20,10 +20,11 @@ use reqwest::{
     Response,
 };
 use reqwest_middleware::ClientWithMiddleware;
-use rsa::{pkcs8::DecodePrivateKey, RsaPrivateKey};
+use rind::signature::RsaKeyPair;
 use serde::Serialize;
 use std::{
     fmt::{Debug, Display},
+    sync::Arc,
     time::{Duration, Instant, SystemTime},
 };
 use tracing::{debug, warn};
@@ -37,7 +38,7 @@ pub struct SendActivityTask {
     pub(crate) activity_id: Url,
     pub(crate) activity: Bytes,
     pub(crate) inbox: Url,
-    pub(crate) private_key: RsaPrivateKey,
+    pub(crate) private_key: Arc<RsaKeyPair>,
     pub(crate) http_signature_compat: bool,
 }
 
@@ -182,12 +183,11 @@ where
 pub(crate) async fn get_pkey_cached<ActorType>(
     data: &Data<impl Clone>,
     actor: &ActorType,
-) -> Result<RsaPrivateKey, Error>
+) -> Result<Arc<RsaKeyPair>, Error>
 where
     ActorType: Actor,
 {
     let actor_id = actor.id();
-    // PKey is internally like an Arc<>, so cloning is ok
     data.config
         .actor_pkey_cache
         .try_get_with_by_ref(actor_id, async {
@@ -199,13 +199,13 @@ where
 
             // This is a mostly expensive blocking call, we don't want to tie up other tasks while this is happening
             let pkey = tokio::task::spawn_blocking(move || {
-                RsaPrivateKey::from_pkcs8_pem(&private_key_pem).map_err(|err| {
+                private_key_from_pem(&private_key_pem).map_err(|err| {
                     Error::Other(format!("Could not create private key from PEM data:{err}"))
                 })
             })
             .await
             .map_err(|err| Error::Other(format!("Error joining: {err}")))??;
-            std::result::Result::<RsaPrivateKey, Error>::Ok(pkey)
+            std::result::Result::<Arc<RsaKeyPair>, Error>::Ok(Arc::new(pkey))
         })
         .await
         .map_err(|e| Error::Other(format!("cloned error: {e}")))
diff --git a/src/config.rs b/src/config.rs
index 1bc4902..3e5d302 100644
--- a/src/config.rs
+++ b/src/config.rs
@@ -17,7 +17,7 @@
 use crate::{
     activity_queue::{create_activity_queue, ActivityQueue},
     error::Error,
-    http_signatures::sign_request,
+    http_signatures::{private_key_from_pem, sign_request},
     protocol::verification::verify_domains_match,
     resolver::{validate_url, ValidatingDnsResolver},
     traits::{Activity, Actor},
@@ -30,7 +30,7 @@ use moka::future::Cache;
 use regex::Regex;
 use reqwest::{redirect::Policy, Client, Request};
 use reqwest_middleware::{ClientWithMiddleware, RequestBuilder};
-use rsa::{pkcs8::DecodePrivateKey, RsaPrivateKey};
+use rind::signature::RsaKeyPair;
 use serde::de::DeserializeOwned;
 use std::{
     ops::Deref,
@@ -109,12 +109,12 @@ pub struct FederationConfig<T: Clone> {
     /// This can be used to implement secure mode federation.
     /// <https://docs.joinmastodon.org/spec/activitypub/#secure-mode>
     #[builder(default = "None", setter(custom))]
-    pub(crate) signed_fetch_actor: Option<Arc<(Url, RsaPrivateKey)>>,
+    pub(crate) signed_fetch_actor: Option<(Url, Arc<RsaKeyPair>)>,
     #[builder(
         default = "Cache::builder().max_capacity(10000).build()",
         setter(custom)
     )]
-    pub(crate) actor_pkey_cache: Cache<Url, RsaPrivateKey>,
+    pub(crate) actor_pkey_cache: Cache<Url, Arc<RsaKeyPair>>,
     /// Queue for sending outgoing activities. Only optional to make builder work, its always
     /// present once constructed.
     #[builder(setter(skip))]
@@ -250,8 +250,8 @@ impl<T: Clone> FederationConfigBuilder<T> {
             .expect("actor does not have a private key to sign with");
 
         let private_key =
-            RsaPrivateKey::from_pkcs8_pem(&private_key_pem).expect("Could not decode PEM data");
-        self.signed_fetch_actor = Some(Some(Arc::new((actor.id().clone(), private_key))));
+            private_key_from_pem(&private_key_pem).expect("Could not decode PEM data");
+        self.signed_fetch_actor = Some(Some((actor.id().clone(), Arc::new(private_key))));
         self
     }
 
@@ -380,13 +380,9 @@ impl<T: Clone> Data<T> {
 
     /// Add HTTP signature to arbitrary request
     pub async fn sign_request(&self, req: RequestBuilder, body: Bytes) -> Result<Request, Error> {
-        let (actor_id, private_key_pem) =
-            self.config
-                .signed_fetch_actor
-                .as_deref()
-                .ok_or(Error::Other(
-                    "config value signed_fetch_actor is none".to_string(),
-                ))?;
+        let (actor_id, private_key_pem) = self.config.signed_fetch_actor.as_ref().ok_or(
+            Error::Other("config value signed_fetch_actor is none".to_string()),
+        )?;
         sign_request(
             req,
             actor_id,
diff --git a/src/error.rs b/src/error.rs
index fd4a48a..ec6caff 100644
--- a/src/error.rs
+++ b/src/error.rs
@@ -2,10 +2,6 @@
 
 use crate::fetch::webfinger::WebFingerError;
 use http_signature_normalization_reqwest::SignError;
-use rsa::{
-    errors::Error as RsaError,
-    pkcs8::{spki::Error as SpkiError, Error as Pkcs8Error},
-};
 use std::{net::IpAddr, string::FromUtf8Error};
 use tokio::task::JoinError;
 use url::Url;
@@ -97,24 +93,6 @@ pub enum Error {
     Other(String),
 }
 
-impl From<RsaError> for Error {
-    fn from(value: RsaError) -> Self {
-        Error::Other(value.to_string())
-    }
-}
-
-impl From<Pkcs8Error> for Error {
-    fn from(value: Pkcs8Error) -> Self {
-        Error::Other(value.to_string())
-    }
-}
-
-impl From<SpkiError> for Error {
-    fn from(value: SpkiError) -> Self {
-        Error::Other(value.to_string())
-    }
-}
-
 impl PartialEq for Error {
     fn eq(&self, other: &Self) -> bool {
         std::mem::discriminant(self) == std::mem::discriminant(other)
diff --git a/src/fetch/mod.rs b/src/fetch/mod.rs
index bca22d0..6ba9901 100644
--- a/src/fetch/mod.rs
+++ b/src/fetch/mod.rs
@@ -118,7 +118,7 @@ async fn fetch_object_http_with_accept<T: Clone, Kind: DeserializeOwned>(
         .header("Accept", content_type)
         .timeout(config.request_timeout);
 
-    let res = if let Some((actor_id, private_key_pem)) = config.signed_fetch_actor.as_deref() {
+    let res = if let Some((actor_id, private_key_pem)) = config.signed_fetch_actor.as_ref() {
         let req = sign_request(
             req,
             actor_id,
diff --git a/src/http_signatures.rs b/src/http_signatures.rs
index 612994d..5ad90bf 100644
--- a/src/http_signatures.rs
+++ b/src/http_signatures.rs
@@ -19,17 +19,26 @@ use http_signature_normalization_reqwest::{
     prelude::{Config, SignExt},
     DefaultSpawner,
 };
+use pem_rfc7468::LineEnding;
 use reqwest::Request;
 use reqwest_middleware::RequestBuilder;
-use rsa::{
-    pkcs8::{DecodePublicKey, EncodePrivateKey, EncodePublicKey, LineEnding},
-    Pkcs1v15Sign,
-    RsaPrivateKey,
-    RsaPublicKey,
+use rind::{
+    rsa::KeySize,
+    signature::{
+        sign::RSA_PKCS1_SHA256,
+        verify::legacy::RSA_PKCS1_1024_SHA256,
+        PublicKey,
+        RsaKeyPair,
+    },
 };
 use serde::Deserialize;
 use sha2::{Digest, Sha256};
-use std::{collections::BTreeMap, fmt::Debug, sync::LazyLock, time::Duration};
+use std::{
+    collections::BTreeMap,
+    fmt::Debug,
+    sync::{Arc, LazyLock},
+    time::Duration,
+};
 use tracing::debug;
 use url::Url;
 
@@ -43,32 +52,58 @@ pub struct Keypair {
 }
 
 impl Keypair {
-    /// Helper method to turn this into an openssl private key
+    /// Helper method to parse the private key for signing
     #[cfg(test)]
-    pub(crate) fn private_key(&self) -> Result<RsaPrivateKey, anyhow::Error> {
-        use rsa::pkcs8::DecodePrivateKey;
-
-        Ok(RsaPrivateKey::from_pkcs8_pem(&self.private_key)?)
+    pub(crate) fn private_key(&self) -> Result<Arc<RsaKeyPair>, Error> {
+        private_key_from_pem(&self.private_key).map(Arc::new)
     }
 }
 
 /// Generate a random asymmetric keypair for ActivityPub HTTP signatures.
-///
-/// Note that this method is very slow in debug mode. To make it faster, follow
-/// instructions in the RSA crate's readme.
-/// <https://github.com/RustCrypto/RSA/blob/master/README.md>
 pub fn generate_actor_keypair() -> Result<Keypair, Error> {
-    let mut rng = rand::thread_rng();
-    let rsa = RsaPrivateKey::new(&mut rng, 2048)?;
-    let pkey = RsaPublicKey::from(&rsa);
-    let public_key = pkey.to_public_key_pem(LineEnding::default())?;
-    let private_key = rsa.to_pkcs8_pem(LineEnding::default())?.to_string();
+    let key_pair = RsaKeyPair::generate(KeySize::Rsa2048).map_err(crypto_error)?;
     Ok(Keypair {
-        private_key,
-        public_key,
+        private_key: pem_encode(PRIVATE_KEY_LABEL, key_pair.to_pkcs8().as_ref())?,
+        public_key: pem_encode(PUBLIC_KEY_LABEL, key_pair.to_spki().as_ref())?,
     })
 }
 
+const PRIVATE_KEY_LABEL: &str = "PRIVATE KEY";
+const PUBLIC_KEY_LABEL: &str = "PUBLIC KEY";
+
+/// Parses a PKCS#8 PEM private key (`BEGIN PRIVATE KEY`) for signing.
+pub(crate) fn private_key_from_pem(pem: &str) -> Result<RsaKeyPair, Error> {
+    let der = pem_decode(PRIVATE_KEY_LABEL, pem)?;
+    RsaKeyPair::from_pkcs8(&der).map_err(crypto_error)
+}
+
+/// Parses a SubjectPublicKeyInfo PEM public key (`BEGIN PUBLIC KEY`) for verifying.
+///
+/// Keys down to 1024 bits are accepted, so that older servers keep federating.
+fn public_key_from_pem(pem: &str) -> Result<PublicKey, Error> {
+    let der = pem_decode(PUBLIC_KEY_LABEL, pem)?;
+    PublicKey::from_spki(&RSA_PKCS1_1024_SHA256, &der).map_err(crypto_error)
+}
+
+fn pem_decode(label: &str, pem: &str) -> Result<Vec<u8>, Error> {
+    let (found, der) =
+        pem_rfc7468::decode_vec(pem.as_bytes()).map_err(|e| Error::Other(e.to_string()))?;
+    if found != label {
+        return Err(Error::Other(format!(
+            "expected PEM label {label}, found {found}"
+        )));
+    }
+    Ok(der)
+}
+
+fn pem_encode(label: &str, der: &[u8]) -> Result<String, Error> {
+    pem_rfc7468::encode_string(label, LineEnding::LF, der).map_err(|e| Error::Other(e.to_string()))
+}
+
+fn crypto_error(err: rind::Error) -> Error {
+    Error::Other(err.to_string())
+}
+
 /// Time for which HTTP signatures are valid.
 ///
 /// This field is optional in the standard, but required by the Rust library. It is not clear
@@ -82,7 +117,7 @@ pub(crate) async fn sign_request(
     request_builder: RequestBuilder,
     actor_id: &Url,
     activity: Bytes,
-    private_key: RsaPrivateKey,
+    private_key: Arc<RsaKeyPair>,
     http_signature_compat: bool,
 ) -> Result<Request, Error> {
     static CONFIG: LazyLock<Config<DefaultSpawner>> =
@@ -105,10 +140,10 @@ pub(crate) async fn sign_request(
             Sha256::new(),
             activity,
             move |signing_string| {
-                Ok(Base64.encode(private_key.sign(
-                    Pkcs1v15Sign::new::<Sha256>(),
-                    &Sha256::digest(signing_string.as_bytes()),
-                )?)) as Result<_, Error>
+                let signature = private_key
+                    .sign(&RSA_PKCS1_SHA256, signing_string.as_bytes())
+                    .map_err(crypto_error)?;
+                Ok(Base64.encode(signature)) as Result<_, Error>
             },
         )
         .await
@@ -214,18 +249,14 @@ fn verify_signature_inner<T: Clone>(
                 "Verifying with key {}, message {}",
                 &public_key, &signing_string
             );
-            let public_key = RsaPublicKey::from_public_key_pem(public_key)?;
+            let public_key = public_key_from_pem(public_key)?;
 
             let base64_decoded = Base64
                 .decode(signature)
                 .map_err(|err| Error::Other(err.to_string()))?;
 
             Ok(public_key
-                .verify(
-                    Pkcs1v15Sign::new::<Sha256>(),
-                    &Sha256::digest(signing_string.as_bytes()),
-                    &base64_decoded,
-                )
+                .verify(signing_string.as_bytes(), &base64_decoded)
                 .is_ok())
         })?;
 
@@ -298,7 +329,6 @@ pub mod test {
     use crate::activity_sending::generate_request_headers;
     use reqwest::Client;
     use reqwest_middleware::ClientWithMiddleware;
-    use rsa::{pkcs1::DecodeRsaPrivateKey, pkcs8::DecodePrivateKey};
     use std::str::FromStr;
 
     static ACTOR_ID: LazyLock<Url> =
@@ -322,7 +352,7 @@ pub mod test {
             request_builder,
             &ACTOR_ID,
             "my activity".into(),
-            RsaPrivateKey::from_pkcs8_pem(&test_keypair().private_key).unwrap(),
+            test_keypair().private_key().unwrap(),
             // set this to prevent created/expires headers to be generated and inserted
             // automatically from current time
             true,
@@ -358,7 +388,7 @@ pub mod test {
             request_builder,
             &ACTOR_ID,
             "my activity".to_string().into(),
-            RsaPrivateKey::from_pkcs8_pem(&test_keypair().private_key).unwrap(),
+            test_keypair().private_key().unwrap(),
             false,
         )
         .await
@@ -401,13 +431,11 @@ pub mod test {
 
     /// Internal only, return hardcoded keypair for testing
     pub fn test_keypair() -> Keypair {
-        let rsa = RsaPrivateKey::from_pkcs1_pem(PRIVATE_KEY).unwrap();
-        let pkey = RsaPublicKey::from(&rsa);
-        let public_key = pkey.to_public_key_pem(LineEnding::default()).unwrap();
-        let private_key = rsa.to_pkcs8_pem(LineEnding::default()).unwrap().to_string();
+        let rsa =
+            RsaKeyPair::from_pkcs1(&pem_decode("RSA PRIVATE KEY", PRIVATE_KEY).unwrap()).unwrap();
         Keypair {
-            private_key,
-            public_key,
+            private_key: pem_encode(PRIVATE_KEY_LABEL, rsa.to_pkcs8().as_ref()).unwrap(),
+            public_key: pem_encode(PUBLIC_KEY_LABEL, rsa.to_spki().as_ref()).unwrap(),
         }
     }
 
-- 
2.56.0

Validation

All on the port above, against rsa 0.9.10 (what apf pins):

  • apf's own CI steps from .woodpecker.yml: nightly cargo fmt --check, taplo format --check, cargo shear --deny-warnings, cargo clippy --all-targets --all-features (clean), cargo test --all-features (29 + 43 pass, including the hard-coded test_sign vector, so signatures are byte-identical), cargo doc --all-features, and both local_federation examples (actix-web, axum), which sign and verify between two local instances. With RUSTDOCFLAGS=-Dwarnings, which their CI doesn't set, cargo doc reports two broken intra-doc links in the IP-check code, outside this diff.
  • Compat harness (old and new code side by side): public keys from live Lemmy (user and community), Mastodon, Misskey, PeerTube, Pixelfed, Mbin, Hubzilla, PieFed, Mitra, Smithereen, WriteFreely, and Bridgy Fed actors parse the same in both. 45 private keys (20 from rsa, 20 from rind, 5 from OpenSSL) load in both, sign byte-identically, and cross-verify. The only differences are 512-bit keys (rsa takes them, rind refuses) and 8192-bit keys (rsa refuses, rind takes them). Pleroma serves its PEM with a trailing blank line, and both refuse it, so that behavior is unchanged.
  • Lemmy main, patched to this apf, passes cargo check --workspace --exclude lemmy_server --all-targets. lemmy_server only adds jemalloc, whose C build fails in this sandbox.
  • Benchmarks: criterion under quest gate --host on nixos (Ryzen 9 5900XT, Zen 3). Numbers are in the PR text.

Choices made without asking

  • Verification takes verify::legacy::RSA_PKCS1_1024_SHA256 (1024 to 8192 bits), not the 2048-bit default. rsa 0.9 accepted anything up to 4096, so a 2048 floor could cut off old servers with 1024-bit keys. Every live key sampled is 2048 or 4096.
  • PEM goes through pem-rfc7468 1.0, a new direct dependency but the same parser rsa used through pkcs8/spki (0.7 there, with an unchanged decode_vec), so PEM acceptance doesn't drift.
  • No zeroize on the decoded PKCS#8 DER. The PEM string it comes from already sits unwiped in the caller's heap, so wiping the copy buys nothing; rind wipes the RsaKeyPair itself.
  • rind errors map to Error::Other privately instead of through From impls, keeping rind's types out of apf's public API so a rind major bump never forces an apf one.
  • The dev-profile opt-level = 3 override moves from num-bigint-dig to rind. Without it, debug key generation is slower than rsa with its override (275 vs 141 ms median); with it, 28 vs 193 ms.
  • rand moves to dev-dependencies, since only the examples use it now, and cargo-shear flags it otherwise.

Public API

rind: none. apf: Error loses its From impls for rsa, pkcs8, and spki errors (it's on 0.7.0 betas, and Lemmy uses none of them).

Models

Size: M
Implementer: Claude Code, claude-opus-5-5, high effort

Review

Skipped: nothing lands in rind (the branch holds only the claim commit). The upstream diff gets its review on LemmyNet's PR.

Suggested follow-ups

  • Lemmy's JWTs run on rind-jsonwebtoken (plan): once this lands, rsa is still in Lemmy's tree through jsonwebtoken's rust_crypto feature (lemmy_api_utils). rind-jsonwebtoken plugs into jsonwebtoken 11's CryptoProvider, so a small Lemmy PR would drop rsa and RUSTSEC-2023-0071 from Lemmy entirely.
  • Pleroma's trailing-newline PEM (drop): this predates the port and the port doesn't change it. It's apf's parsing choice, not rind's.

(Written by Claude Opus 5.5 with high)

Quest-Claim: 8d18118f9d785329
@kixelated

Copy link
Copy Markdown
Collaborator Author

Closing: the maintainer dropped this quest on 2026-11-07 (let projects pull rind rather than send them patches). moq-dev/rind#471 deletes it with the others.

(Written by Claude Opus 5.5 with high)

@kixelated kixelated closed this Nov 7, 2026
@kixelated
kixelated deleted the quest/m2/adoption/lemmy branch November 7, 2026 23:52
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant