1use pubky::{
19 errors::RequestError, Error as PubkyError, PubkySession, PublicKey, PublicStorage, StatusCode,
20};
21use serde::{Deserialize, Serialize};
22
23use crate::{
24 pubky_routing::{fetch_text, identity_pair_path_domain},
25 validation::{invalid_data, invalid_wire, parse_utc_timestamp, validate_uuid_v4},
26 PaykitError, Result, PAYKIT_ENCRYPTED_LINK_RECOVERY_PATH_PREFIX,
27};
28
29const RECOVERY_MARKER_KIND: &str = "paykit.encrypted_link_recovery";
30const RECOVERY_MARKER_PATH_DOMAIN: &[u8] = b"paykit-link-recovery-v0";
31const RECOVERY_MARKER_MAX_BYTES: usize = 4 * 1024;
32
33#[derive(Clone, Debug, PartialEq, Eq)]
35pub struct EncryptedLinkRecoveryMarker {
36 attempt_id: String,
37 created_at: String,
38}
39
40impl EncryptedLinkRecoveryMarker {
41 pub fn new(attempt_id: impl Into<String>, created_at: impl Into<String>) -> Result<Self> {
43 let attempt_id = validate_uuid_v4(attempt_id.into(), "Encrypted Link recovery attempt ID")?;
44 let created_at = created_at.into();
45 parse_utc_timestamp(&created_at, "Encrypted Link recovery marker created_at")?;
46 Ok(Self {
47 attempt_id,
48 created_at,
49 })
50 }
51
52 pub fn new_v4(created_at: impl Into<String>) -> Result<Self> {
54 Self::new(uuid::Uuid::new_v4().to_string(), created_at)
55 }
56
57 pub fn attempt_id(&self) -> &str {
59 &self.attempt_id
60 }
61
62 pub fn created_at(&self) -> &str {
64 &self.created_at
65 }
66}
67
68#[derive(Serialize, Deserialize)]
69#[serde(deny_unknown_fields)]
70struct RecoveryMarkerWire {
71 version: u8,
72 kind: String,
73 attempt_id: String,
74 created_at: String,
75}
76
77impl From<&EncryptedLinkRecoveryMarker> for RecoveryMarkerWire {
78 fn from(marker: &EncryptedLinkRecoveryMarker) -> Self {
79 Self {
80 version: 1,
81 kind: RECOVERY_MARKER_KIND.into(),
82 attempt_id: marker.attempt_id.clone(),
83 created_at: marker.created_at.clone(),
84 }
85 }
86}
87
88impl TryFrom<RecoveryMarkerWire> for EncryptedLinkRecoveryMarker {
89 type Error = PaykitError;
90
91 fn try_from(wire: RecoveryMarkerWire) -> Result<Self> {
92 if wire.version != 1 || wire.kind != RECOVERY_MARKER_KIND {
93 return Err(invalid_data(
96 "unsupported Encrypted Link recovery marker version/kind",
97 None,
98 ));
99 }
100 Self::new(wire.attempt_id, wire.created_at)
101 .map_err(|err| invalid_wire(err, "Encrypted Link recovery marker"))
102 }
103}
104
105pub fn serialize_encrypted_link_recovery_marker(
107 marker: &EncryptedLinkRecoveryMarker,
108) -> Result<String> {
109 serde_json::to_string(&RecoveryMarkerWire::from(marker)).map_err(|err| {
110 PaykitError::Validation(format!(
111 "failed to serialize Encrypted Link recovery marker: {err}"
112 ))
113 })
114}
115
116pub fn parse_encrypted_link_recovery_marker_json(
118 raw_json: &str,
119) -> Result<EncryptedLinkRecoveryMarker> {
120 if raw_json.len() > RECOVERY_MARKER_MAX_BYTES {
121 return Err(invalid_data(
122 format!("Encrypted Link recovery marker exceeds {RECOVERY_MARKER_MAX_BYTES} bytes"),
123 None,
124 ));
125 }
126 let wire = serde_json::from_str::<RecoveryMarkerWire>(raw_json)
131 .map_err(|_| invalid_data("Encrypted Link recovery marker JSON is invalid", None))?;
132 wire.try_into()
133}
134
135pub fn encrypted_link_recovery_marker_paths(
141 local_secret_key: &[u8; 32],
142 local_identity_public_key: &PublicKey,
143 remote_identity_public_key: &PublicKey,
144 remote_noise_public_key: &PublicKey,
145) -> (String, String) {
146 let path_domain = identity_pair_path_domain(
147 RECOVERY_MARKER_PATH_DOMAIN,
148 local_identity_public_key,
149 remote_identity_public_key,
150 );
151 pubky_noise::path_derivation::derive_asymmetric_paths(
152 local_secret_key,
153 remote_noise_public_key,
154 &path_domain,
155 PAYKIT_ENCRYPTED_LINK_RECOVERY_PATH_PREFIX,
156 )
157}
158
159pub async fn publish_encrypted_link_recovery_marker(
173 session: &PubkySession,
174 local_secret_key: &[u8; 32],
175 remote_identity_public_key: &PublicKey,
176 remote_noise_public_key: &PublicKey,
177 marker: &EncryptedLinkRecoveryMarker,
178) -> Result<String> {
179 let (write_path, _) = encrypted_link_recovery_marker_paths(
180 local_secret_key,
181 session.info().public_key(),
182 remote_identity_public_key,
183 remote_noise_public_key,
184 );
185 let payload = serialize_encrypted_link_recovery_marker(marker)?;
186 session
187 .storage()
188 .put(write_path.clone(), payload)
189 .await
190 .map_err(|err| PaykitError::Transport {
191 context: "publish Encrypted Link recovery marker".into(),
192 source: err.into(),
193 })?;
194 Ok(write_path)
195}
196
197pub async fn remove_encrypted_link_recovery_marker(
211 session: &PubkySession,
212 local_secret_key: &[u8; 32],
213 remote_identity_public_key: &PublicKey,
214 remote_noise_public_key: &PublicKey,
215) -> Result<String> {
216 let (write_path, _) = encrypted_link_recovery_marker_paths(
217 local_secret_key,
218 session.info().public_key(),
219 remote_identity_public_key,
220 remote_noise_public_key,
221 );
222 match session.storage().delete(write_path.clone()).await {
223 Ok(_) => Ok(write_path),
224 Err(err) if is_not_found(&err) => Ok(write_path),
225 Err(err) => Err(PaykitError::Transport {
226 context: "remove Encrypted Link recovery marker".into(),
227 source: err.into(),
228 }),
229 }
230}
231
232pub async fn fetch_encrypted_link_recovery_marker(
243 storage: &PublicStorage,
244 local_secret_key: &[u8; 32],
245 local_identity_public_key: &PublicKey,
246 remote_identity_public_key: &PublicKey,
247 remote_noise_public_key: &PublicKey,
248) -> Result<Option<EncryptedLinkRecoveryMarker>> {
249 let (_, read_path) = encrypted_link_recovery_marker_paths(
250 local_secret_key,
251 local_identity_public_key,
252 remote_identity_public_key,
253 remote_noise_public_key,
254 );
255 let addr = format!("{remote_identity_public_key}{read_path}");
256 fetch_text(
257 storage,
258 addr,
259 "fetch Encrypted Link recovery marker",
260 Some(RECOVERY_MARKER_MAX_BYTES),
261 )
262 .await?
263 .map(|raw_json| parse_encrypted_link_recovery_marker_json(&raw_json))
264 .transpose()
265}
266
267fn is_not_found(err: &PubkyError) -> bool {
268 matches!(
269 err,
270 PubkyError::Request(RequestError::Server { status, .. })
271 if *status == StatusCode::NOT_FOUND || *status == StatusCode::GONE
272 )
273}
274
275#[cfg(test)]
276mod tests {
277 use pubky::Keypair;
278 use serde_json::json;
279
280 use super::*;
281
282 fn secret_pair() -> ([u8; 32], PublicKey) {
283 let keypair = Keypair::random();
284 (keypair.secret_key(), keypair.public_key())
285 }
286
287 #[test]
288 fn test_recovery_marker_json_round_trips() {
289 let marker = EncryptedLinkRecoveryMarker::new(
290 "650e8400-e29b-41d4-a716-446655440000",
291 "2026-06-03T12:00:00Z",
292 )
293 .unwrap();
294
295 let raw = serialize_encrypted_link_recovery_marker(&marker).unwrap();
296 let parsed = parse_encrypted_link_recovery_marker_json(&raw).unwrap();
297
298 assert_eq!(parsed, marker);
299 assert!(raw.contains("\"kind\":\"paykit.encrypted_link_recovery\""));
300 }
301
302 #[test]
303 fn test_recovery_marker_rejects_extra_fields() {
304 let raw = r#"{"version":1,"kind":"paykit.encrypted_link_recovery","attempt_id":"650e8400-e29b-41d4-a716-446655440000","created_at":"2026-06-03T12:00:00Z","peer":"extra"}"#;
305
306 let result = parse_encrypted_link_recovery_marker_json(raw);
307
308 assert!(matches!(result, Err(PaykitError::InvalidData { .. })));
309 }
310
311 #[test]
312 fn test_recovery_marker_parse_errors_redact_remote_payload() {
313 let sentinel = "SENTINEL_REMOTE_MARKER";
314 let payload = format!("{sentinel}\n\u{1b}");
315 let marker = EncryptedLinkRecoveryMarker::new(
316 "650e8400-e29b-41d4-a716-446655440000",
317 "2026-06-03T12:00:00Z",
318 )
319 .unwrap();
320 let with_field = |field: &str, value: serde_json::Value| {
321 let mut document = serde_json::to_value(RecoveryMarkerWire::from(&marker)).unwrap();
322 document[field] = value;
323 document.to_string()
324 };
325 let cases = [
326 ("version/kind", with_field("kind", json!(payload))),
327 ("JSON is invalid", with_field("version", json!(payload))),
328 ("JSON is invalid", with_field(&payload, json!(1))),
329 ("attempt ID", with_field("attempt_id", json!(payload))),
330 ("created_at", with_field("created_at", json!(payload))),
331 ];
332
333 for (expected_context, raw) in cases {
334 let error = parse_encrypted_link_recovery_marker_json(&raw).unwrap_err();
335 let message = error.to_string();
336 assert!(message.contains(expected_context), "{message:?}");
337 assert!(!message.contains(sentinel), "{message:?}");
338 assert!(!message.chars().any(char::is_control), "{message:?}");
339 assert!(!format!("{error:?}").contains(sentinel), "{error:?}");
340 assert!(
341 matches!(error, PaykitError::InvalidData { source: None, .. }),
342 "{error:?}"
343 );
344 }
345 }
346
347 #[test]
348 fn test_recovery_marker_rejects_oversized_payload() {
349 let raw = " ".repeat(RECOVERY_MARKER_MAX_BYTES + 1);
350
351 let result = parse_encrypted_link_recovery_marker_json(&raw);
352
353 assert!(matches!(result, Err(PaykitError::InvalidData { .. })));
354 }
355
356 #[test]
357 fn test_recovery_marker_paths_are_pairwise_symmetric() {
358 let (alice_secret, alice_public) = secret_pair();
359 let (bob_secret, bob_public) = secret_pair();
360 let alice_identity = Keypair::from_secret(&[1; 32]).public_key();
361 let bob_identity = Keypair::from_secret(&[2; 32]).public_key();
362
363 let (alice_write, alice_read) = encrypted_link_recovery_marker_paths(
364 &alice_secret,
365 &alice_identity,
366 &bob_identity,
367 &bob_public,
368 );
369 let (bob_write, bob_read) = encrypted_link_recovery_marker_paths(
370 &bob_secret,
371 &bob_identity,
372 &alice_identity,
373 &alice_public,
374 );
375
376 assert_eq!(alice_write, bob_read);
377 assert_eq!(alice_read, bob_write);
378 assert!(alice_write.starts_with(PAYKIT_ENCRYPTED_LINK_RECOVERY_PATH_PREFIX));
379 assert_ne!(alice_write, alice_read);
380 }
381
382 #[test]
383 fn test_recovery_marker_paths_bind_both_identities_with_shared_noise_keys() {
384 let alice = Keypair::from_secret(&[1; 32]).public_key();
385 let bob = Keypair::from_secret(&[2; 32]).public_key();
386 let other = Keypair::from_secret(&[3; 32]).public_key();
387 let (secret, _) = secret_pair();
388 let (_, remote_noise) = secret_pair();
389 let original = encrypted_link_recovery_marker_paths(&secret, &alice, &bob, &remote_noise);
390
391 for (local, remote) in [(&alice, &other), (&other, &bob)] {
392 let changed =
393 encrypted_link_recovery_marker_paths(&secret, local, remote, &remote_noise);
394 assert_ne!(original.0, changed.0);
395 assert_ne!(original.1, changed.1);
396 }
397 }
398}