1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
use std::path::Path;
use crate::PublicKey;
use pubky_common::session::SessionInfo;
use super::core::PubkySession;
use crate::{
Capabilities, PubkyHttpClient, Result, cross_log,
errors::{AuthError, RequestError},
};
impl PubkySession {
/// Export the minimum data needed to restore this session later.
/// Returns a single compact secret token `<pubkey>:<cookie_secret>`
///
/// Useful for scripts that need restarting. Helps avoiding a new Auth flow
/// from a signer on a script restart.
///
/// Treat the returned String as a **bearer secret**. Do not log it; store it
/// securely.
#[must_use]
pub fn export_secret(&self) -> String {
let public_key = self.info().public_key().z32();
let cookie = self.cookie.clone();
cross_log!(info, "Exporting session secret for {}", public_key);
format!("{public_key}:{cookie}")
}
/// Rehydrate a session from a compact secret token `<pubkey>:<cookie_secret>`.
///
/// Useful for scripts that need restarting. Helps avoiding a new Auth flow
/// from a signer on a script restart.
///
/// Performs a `/session` roundtrip to validate and hydrate the authoritative `SessionInfo`.
/// Returns `AuthError::RequestExpired` if the cookie is invalid/expired.
/// # Errors
/// - Returns [`crate::errors::RequestError::Validation`] if the token is malformed or contains an invalid public key.
/// - Propagates transport failures while validating the session with the homeserver.
pub async fn import_secret(token: &str, client: Option<PubkyHttpClient>) -> Result<Self> {
// 1) Get the transport for this session
let client = match client {
Some(c) => c,
None => PubkyHttpClient::new()?,
};
// 2) Parse `<pubkey>:<cookie_secret>` (cookie may contain `:`, so split at the first one)
let (pk_str, cookie) = token
.split_once(':')
.ok_or_else(|| RequestError::Validation {
message: "invalid secret: expected `<pubkey>:<cookie>`".into(),
})?;
let public_key =
PublicKey::try_from_z32(pk_str).map_err(|_err| RequestError::Validation {
message: "invalid public key".into(),
})?;
cross_log!(info, "Importing session secret for {}", public_key);
// 3) Build minimal session; placeholder SessionInfo will be replaced after validation.
let placeholder = SessionInfo::new(&public_key, Capabilities::default(), None);
let mut session = Self {
client,
info: placeholder,
cookie: cookie.to_string(),
};
// 4) Validate cookie and fetch authoritative SessionInfo
let info = session
.revalidate()
.await?
.ok_or(AuthError::RequestExpired)?;
session.info = info;
cross_log!(
info,
"Successfully imported session secret for {}",
public_key
);
Ok(session)
}
/// Write the session secret token to disk. Ensures a `.sess` extension.
///
/// Behavior:
/// - If `secret_file_path` already ends with `.sess`, it is used as-is.
/// - If it has no extension, `.sess` is added.
/// - If it has a different extension, `.<ext>.sess` is appended (e.g., `foo.txt.sess`).
///
/// On Unix, permissions are set to `0o600`.
/// # Errors
/// - Returns [`std::io::Error`] if the file cannot be written or permissions cannot be set.
pub fn write_secret_file<P: AsRef<Path>>(&self, secret_file_path: P) -> std::io::Result<()> {
let token = self.export_secret();
let p = secret_file_path.as_ref();
let target = match p.extension().and_then(|e| e.to_str()) {
Some("sess") => p.to_path_buf(),
Some(_) => {
// Append, do not replace: `name.ext` -> `name.ext.sess`
let mut out = p.to_path_buf();
let fname = p.file_name().and_then(|n| n.to_str()).unwrap_or("session");
out.set_file_name(format!("{fname}.sess"));
out
}
None => {
// No extension: add `.sess`
let mut out = p.to_path_buf();
out.set_extension("sess");
out
}
};
std::fs::write(&target, token)?;
cross_log!(
info,
"Wrote session secret for {} to {}",
self.info().public_key(),
target.display()
);
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&target, std::fs::Permissions::from_mode(0o600))?;
};
Ok(())
}
/// Restore a session from a secret token stored in a file. Requires a `.sess` extension.
///
/// Validation:
/// - `.sess` — valid; file is read and parsed.
/// - `.pkarr` — rejected with a clear error message pointing to `Keypair::from_secret_file`.
/// - Any other or missing extension — rejected with a `.sess`-specific error.
/// # Errors
/// - Returns [`crate::errors::RequestError::Validation`] when the file extension is not `.sess`.
/// - Returns [`crate::errors::RequestError::Validation`] if the file cannot be read.
/// - Propagates errors from [`Self::import_secret`] when the stored token is invalid or when the session cannot be revalidated.
pub async fn from_secret_file(
secret_file_path: &Path,
client: Option<PubkyHttpClient>,
) -> Result<Self> {
match secret_file_path.extension().and_then(|e| e.to_str()) {
Some("sess") => { /* ok */ }
Some("pkarr") => {
return Err(RequestError::Validation {
message: format!(
"refused to load `{}`: `.pkarr` is a keypair secret. \
Use `Keypair::from_secret_file` to load keys. \
Session secrets must use the `.sess` extension.",
secret_file_path.display()
),
}
.into());
}
Some(other) => {
return Err(RequestError::Validation {
message: format!(
"invalid session secret extension `.{other}` for `{}`; expected `.sess`",
secret_file_path.display()
),
}
.into());
}
None => {
return Err(RequestError::Validation {
message: format!(
"missing extension for `{}`; session secret files must end with `.sess`",
secret_file_path.display()
),
}
.into());
}
}
let token =
std::fs::read_to_string(secret_file_path).map_err(|e| RequestError::Validation {
message: format!("failed to read session secret file: {e}"),
})?;
cross_log!(
info,
"Loading session secret from {}",
secret_file_path.display()
);
Self::import_secret(token.trim(), client).await
}
}