Skip to main content

max / makenotwork

10.6 KB · 317 lines History Blame Raw
1 //! HTTP client for the Multithreaded internal API.
2 //!
3 //! Signs requests with HMAC-SHA256 and communicates with MT's `/internal/*` endpoints.
4
5 use hmac::{Hmac, KeyInit, Mac};
6 use serde::{Deserialize, Serialize};
7 use sha2::Sha256;
8 use uuid::Uuid;
9
10 use crate::db::MtThreadId;
11
12 /// Errors from the MT internal API client.
13 #[derive(Debug, thiserror::Error)]
14 pub enum MtClientError {
15 #[error("MT unreachable: {0}")]
16 Unreachable(reqwest::Error),
17 #[error("MT returned error status {status}: {body}")]
18 BadResponse { status: u16, body: String },
19 #[error("failed to deserialize MT response: {0}")]
20 Deserialize(reqwest::Error),
21 }
22
23 /// HTTP client for MT's internal API with HMAC-SHA256 request signing.
24 #[derive(Clone)]
25 pub struct MtClient {
26 http: reqwest::Client,
27 base_url: String,
28 secret: String,
29 }
30
31 // Request/response types (must match MT's internal API)
32
33 #[derive(Serialize)]
34 pub struct CreateCommunityRequest {
35 pub name: String,
36 pub slug: String,
37 pub description: Option<String>,
38 pub owner_mnw_id: Uuid,
39 pub owner_username: String,
40 pub owner_display_name: Option<String>,
41 }
42
43 #[derive(Deserialize)]
44 pub struct CreateCommunityResponse {
45 pub community_id: Uuid,
46 pub created: bool,
47 }
48
49 #[derive(Serialize)]
50 pub struct CreateThreadRequest {
51 pub community_slug: String,
52 pub category_slug: String,
53 pub title: String,
54 pub body_markdown: String,
55 pub author_mnw_id: Uuid,
56 pub author_username: String,
57 pub author_display_name: Option<String>,
58 pub external_ref: String,
59 }
60
61 #[derive(Deserialize)]
62 pub struct CreateThreadResponse {
63 pub thread_id: MtThreadId,
64 pub post_id: Uuid,
65 pub created: bool,
66 }
67
68 #[derive(Serialize)]
69 pub struct CreatePostRequest {
70 pub body_markdown: String,
71 pub author_mnw_id: Uuid,
72 pub author_username: String,
73 pub author_display_name: Option<String>,
74 /// Idempotency key, MT dedups a retried/replayed reply on this. Use a
75 /// stable per-message value (e.g. `mnw:post:<email-message-id>`).
76 pub external_ref: String,
77 }
78
79 #[derive(Deserialize)]
80 pub struct CreatePostResponse {
81 pub post_id: Uuid,
82 /// False when MT returned an existing reply (the ref was already seen).
83 #[serde(default)]
84 pub created: bool,
85 }
86
87 #[derive(Deserialize)]
88 pub struct ThreadStatsResponse {
89 pub post_count: i64,
90 pub last_activity_at: Option<chrono::DateTime<chrono::Utc>>,
91 }
92
93 impl MtClient {
94 /// Create a new MT client with the given base URL and shared secret.
95 pub fn new(base_url: String, secret: String) -> Self {
96 // `build()` constructs the rustls connector, which reads the
97 // process-wide provider and panics if none is installed. Idempotent.
98 crate::crypto::install_default_crypto_provider();
99
100 let http = reqwest::Client::builder()
101 .timeout(std::time::Duration::from_secs(5))
102 .connect_timeout(std::time::Duration::from_secs(3))
103 .build()
104 .expect("failed to build MT HTTP client");
105
106 Self {
107 http,
108 base_url,
109 secret,
110 }
111 }
112
113 /// Sign a request, binding method + path + a fresh nonce in addition to the
114 /// timestamp and body. Returns (timestamp, nonce, hex signature). The
115 /// canonical message, `timestamp \n METHOD \n PATH \n NONCE \n body`, must
116 /// match MT's `compute_internal_signature_v2` byte-for-byte. `path` is the
117 /// request path only (no scheme/host, no query string).
118 fn sign_request(&self, method: &str, path: &str, body: &str) -> (String, String, String) {
119 let timestamp = chrono::Utc::now().timestamp().to_string();
120 let nonce = Uuid::new_v4().simple().to_string();
121
122 let mut mac = Hmac::<Sha256>::new_from_slice(self.secret.as_bytes())
123 .expect("HMAC-SHA256 accepts any key length");
124 mac.update(timestamp.as_bytes());
125 mac.update(b"\n");
126 mac.update(method.as_bytes());
127 mac.update(b"\n");
128 mac.update(path.as_bytes());
129 mac.update(b"\n");
130 mac.update(nonce.as_bytes());
131 mac.update(b"\n");
132 mac.update(body.as_bytes());
133 let signature = hex::encode(mac.finalize().into_bytes());
134
135 (timestamp, nonce, signature)
136 }
137
138 /// Send a signed POST request and deserialize the response.
139 async fn signed_post<Req: Serialize, Resp: for<'de> Deserialize<'de>>(
140 &self,
141 path: &str,
142 req: &Req,
143 ) -> Result<Resp, MtClientError> {
144 let body = serde_json::to_string(req).expect("request serialization cannot fail");
145 let (timestamp, nonce, signature) = self.sign_request("POST", path, &body);
146
147 let resp = self
148 .http
149 .post(format!("{}{}", self.base_url, path))
150 .header("Content-Type", "application/json")
151 .header("X-Internal-Timestamp", &timestamp)
152 .header("X-Internal-Signature", &signature)
153 .header("X-Internal-Nonce", &nonce)
154 .body(body)
155 .send()
156 .await
157 .map_err(MtClientError::Unreachable)?;
158
159 let status = resp.status();
160 if !status.is_success() {
161 let body = resp.text().await.unwrap_or_default();
162 return Err(MtClientError::BadResponse {
163 status: status.as_u16(),
164 body,
165 });
166 }
167
168 resp.json().await.map_err(MtClientError::Deserialize)
169 }
170
171 /// Create or retrieve an existing community on MT.
172 #[tracing::instrument(skip_all)]
173 pub async fn create_community(
174 &self,
175 req: &CreateCommunityRequest,
176 ) -> Result<CreateCommunityResponse, MtClientError> {
177 self.signed_post("/internal/communities", req).await
178 }
179
180 /// Create a discussion thread on MT linked to MNW content.
181 #[tracing::instrument(skip_all)]
182 pub async fn create_thread(
183 &self,
184 req: &CreateThreadRequest,
185 ) -> Result<CreateThreadResponse, MtClientError> {
186 self.signed_post("/internal/threads", req).await
187 }
188
189 /// Add a reply to an existing thread on MT.
190 #[tracing::instrument(skip_all, fields(thread_id = %thread_id))]
191 pub async fn create_post(
192 &self,
193 thread_id: MtThreadId,
194 req: &CreatePostRequest,
195 ) -> Result<CreatePostResponse, MtClientError> {
196 self.signed_post(&format!("/internal/threads/{thread_id}/posts"), req)
197 .await
198 }
199
200 /// Get thread stats (post count + last activity).
201 #[tracing::instrument(skip_all, fields(thread_id = %thread_id))]
202 pub async fn get_thread_stats(
203 &self,
204 thread_id: MtThreadId,
205 ) -> Result<ThreadStatsResponse, MtClientError> {
206 let path = format!("/internal/threads/{thread_id}/stats");
207 let (timestamp, nonce, signature) = self.sign_request("GET", &path, "");
208 let resp = self
209 .http
210 .get(format!("{}{}", self.base_url, path))
211 .header("X-Internal-Timestamp", &timestamp)
212 .header("X-Internal-Signature", &signature)
213 .header("X-Internal-Nonce", &nonce)
214 .send()
215 .await
216 .map_err(MtClientError::Unreachable)?;
217
218 let status = resp.status();
219 if !status.is_success() {
220 let body = resp.text().await.unwrap_or_default();
221 return Err(MtClientError::BadResponse {
222 status: status.as_u16(),
223 body,
224 });
225 }
226
227 resp.json().await.map_err(MtClientError::Deserialize)
228 }
229 }
230
231 #[cfg(test)]
232 mod tests {
233 use super::*;
234
235 #[test]
236 fn sign_request_produces_valid_signature_and_fresh_nonce() {
237 let client = MtClient::new("http://localhost".to_string(), "test-secret".to_string());
238 let body = r#"{"name":"test"}"#;
239 let (ts1, nonce1, sig1) = client.sign_request("POST", "/internal/communities", body);
240 let (ts2, nonce2, sig2) = client.sign_request("POST", "/internal/communities", body);
241
242 let t1: i64 = ts1.parse().unwrap();
243 let t2: i64 = ts2.parse().unwrap();
244 assert!((t1 - t2).abs() <= 1);
245
246 assert_eq!(sig1.len(), 64, "SHA-256 hex is 64 chars");
247 assert!(sig1.chars().all(|c| c.is_ascii_hexdigit()));
248
249 // Each request carries a fresh nonce, so even identical method/path/body
250 // produce a distinct signature, single-use by construction.
251 assert_ne!(nonce1, nonce2, "nonce must be fresh per request");
252 assert_ne!(sig1, sig2, "fresh nonce must change the signature");
253 }
254
255 /// Recompute the canonical v2 message inline to pin that method, path, and
256 /// nonce are all bound (a mutation dropping any field would collide).
257 #[test]
258 fn signed_message_binds_method_path_nonce() {
259 use hmac::{Hmac, KeyInit, Mac};
260 use sha2::Sha256;
261
262 fn sig(
263 secret: &str,
264 ts: &str,
265 method: &str,
266 path: &str,
267 nonce: &str,
268 body: &str,
269 ) -> String {
270 let mut mac = Hmac::<Sha256>::new_from_slice(secret.as_bytes()).unwrap();
271 for field in [ts, method, path, nonce] {
272 mac.update(field.as_bytes());
273 mac.update(b"\n");
274 }
275 mac.update(body.as_bytes());
276 hex::encode(mac.finalize().into_bytes())
277 }
278
279 // The verifier is multithreaded's compute_internal_signature_v2, so the
280 // exact bytes are a cross-repo contract that neither repo can catch by
281 // agreeing with itself. Both ends pin this same independently computed
282 // HMAC-SHA256: key "secret", message "100\nPOST\n/x\nn\nbody".
283 assert_eq!(
284 sig("secret", "100", "POST", "/x", "n", "body"),
285 "0e97f90cb4e4ca7aaa5499e67a22fb5b7ad45ad3cc966f37d225f39da2728098"
286 );
287
288 // Tie the production signer to that layout, so the pinned vector above
289 // constrains sign_request and not just this local reimplementation.
290 let client = MtClient::new("http://localhost".to_string(), "s".to_string());
291 let (ts, nonce, produced) = client.sign_request("POST", "/a", "body");
292 assert_eq!(produced, sig("s", &ts, "POST", "/a", &nonce, "body"));
293
294 let base = sig("s", "100", "POST", "/a", "n1", "body");
295 assert_ne!(
296 base,
297 sig("s", "100", "GET", "/a", "n1", "body"),
298 "method bound"
299 );
300 assert_ne!(
301 base,
302 sig("s", "100", "POST", "/b", "n1", "body"),
303 "path bound"
304 );
305 assert_ne!(
306 base,
307 sig("s", "100", "POST", "/a", "n2", "body"),
308 "nonce bound"
309 );
310 assert_ne!(
311 base,
312 sig("s", "100", "POST", "/a", "n1", "body2"),
313 "body bound"
314 );
315 }
316 }
317