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}