paykit-lib 0.1.0

Paykit library implementation
Documentation
//! Stateless Encrypted Link Recovery Marker helpers.
//!
//! Recovery markers are public Pubky records identifying per-peer Encrypted
//! Link stream attempts. They are published before handshakes and retained
//! after completion, independently of the link. Each peer derives a marker
//! path from its local Noise secret key, the counterparty Noise public key, and
//! the sorted Pubky identity pair. It writes a minimal marker to its own
//! homeserver and polls the counterparty's derived marker path.
//!
//! Marker payloads intentionally contain only `version`, `kind`, `attempt_id`,
//! and `created_at`. They do not contain payment data, endpoint data, message
//! counters, peer labels, or recovery transcripts.
//!
//! This module only provides the wire shape and Pubky publish/fetch/remove
//! helpers. A higher-level runtime or SDK coordinates marker writes, decides
//! when a new attempt is needed, and relinks after observing a changed marker.

use pubky::{
    errors::RequestError, Error as PubkyError, PubkySession, PublicKey, PublicStorage, StatusCode,
};
use serde::{Deserialize, Serialize};

use crate::{
    pubky_routing::{fetch_text, identity_pair_path_domain},
    validation::{invalid_data, invalid_wire, parse_utc_timestamp, validate_uuid_v4},
    PaykitError, Result, PAYKIT_ENCRYPTED_LINK_RECOVERY_PATH_PREFIX,
};

const RECOVERY_MARKER_KIND: &str = "paykit.encrypted_link_recovery";
const RECOVERY_MARKER_PATH_DOMAIN: &[u8] = b"paykit-link-recovery-v0";
const RECOVERY_MARKER_MAX_BYTES: usize = 4 * 1024;

/// Minimal public marker identifying a peer's current Encrypted Link attempt.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct EncryptedLinkRecoveryMarker {
    attempt_id: String,
    created_at: String,
}

impl EncryptedLinkRecoveryMarker {
    /// Create a marker from a UUID-v4 attempt id and RFC3339 UTC timestamp.
    pub fn new(attempt_id: impl Into<String>, created_at: impl Into<String>) -> Result<Self> {
        let attempt_id = validate_uuid_v4(attempt_id.into(), "Encrypted Link recovery attempt ID")?;
        let created_at = created_at.into();
        parse_utc_timestamp(&created_at, "Encrypted Link recovery marker created_at")?;
        Ok(Self {
            attempt_id,
            created_at,
        })
    }

    /// Generate a marker with a fresh UUID-v4 attempt id.
    pub fn new_v4(created_at: impl Into<String>) -> Result<Self> {
        Self::new(uuid::Uuid::new_v4().to_string(), created_at)
    }

    /// Stable recovery attempt id.
    pub fn attempt_id(&self) -> &str {
        &self.attempt_id
    }

    /// Marker creation time as an RFC3339 UTC timestamp.
    pub fn created_at(&self) -> &str {
        &self.created_at
    }
}

#[derive(Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
struct RecoveryMarkerWire {
    version: u8,
    kind: String,
    attempt_id: String,
    created_at: String,
}

impl From<&EncryptedLinkRecoveryMarker> for RecoveryMarkerWire {
    fn from(marker: &EncryptedLinkRecoveryMarker) -> Self {
        Self {
            version: 1,
            kind: RECOVERY_MARKER_KIND.into(),
            attempt_id: marker.attempt_id.clone(),
            created_at: marker.created_at.clone(),
        }
    }
}

impl TryFrom<RecoveryMarkerWire> for EncryptedLinkRecoveryMarker {
    type Error = PaykitError;

    fn try_from(wire: RecoveryMarkerWire) -> Result<Self> {
        if wire.version != 1 || wire.kind != RECOVERY_MARKER_KIND {
            // SECURITY / REDACTION: static context only; see
            // `parse_encrypted_link_recovery_marker_json`.
            return Err(invalid_data(
                "unsupported Encrypted Link recovery marker version/kind",
                None,
            ));
        }
        Self::new(wire.attempt_id, wire.created_at)
            .map_err(|err| invalid_wire(err, "Encrypted Link recovery marker"))
    }
}

/// Serialize an Encrypted Link Recovery Marker to canonical JSON.
pub fn serialize_encrypted_link_recovery_marker(
    marker: &EncryptedLinkRecoveryMarker,
) -> Result<String> {
    serde_json::to_string(&RecoveryMarkerWire::from(marker)).map_err(|err| {
        PaykitError::Validation(format!(
            "failed to serialize Encrypted Link recovery marker: {err}"
        ))
    })
}

/// Parse an Encrypted Link Recovery Marker JSON payload.
pub fn parse_encrypted_link_recovery_marker_json(
    raw_json: &str,
) -> Result<EncryptedLinkRecoveryMarker> {
    if raw_json.len() > RECOVERY_MARKER_MAX_BYTES {
        return Err(invalid_data(
            format!("Encrypted Link recovery marker exceeds {RECOVERY_MARKER_MAX_BYTES} bytes"),
            None,
        ));
    }
    // SECURITY / REDACTION: the marker is peer-published public data that
    // reaches `LinkObservation` contexts and FFI exception text. Every error on
    // this path, including `TryFrom`, uses a static context; serde causes quote
    // the JSON and are dropped.
    let wire = serde_json::from_str::<RecoveryMarkerWire>(raw_json)
        .map_err(|_| invalid_data("Encrypted Link recovery marker JSON is invalid", None))?;
    wire.try_into()
}

/// Compute local write and remote read paths for recovery markers.
///
/// Both Pubky identities bind the paths independently of the advertised Noise
/// keys. The Noise DH secret keeps the paths private, and canonical identity
/// ordering makes each peer's write path match the other's read path.
pub fn encrypted_link_recovery_marker_paths(
    local_secret_key: &[u8; 32],
    local_identity_public_key: &PublicKey,
    remote_identity_public_key: &PublicKey,
    remote_noise_public_key: &PublicKey,
) -> (String, String) {
    let path_domain = identity_pair_path_domain(
        RECOVERY_MARKER_PATH_DOMAIN,
        local_identity_public_key,
        remote_identity_public_key,
    );
    pubky_noise::path_derivation::derive_asymmetric_paths(
        local_secret_key,
        remote_noise_public_key,
        &path_domain,
        PAYKIT_ENCRYPTED_LINK_RECOVERY_PATH_PREFIX,
    )
}

/// Publish a local recovery marker and return the path written.
///
/// The caller must provide a Pubky session with write access to the derived
/// marker path and the same local Noise key used for the peer relationship. The
/// caller owns session creation, capability scope, key rotation, and recovery
/// policy. Request timeouts are configured on the Pubky client.
/// The session supplies the local Pubky identity; `remote_identity_public_key`
/// selects the counterparty independently of its advertised Noise key.
///
/// # Errors
///
/// Returns [`PaykitError::Validation`] when the marker cannot be serialized and
/// [`PaykitError::Transport`] when Pubky storage rejects the write.
pub async fn publish_encrypted_link_recovery_marker(
    session: &PubkySession,
    local_secret_key: &[u8; 32],
    remote_identity_public_key: &PublicKey,
    remote_noise_public_key: &PublicKey,
    marker: &EncryptedLinkRecoveryMarker,
) -> Result<String> {
    let (write_path, _) = encrypted_link_recovery_marker_paths(
        local_secret_key,
        session.info().public_key(),
        remote_identity_public_key,
        remote_noise_public_key,
    );
    let payload = serialize_encrypted_link_recovery_marker(marker)?;
    session
        .storage()
        .put(write_path.clone(), payload)
        .await
        .map_err(|err| PaykitError::Transport {
            context: "publish Encrypted Link recovery marker".into(),
            source: err.into(),
        })?;
    Ok(write_path)
}

/// Remove the local recovery marker for a counterparty.
///
/// Missing markers are treated as success. The caller must provide a Pubky
/// session with write access to the derived marker path and owns session
/// creation, capability scope, key rotation, recovery policy, and Pubky-client
/// timeout configuration.
/// The session supplies the local Pubky identity; the remote identity and Noise
/// key must belong to the intended counterparty.
///
/// # Errors
///
/// Returns [`PaykitError::Transport`] when Pubky storage fails for a reason
/// other than the marker already being absent.
pub async fn remove_encrypted_link_recovery_marker(
    session: &PubkySession,
    local_secret_key: &[u8; 32],
    remote_identity_public_key: &PublicKey,
    remote_noise_public_key: &PublicKey,
) -> Result<String> {
    let (write_path, _) = encrypted_link_recovery_marker_paths(
        local_secret_key,
        session.info().public_key(),
        remote_identity_public_key,
        remote_noise_public_key,
    );
    match session.storage().delete(write_path.clone()).await {
        Ok(_) => Ok(write_path),
        Err(err) if is_not_found(&err) => Ok(write_path),
        Err(err) => Err(PaykitError::Transport {
            context: "remove Encrypted Link recovery marker".into(),
            source: err.into(),
        }),
    }
}

/// Fetch a counterparty's recovery marker, if one is present.
///
/// This unauthenticated read returns `Ok(None)` when the derived marker resource
/// is missing or empty. The caller supplies both Pubky identities and Noise keys
/// and owns key rotation, recovery policy, and Pubky-client timeout configuration.
///
/// # Errors
///
/// Returns [`PaykitError::InvalidData`] for malformed remote marker data and
/// [`PaykitError::Transport`] when Pubky storage cannot be read.
pub async fn fetch_encrypted_link_recovery_marker(
    storage: &PublicStorage,
    local_secret_key: &[u8; 32],
    local_identity_public_key: &PublicKey,
    remote_identity_public_key: &PublicKey,
    remote_noise_public_key: &PublicKey,
) -> Result<Option<EncryptedLinkRecoveryMarker>> {
    let (_, read_path) = encrypted_link_recovery_marker_paths(
        local_secret_key,
        local_identity_public_key,
        remote_identity_public_key,
        remote_noise_public_key,
    );
    let addr = format!("{remote_identity_public_key}{read_path}");
    fetch_text(
        storage,
        addr,
        "fetch Encrypted Link recovery marker",
        Some(RECOVERY_MARKER_MAX_BYTES),
    )
    .await?
    .map(|raw_json| parse_encrypted_link_recovery_marker_json(&raw_json))
    .transpose()
}

fn is_not_found(err: &PubkyError) -> bool {
    matches!(
        err,
        PubkyError::Request(RequestError::Server { status, .. })
            if *status == StatusCode::NOT_FOUND || *status == StatusCode::GONE
    )
}

#[cfg(test)]
mod tests {
    use pubky::Keypair;
    use serde_json::json;

    use super::*;

    fn secret_pair() -> ([u8; 32], PublicKey) {
        let keypair = Keypair::random();
        (keypair.secret_key(), keypair.public_key())
    }

    #[test]
    fn test_recovery_marker_json_round_trips() {
        let marker = EncryptedLinkRecoveryMarker::new(
            "650e8400-e29b-41d4-a716-446655440000",
            "2026-06-03T12:00:00Z",
        )
        .unwrap();

        let raw = serialize_encrypted_link_recovery_marker(&marker).unwrap();
        let parsed = parse_encrypted_link_recovery_marker_json(&raw).unwrap();

        assert_eq!(parsed, marker);
        assert!(raw.contains("\"kind\":\"paykit.encrypted_link_recovery\""));
    }

    #[test]
    fn test_recovery_marker_rejects_extra_fields() {
        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"}"#;

        let result = parse_encrypted_link_recovery_marker_json(raw);

        assert!(matches!(result, Err(PaykitError::InvalidData { .. })));
    }

    #[test]
    fn test_recovery_marker_parse_errors_redact_remote_payload() {
        let sentinel = "SENTINEL_REMOTE_MARKER";
        let payload = format!("{sentinel}\n\u{1b}");
        let marker = EncryptedLinkRecoveryMarker::new(
            "650e8400-e29b-41d4-a716-446655440000",
            "2026-06-03T12:00:00Z",
        )
        .unwrap();
        let with_field = |field: &str, value: serde_json::Value| {
            let mut document = serde_json::to_value(RecoveryMarkerWire::from(&marker)).unwrap();
            document[field] = value;
            document.to_string()
        };
        let cases = [
            ("version/kind", with_field("kind", json!(payload))),
            ("JSON is invalid", with_field("version", json!(payload))),
            ("JSON is invalid", with_field(&payload, json!(1))),
            ("attempt ID", with_field("attempt_id", json!(payload))),
            ("created_at", with_field("created_at", json!(payload))),
        ];

        for (expected_context, raw) in cases {
            let error = parse_encrypted_link_recovery_marker_json(&raw).unwrap_err();
            let message = error.to_string();
            assert!(message.contains(expected_context), "{message:?}");
            assert!(!message.contains(sentinel), "{message:?}");
            assert!(!message.chars().any(char::is_control), "{message:?}");
            assert!(!format!("{error:?}").contains(sentinel), "{error:?}");
            assert!(
                matches!(error, PaykitError::InvalidData { source: None, .. }),
                "{error:?}"
            );
        }
    }

    #[test]
    fn test_recovery_marker_rejects_oversized_payload() {
        let raw = " ".repeat(RECOVERY_MARKER_MAX_BYTES + 1);

        let result = parse_encrypted_link_recovery_marker_json(&raw);

        assert!(matches!(result, Err(PaykitError::InvalidData { .. })));
    }

    #[test]
    fn test_recovery_marker_paths_are_pairwise_symmetric() {
        let (alice_secret, alice_public) = secret_pair();
        let (bob_secret, bob_public) = secret_pair();
        let alice_identity = Keypair::from_secret(&[1; 32]).public_key();
        let bob_identity = Keypair::from_secret(&[2; 32]).public_key();

        let (alice_write, alice_read) = encrypted_link_recovery_marker_paths(
            &alice_secret,
            &alice_identity,
            &bob_identity,
            &bob_public,
        );
        let (bob_write, bob_read) = encrypted_link_recovery_marker_paths(
            &bob_secret,
            &bob_identity,
            &alice_identity,
            &alice_public,
        );

        assert_eq!(alice_write, bob_read);
        assert_eq!(alice_read, bob_write);
        assert!(alice_write.starts_with(PAYKIT_ENCRYPTED_LINK_RECOVERY_PATH_PREFIX));
        assert_ne!(alice_write, alice_read);
    }

    #[test]
    fn test_recovery_marker_paths_bind_both_identities_with_shared_noise_keys() {
        let alice = Keypair::from_secret(&[1; 32]).public_key();
        let bob = Keypair::from_secret(&[2; 32]).public_key();
        let other = Keypair::from_secret(&[3; 32]).public_key();
        let (secret, _) = secret_pair();
        let (_, remote_noise) = secret_pair();
        let original = encrypted_link_recovery_marker_paths(&secret, &alice, &bob, &remote_noise);

        for (local, remote) in [(&alice, &other), (&other, &bob)] {
            let changed =
                encrypted_link_recovery_marker_paths(&secret, local, remote, &remote_noise);
            assert_ne!(original.0, changed.0);
            assert_ne!(original.1, changed.1);
        }
    }
}