Skip to main content

paykit_lib/
encrypted_link_recovery.rs

1//! Stateless Encrypted Link Recovery Marker helpers.
2//!
3//! Recovery markers are public Pubky records identifying per-peer Encrypted
4//! Link stream attempts. They are published before handshakes and retained
5//! after completion, independently of the link. Each peer derives a marker
6//! path from its local Noise secret key, the counterparty Noise public key, and
7//! the sorted Pubky identity pair. It writes a minimal marker to its own
8//! homeserver and polls the counterparty's derived marker path.
9//!
10//! Marker payloads intentionally contain only `version`, `kind`, `attempt_id`,
11//! and `created_at`. They do not contain payment data, endpoint data, message
12//! counters, peer labels, or recovery transcripts.
13//!
14//! This module only provides the wire shape and Pubky publish/fetch/remove
15//! helpers. A higher-level runtime or SDK coordinates marker writes, decides
16//! when a new attempt is needed, and relinks after observing a changed marker.
17
18use 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/// Minimal public marker identifying a peer's current Encrypted Link attempt.
34#[derive(Clone, Debug, PartialEq, Eq)]
35pub struct EncryptedLinkRecoveryMarker {
36    attempt_id: String,
37    created_at: String,
38}
39
40impl EncryptedLinkRecoveryMarker {
41    /// Create a marker from a UUID-v4 attempt id and RFC3339 UTC timestamp.
42    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    /// Generate a marker with a fresh UUID-v4 attempt id.
53    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    /// Stable recovery attempt id.
58    pub fn attempt_id(&self) -> &str {
59        &self.attempt_id
60    }
61
62    /// Marker creation time as an RFC3339 UTC timestamp.
63    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            // SECURITY / REDACTION: static context only; see
94            // `parse_encrypted_link_recovery_marker_json`.
95            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
105/// Serialize an Encrypted Link Recovery Marker to canonical JSON.
106pub 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
116/// Parse an Encrypted Link Recovery Marker JSON payload.
117pub 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    // SECURITY / REDACTION: the marker is peer-published public data that
127    // reaches `LinkObservation` contexts and FFI exception text. Every error on
128    // this path, including `TryFrom`, uses a static context; serde causes quote
129    // the JSON and are dropped.
130    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
135/// Compute local write and remote read paths for recovery markers.
136///
137/// Both Pubky identities bind the paths independently of the advertised Noise
138/// keys. The Noise DH secret keeps the paths private, and canonical identity
139/// ordering makes each peer's write path match the other's read path.
140pub 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
159/// Publish a local recovery marker and return the path written.
160///
161/// The caller must provide a Pubky session with write access to the derived
162/// marker path and the same local Noise key used for the peer relationship. The
163/// caller owns session creation, capability scope, key rotation, and recovery
164/// policy. Request timeouts are configured on the Pubky client.
165/// The session supplies the local Pubky identity; `remote_identity_public_key`
166/// selects the counterparty independently of its advertised Noise key.
167///
168/// # Errors
169///
170/// Returns [`PaykitError::Validation`] when the marker cannot be serialized and
171/// [`PaykitError::Transport`] when Pubky storage rejects the write.
172pub 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
197/// Remove the local recovery marker for a counterparty.
198///
199/// Missing markers are treated as success. The caller must provide a Pubky
200/// session with write access to the derived marker path and owns session
201/// creation, capability scope, key rotation, recovery policy, and Pubky-client
202/// timeout configuration.
203/// The session supplies the local Pubky identity; the remote identity and Noise
204/// key must belong to the intended counterparty.
205///
206/// # Errors
207///
208/// Returns [`PaykitError::Transport`] when Pubky storage fails for a reason
209/// other than the marker already being absent.
210pub 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
232/// Fetch a counterparty's recovery marker, if one is present.
233///
234/// This unauthenticated read returns `Ok(None)` when the derived marker resource
235/// is missing or empty. The caller supplies both Pubky identities and Noise keys
236/// and owns key rotation, recovery policy, and Pubky-client timeout configuration.
237///
238/// # Errors
239///
240/// Returns [`PaykitError::InvalidData`] for malformed remote marker data and
241/// [`PaykitError::Transport`] when Pubky storage cannot be read.
242pub 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}