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;
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct EncryptedLinkRecoveryMarker {
attempt_id: String,
created_at: String,
}
impl EncryptedLinkRecoveryMarker {
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,
})
}
pub fn new_v4(created_at: impl Into<String>) -> Result<Self> {
Self::new(uuid::Uuid::new_v4().to_string(), created_at)
}
pub fn attempt_id(&self) -> &str {
&self.attempt_id
}
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 {
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"))
}
}
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}"
))
})
}
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,
));
}
let wire = serde_json::from_str::<RecoveryMarkerWire>(raw_json)
.map_err(|_| invalid_data("Encrypted Link recovery marker JSON is invalid", None))?;
wire.try_into()
}
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,
)
}
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)
}
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(),
}),
}
}
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);
}
}
}