Skip to main content

solid_pod_rs_activitypub/
actor.rs

1//! ActivityPub Actor document (§4.1) + keypair management.
2//!
3//! JSS parity: mirrors the Accept-negotiated Actor document produced by
4//! `src/server.js:238-259` and `src/ap/routes/actor.js`. The Rust
5//! surface is framework-agnostic — consumers wire [`render_actor`] into
6//! their HTTP layer (axum/actix/etc).
7//!
8//! The Actor's signing key is RSA-2048 for broad Mastodon/Pleroma
9//! interop — these implementations historically validated only
10//! `RSA-SHA256` and `rsa-sha256` HTTP Signatures (draft-cavage v12). A
11//! forward-looking Ed25519 variant is trivial to add if/when upstream
12//! AP fleets accept it. See
13//! <https://docs.joinmastodon.org/spec/activitypub/#http-signatures>.
14
15use rsa::pkcs8::{EncodePrivateKey, EncodePublicKey, LineEnding};
16use rsa::{RsaPrivateKey, RsaPublicKey};
17use serde::{Deserialize, Serialize};
18
19/// PEM-encoded public key embedded in the Actor document.
20#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
21pub struct PublicKey {
22    pub id: String,
23    pub owner: String,
24    #[serde(rename = "publicKeyPem")]
25    pub public_key_pem: String,
26}
27
28/// Sharedinbox / streams endpoints exposed under `endpoints`. Mastodon
29/// probes this to discover the per-instance sharedInbox — the field is
30/// optional but widely expected.
31#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
32pub struct Endpoints {
33    #[serde(rename = "sharedInbox", skip_serializing_if = "Option::is_none")]
34    pub shared_inbox: Option<String>,
35}
36
37/// ActivityPub Actor document (`type: Person`).
38///
39/// The serialisation preserves the JSON-LD contexts in insertion order
40/// because several major fediverse servers parse the `@context` array
41/// positionally rather than using a true JSON-LD processor.
42#[derive(Debug, Clone, Serialize, Deserialize)]
43pub struct Actor {
44    #[serde(rename = "@context")]
45    pub context: Vec<serde_json::Value>,
46    pub id: String,
47    #[serde(rename = "type")]
48    pub actor_type: String,
49    #[serde(rename = "preferredUsername")]
50    pub preferred_username: String,
51    pub name: String,
52    #[serde(skip_serializing_if = "Option::is_none")]
53    pub summary: Option<String>,
54    pub inbox: String,
55    pub outbox: String,
56    pub followers: String,
57    pub following: String,
58    #[serde(rename = "publicKey")]
59    pub public_key: PublicKey,
60    #[serde(skip_serializing_if = "Option::is_none")]
61    pub endpoints: Option<Endpoints>,
62    /// Optional `alsoKnownAs` — the SAND stack uses this to link the
63    /// Actor to a did:nostr identifier.
64    #[serde(rename = "alsoKnownAs", skip_serializing_if = "Vec::is_empty", default)]
65    pub also_known_as: Vec<String>,
66}
67
68/// Generate a fresh RSA-2048 keypair and return PEM-encoded
69/// `(private_key_pem, public_key_pem)` pair.
70///
71/// RSA-2048 is the Mastodon interop baseline — RSA-4096 works in
72/// theory but causes timeout failures on several major servers that
73/// hard-code a 4 s verification budget.
74pub fn generate_actor_keypair() -> Result<(String, String), crate::error::SigError> {
75    let mut rng = rand::thread_rng();
76    let private_key = RsaPrivateKey::new(&mut rng, 2048)
77        .map_err(|e| crate::error::SigError::Rsa(e.to_string()))?;
78    let public_key = RsaPublicKey::from(&private_key);
79    let priv_pem = private_key
80        .to_pkcs8_pem(LineEnding::LF)
81        .map_err(|e| crate::error::SigError::Rsa(e.to_string()))?
82        .to_string();
83    let pub_pem = public_key
84        .to_public_key_pem(LineEnding::LF)
85        .map_err(|e| crate::error::SigError::Rsa(e.to_string()))?;
86    Ok((priv_pem, pub_pem))
87}
88
89/// Render an Actor document for the pod at `base_url`. `base_url` is
90/// the scheme+host only (e.g. `https://pod.example`). The document
91/// exposes endpoints relative to `/profile/card.jsonld`, matching JSS.
92///
93/// `preferred_username` is the WebFinger local-part; `display_name` is
94/// the human-facing label. `pubkey_pem` must already be PEM-encoded
95/// (either freshly generated via [`generate_actor_keypair`] or loaded
96/// from disk).
97pub fn render_actor(
98    base_url: &str,
99    preferred_username: &str,
100    display_name: &str,
101    summary: Option<&str>,
102    pubkey_pem: &str,
103) -> Actor {
104    let base = base_url.trim_end_matches('/');
105    let profile = format!("{base}/profile/card.jsonld");
106    let actor_id = format!("{profile}#me");
107
108    Actor {
109        context: vec![
110            serde_json::Value::String("https://www.w3.org/ns/activitystreams".to_string()),
111            serde_json::Value::String("https://w3id.org/security/v1".to_string()),
112        ],
113        id: actor_id.clone(),
114        actor_type: "Person".to_string(),
115        preferred_username: preferred_username.to_string(),
116        name: display_name.to_string(),
117        summary: summary.map(|s| s.to_string()),
118        inbox: format!("{profile}/inbox"),
119        outbox: format!("{profile}/outbox"),
120        followers: format!("{profile}/followers"),
121        following: format!("{profile}/following"),
122        public_key: PublicKey {
123            id: format!("{profile}#main-key"),
124            owner: actor_id,
125            public_key_pem: pubkey_pem.to_string(),
126        },
127        endpoints: Some(Endpoints {
128            shared_inbox: Some(format!("{base}/inbox")),
129        }),
130        also_known_as: Vec::new(),
131    }
132}
133
134/// The format to serve from the actor endpoint based on Accept
135/// content-negotiation. JSS uses a dedicated route for the AP profile
136/// that content-negotiates between ActivityPub JSON-LD and LDP Turtle/
137/// JSON-LD profile.
138#[derive(Debug, Clone, Copy, PartialEq, Eq)]
139pub enum ActorFormat {
140    /// `application/activity+json` or
141    /// `application/ld+json; profile="https://www.w3.org/ns/activitystreams"`
142    ActivityJson,
143    /// Everything else — serve the Solid/LDP profile representation.
144    LdpProfile,
145}
146
147/// Inspect an HTTP `Accept` header value and decide whether the
148/// requester wants the ActivityPub JSON-LD representation or the
149/// regular LDP profile.
150///
151/// Matching rules (mirrors JSS `src/ap/routes/actor.js`):
152///
153/// * `application/activity+json` anywhere in the Accept value → [`ActorFormat::ActivityJson`]
154/// * `application/ld+json` **with** the ActivityStreams profile
155///   parameter → [`ActorFormat::ActivityJson`]
156/// * Anything else (including missing/empty Accept) → [`ActorFormat::LdpProfile`]
157pub fn negotiate_actor_format(accept: &str) -> ActorFormat {
158    // Normalise for case-insensitive matching.
159    let lower = accept.to_ascii_lowercase();
160
161    // Exact media-type check.
162    if lower.contains("application/activity+json") {
163        return ActorFormat::ActivityJson;
164    }
165
166    // ld+json with the ActivityStreams profile parameter.
167    if lower.contains("application/ld+json") {
168        // The profile parameter may appear as:
169        //   profile="https://www.w3.org/ns/activitystreams"
170        // with optional spacing around '='.
171        if lower.contains("https://www.w3.org/ns/activitystreams") {
172            return ActorFormat::ActivityJson;
173        }
174    }
175
176    ActorFormat::LdpProfile
177}
178
179/// Attach a did:nostr identifier (or any URI) to the Actor's
180/// `alsoKnownAs` set. Used to bind AP identities to NIP-01 pubkeys in
181/// the SAND stack.
182pub fn with_also_known_as(mut actor: Actor, also: impl IntoIterator<Item = String>) -> Actor {
183    actor.also_known_as.extend(also);
184    actor
185}
186
187// ---------------------------------------------------------------------------
188// Tests
189// ---------------------------------------------------------------------------
190
191#[cfg(test)]
192mod tests {
193    use super::*;
194
195    #[test]
196    fn actor_document_shape() {
197        let actor = render_actor(
198            "https://pod.example",
199            "alice",
200            "Alice Example",
201            Some("bio"),
202            "-----BEGIN PUBLIC KEY-----\nAAA\n-----END PUBLIC KEY-----",
203        );
204        assert_eq!(actor.id, "https://pod.example/profile/card.jsonld#me");
205        assert_eq!(actor.actor_type, "Person");
206        assert_eq!(actor.preferred_username, "alice");
207        assert_eq!(actor.inbox, "https://pod.example/profile/card.jsonld/inbox");
208        assert_eq!(
209            actor.outbox,
210            "https://pod.example/profile/card.jsonld/outbox"
211        );
212        assert_eq!(
213            actor.followers,
214            "https://pod.example/profile/card.jsonld/followers"
215        );
216        assert_eq!(
217            actor.following,
218            "https://pod.example/profile/card.jsonld/following"
219        );
220        assert_eq!(
221            actor.public_key.id,
222            "https://pod.example/profile/card.jsonld#main-key"
223        );
224        assert_eq!(actor.public_key.owner, actor.id);
225        assert!(actor.public_key.public_key_pem.contains("BEGIN PUBLIC KEY"));
226        assert_eq!(
227            actor
228                .endpoints
229                .as_ref()
230                .and_then(|e| e.shared_inbox.as_deref()),
231            Some("https://pod.example/inbox")
232        );
233    }
234
235    #[test]
236    fn actor_context_order_preserved_for_fediverse_compat() {
237        let actor = render_actor("https://pod.example", "bob", "Bob", None, "PEM");
238        // Several Mastodon/Pleroma releases positionally assume index 0
239        // is activitystreams. Keep this assertion strict.
240        assert_eq!(
241            actor.context[0],
242            serde_json::Value::String("https://www.w3.org/ns/activitystreams".to_string())
243        );
244        assert_eq!(
245            actor.context[1],
246            serde_json::Value::String("https://w3id.org/security/v1".to_string())
247        );
248    }
249
250    #[test]
251    fn actor_base_url_trailing_slash_normalised() {
252        let a = render_actor("https://pod.example/", "x", "X", None, "PEM");
253        let b = render_actor("https://pod.example", "x", "X", None, "PEM");
254        assert_eq!(a.id, b.id);
255        assert_eq!(a.inbox, b.inbox);
256    }
257
258    #[test]
259    fn actor_serialises_with_jsonld_fields() {
260        let actor = render_actor("https://pod.example", "alice", "Alice", None, "PEM");
261        let j = serde_json::to_value(&actor).unwrap();
262        assert!(j.get("@context").is_some());
263        assert_eq!(j["type"], "Person");
264        assert_eq!(j["preferredUsername"], "alice");
265        assert!(j.get("publicKey").is_some());
266    }
267
268    #[test]
269    fn also_known_as_appends() {
270        let actor = render_actor("https://pod.example", "a", "A", None, "PEM");
271        let linked = with_also_known_as(actor, ["did:nostr:abc".to_string()]);
272        assert_eq!(linked.also_known_as, vec!["did:nostr:abc".to_string()]);
273    }
274
275    #[test]
276    fn actor_keypair_generation_rsa2048() {
277        let (priv_pem, pub_pem) = generate_actor_keypair().expect("keypair generates");
278        assert!(priv_pem.starts_with("-----BEGIN PRIVATE KEY-----"));
279        assert!(pub_pem.starts_with("-----BEGIN PUBLIC KEY-----"));
280        // Roundtrip through rsa crate to confirm decodability.
281        use rsa::pkcs8::DecodePrivateKey;
282        use rsa::pkcs8::DecodePublicKey;
283        use rsa::traits::PublicKeyParts;
284        let sk = RsaPrivateKey::from_pkcs8_pem(&priv_pem).unwrap();
285        let pk = RsaPublicKey::from_public_key_pem(&pub_pem).unwrap();
286        assert_eq!(sk.size(), 256); // 2048 bits -> 256 bytes
287        assert_eq!(RsaPublicKey::from(&sk), pk);
288    }
289
290    // --- negotiate_actor_format tests ---
291
292    #[test]
293    fn negotiate_activity_json_media_type() {
294        assert_eq!(
295            negotiate_actor_format("application/activity+json"),
296            ActorFormat::ActivityJson,
297        );
298    }
299
300    #[test]
301    fn negotiate_activity_json_with_charset() {
302        assert_eq!(
303            negotiate_actor_format("application/activity+json; charset=utf-8"),
304            ActorFormat::ActivityJson,
305        );
306    }
307
308    #[test]
309    fn negotiate_ld_json_with_activitystreams_profile() {
310        assert_eq!(
311            negotiate_actor_format(
312                r#"application/ld+json; profile="https://www.w3.org/ns/activitystreams""#
313            ),
314            ActorFormat::ActivityJson,
315        );
316    }
317
318    #[test]
319    fn negotiate_ld_json_without_profile_is_ldp() {
320        assert_eq!(
321            negotiate_actor_format("application/ld+json"),
322            ActorFormat::LdpProfile,
323        );
324    }
325
326    #[test]
327    fn negotiate_html_is_ldp() {
328        assert_eq!(negotiate_actor_format("text/html"), ActorFormat::LdpProfile,);
329    }
330
331    #[test]
332    fn negotiate_empty_is_ldp() {
333        assert_eq!(negotiate_actor_format(""), ActorFormat::LdpProfile,);
334    }
335
336    #[test]
337    fn negotiate_mixed_accept_with_activity_json() {
338        // A browser-like Accept that also lists activity+json.
339        assert_eq!(
340            negotiate_actor_format("text/html, application/activity+json, */*"),
341            ActorFormat::ActivityJson,
342        );
343    }
344
345    #[test]
346    fn negotiate_case_insensitive() {
347        assert_eq!(
348            negotiate_actor_format("Application/Activity+JSON"),
349            ActorFormat::ActivityJson,
350        );
351    }
352}