Skip to main content

actpub_activitystreams/
actor.rs

1//! `ActivityPub` actor extension types.
2//!
3//! `ActivityPub` augments AS 2.0 actor objects (`Person`, `Group`,
4//! `Service`, `Application`, `Organization`) with cryptographic and
5//! transport metadata that the bare AS 2.0 vocabulary does not define:
6//!
7//! - [`PublicKey`] — the W3C Security v1 `publicKey` block embedded on
8//!   every Mastodon-style actor, used by HTTP Signature verification
9//!   to look up the signing key by `keyId`.
10//! - [`Endpoints`] — the `ActivityPub` §4.1 `endpoints` block listing the
11//!   actor's shared inbox and (optionally) Linked Data Signatures or
12//!   client-to-server OAuth endpoints.
13//!
14//! These are modelled as small focused structs rather than free-form
15//! JSON because every Fediverse implementation reads and writes the
16//! same fields.
17
18use serde::{Deserialize, Serialize};
19use url::Url;
20
21/// W3C Security v1 `publicKey` block.
22///
23/// Mastodon, Pleroma, Misskey, Lemmy and every other Cavage-era
24/// implementation expose actors with this exact shape:
25///
26/// ```json
27/// "publicKey": {
28///   "id":          "https://example.com/users/alice#main-key",
29///   "owner":       "https://example.com/users/alice",
30///   "publicKeyPem": "-----BEGIN PUBLIC KEY-----\nMIIB…"
31/// }
32/// ```
33///
34/// The PEM payload is an X.509 `SubjectPublicKeyInfo` (PKIX) document and
35/// is the canonical way to publish an actor's RSA-2048 (or, more
36/// recently, Ed25519) verification key for HTTP Signatures. Modern FEP
37/// implementations additionally publish [`Multikey`](crate::Multikey)
38/// entries via `assertionMethod`, but `publicKey` remains the
39/// must-have legacy field.
40#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
41#[serde(rename_all = "camelCase")]
42pub struct PublicKey {
43    /// Globally unique identifier for this key, typically the actor URL
44    /// suffixed with a `#fragment` (e.g. `#main-key`).
45    pub id: Url,
46
47    /// The actor that owns and rotates this key. MUST equal the actor's
48    /// `id` for the receiver to accept signatures made with it.
49    pub owner: Url,
50
51    /// PKIX `SubjectPublicKeyInfo` PEM, including the
52    /// `-----BEGIN PUBLIC KEY-----` armour.
53    pub public_key_pem: String,
54}
55
56impl PublicKey {
57    /// Builds a [`PublicKey`] from its three fields.
58    #[must_use]
59    pub fn new(id: Url, owner: Url, public_key_pem: impl Into<String>) -> Self {
60        Self {
61            id,
62            owner,
63            public_key_pem: public_key_pem.into(),
64        }
65    }
66}
67
68/// `ActivityPub` §4.1 `endpoints` block.
69///
70/// Servers publish auxiliary URLs through this object. The most widely
71/// used field by far is [`shared_inbox`](Self::shared_inbox), which
72/// lets remote senders deliver one POST per server instead of one POST
73/// per follower. The OAuth fields support C2S clients (rare in
74/// production today). The `proxyUrl` and `provideClientKey` /
75/// `signClientKey` fields are reserved for Linked Data Signatures and
76/// remain in the spec for forward-compatibility.
77#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
78#[serde(rename_all = "camelCase")]
79pub struct Endpoints {
80    /// Server-wide inbox into which any actor on this server can be
81    /// addressed. Receiving servers MAY deliver a single POST here in
82    /// place of N per-follower deliveries.
83    #[serde(skip_serializing_if = "Option::is_none")]
84    pub shared_inbox: Option<Url>,
85
86    /// Endpoint where a remote client can obtain an OAuth 2.0
87    /// authorization code on behalf of an actor on this server.
88    #[serde(skip_serializing_if = "Option::is_none")]
89    pub oauth_authorization_endpoint: Option<Url>,
90
91    /// Endpoint where a remote client can exchange an OAuth 2.0
92    /// authorization code for a bearer token.
93    #[serde(skip_serializing_if = "Option::is_none")]
94    pub oauth_token_endpoint: Option<Url>,
95
96    /// LD-Signatures: endpoint that supplies a fresh client key for
97    /// HTTP-signature exchange.
98    #[serde(skip_serializing_if = "Option::is_none")]
99    pub provide_client_key: Option<Url>,
100
101    /// LD-Signatures: endpoint that signs an arbitrary client-supplied
102    /// key on behalf of this actor.
103    #[serde(skip_serializing_if = "Option::is_none")]
104    pub sign_client_key: Option<Url>,
105
106    /// Generic proxy endpoint for fetching authenticated remote
107    /// resources, defined for forward-compatibility with
108    /// `ActivityPub` §7.1.2 client-to-server semantics.
109    #[serde(skip_serializing_if = "Option::is_none")]
110    pub proxy_url: Option<Url>,
111}
112
113#[cfg(test)]
114mod tests {
115    use pretty_assertions::assert_eq;
116    use serde_json::json;
117
118    use super::*;
119
120    #[test]
121    fn public_key_roundtrips_in_mastodon_shape() {
122        let raw = json!({
123            "id": "https://mastodon.social/users/alice#main-key",
124            "owner": "https://mastodon.social/users/alice",
125            "publicKeyPem": "-----BEGIN PUBLIC KEY-----\nMIIB…\n-----END PUBLIC KEY-----\n"
126        });
127        let key: PublicKey = serde_json::from_value(raw.clone()).unwrap();
128        assert_eq!(key.owner.as_str(), "https://mastodon.social/users/alice");
129        let back = serde_json::to_value(&key).unwrap();
130        assert_eq!(back, raw);
131    }
132
133    #[test]
134    fn endpoints_with_only_shared_inbox_omits_other_fields() {
135        let endpoints = Endpoints {
136            shared_inbox: Some(Url::parse("https://mastodon.social/inbox").unwrap()),
137            ..Endpoints::default()
138        };
139        let v = serde_json::to_value(&endpoints).unwrap();
140        assert_eq!(v, json!({ "sharedInbox": "https://mastodon.social/inbox" }));
141    }
142
143    #[test]
144    fn endpoints_full_roundtrip() {
145        let raw = json!({
146            "sharedInbox": "https://example.com/inbox",
147            "oauthAuthorizationEndpoint": "https://example.com/oauth/authorize",
148            "oauthTokenEndpoint": "https://example.com/oauth/token"
149        });
150        let endpoints: Endpoints = serde_json::from_value(raw.clone()).unwrap();
151        assert!(endpoints.shared_inbox.is_some());
152        assert!(endpoints.oauth_authorization_endpoint.is_some());
153        let back = serde_json::to_value(&endpoints).unwrap();
154        assert_eq!(back, raw);
155    }
156}