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
226            .public_key
227            .public_key_pem
228            .contains("BEGIN PUBLIC KEY"));
229        assert_eq!(
230            actor.endpoints.as_ref().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(
238            "https://pod.example",
239            "bob",
240            "Bob",
241            None,
242            "PEM",
243        );
244        // Several Mastodon/Pleroma releases positionally assume index 0
245        // is activitystreams. Keep this assertion strict.
246        assert_eq!(
247            actor.context[0],
248            serde_json::Value::String("https://www.w3.org/ns/activitystreams".to_string())
249        );
250        assert_eq!(
251            actor.context[1],
252            serde_json::Value::String("https://w3id.org/security/v1".to_string())
253        );
254    }
255
256    #[test]
257    fn actor_base_url_trailing_slash_normalised() {
258        let a = render_actor("https://pod.example/", "x", "X", None, "PEM");
259        let b = render_actor("https://pod.example", "x", "X", None, "PEM");
260        assert_eq!(a.id, b.id);
261        assert_eq!(a.inbox, b.inbox);
262    }
263
264    #[test]
265    fn actor_serialises_with_jsonld_fields() {
266        let actor = render_actor("https://pod.example", "alice", "Alice", None, "PEM");
267        let j = serde_json::to_value(&actor).unwrap();
268        assert!(j.get("@context").is_some());
269        assert_eq!(j["type"], "Person");
270        assert_eq!(j["preferredUsername"], "alice");
271        assert!(j.get("publicKey").is_some());
272    }
273
274    #[test]
275    fn also_known_as_appends() {
276        let actor = render_actor("https://pod.example", "a", "A", None, "PEM");
277        let linked = with_also_known_as(actor, ["did:nostr:abc".to_string()]);
278        assert_eq!(linked.also_known_as, vec!["did:nostr:abc".to_string()]);
279    }
280
281    #[test]
282    fn actor_keypair_generation_rsa2048() {
283        let (priv_pem, pub_pem) = generate_actor_keypair().expect("keypair generates");
284        assert!(priv_pem.starts_with("-----BEGIN PRIVATE KEY-----"));
285        assert!(pub_pem.starts_with("-----BEGIN PUBLIC KEY-----"));
286        // Roundtrip through rsa crate to confirm decodability.
287        use rsa::pkcs8::DecodePrivateKey;
288        use rsa::pkcs8::DecodePublicKey;
289        use rsa::traits::PublicKeyParts;
290        let sk = RsaPrivateKey::from_pkcs8_pem(&priv_pem).unwrap();
291        let pk = RsaPublicKey::from_public_key_pem(&pub_pem).unwrap();
292        assert_eq!(sk.size(), 256); // 2048 bits -> 256 bytes
293        assert_eq!(RsaPublicKey::from(&sk), pk);
294    }
295
296    // --- negotiate_actor_format tests ---
297
298    #[test]
299    fn negotiate_activity_json_media_type() {
300        assert_eq!(
301            negotiate_actor_format("application/activity+json"),
302            ActorFormat::ActivityJson,
303        );
304    }
305
306    #[test]
307    fn negotiate_activity_json_with_charset() {
308        assert_eq!(
309            negotiate_actor_format("application/activity+json; charset=utf-8"),
310            ActorFormat::ActivityJson,
311        );
312    }
313
314    #[test]
315    fn negotiate_ld_json_with_activitystreams_profile() {
316        assert_eq!(
317            negotiate_actor_format(
318                r#"application/ld+json; profile="https://www.w3.org/ns/activitystreams""#
319            ),
320            ActorFormat::ActivityJson,
321        );
322    }
323
324    #[test]
325    fn negotiate_ld_json_without_profile_is_ldp() {
326        assert_eq!(
327            negotiate_actor_format("application/ld+json"),
328            ActorFormat::LdpProfile,
329        );
330    }
331
332    #[test]
333    fn negotiate_html_is_ldp() {
334        assert_eq!(
335            negotiate_actor_format("text/html"),
336            ActorFormat::LdpProfile,
337        );
338    }
339
340    #[test]
341    fn negotiate_empty_is_ldp() {
342        assert_eq!(
343            negotiate_actor_format(""),
344            ActorFormat::LdpProfile,
345        );
346    }
347
348    #[test]
349    fn negotiate_mixed_accept_with_activity_json() {
350        // A browser-like Accept that also lists activity+json.
351        assert_eq!(
352            negotiate_actor_format("text/html, application/activity+json, */*"),
353            ActorFormat::ActivityJson,
354        );
355    }
356
357    #[test]
358    fn negotiate_case_insensitive() {
359        assert_eq!(
360            negotiate_actor_format("Application/Activity+JSON"),
361            ActorFormat::ActivityJson,
362        );
363    }
364}