use std::any::Any;
use std::fmt;
use std::sync::Arc;
use async_trait::async_trait;
use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
use pubky_common::{
auth::{
grant::GrantClaims,
grant_session_responses::{GrantSessionInfo, GrantSessionResponse},
jws::{POP_JWS_TYP, PopNonce},
pop::PopProofClaims,
},
crypto::{Keypair, PublicKey},
encryption_keys::ScopedEncryptionKeyBundle,
};
use reqwest::{Method, RequestBuilder, StatusCode};
use tokio::sync::Mutex;
use zeroize::Zeroizing;
use super::{
approval::{GrantApproval, SignedApproval, VerifiedApproval},
grant_exchange::{credential_from_grant_exchange, post_grant_session},
pop_signer::{DelegatedSignFn, GrantPopSigner},
shared_session::GrantSessionCoordinator,
};
use crate::actors::session::core::PubkySession;
use crate::actors::session::credential::{SessionCredential, credential_session_missing};
use crate::{
PubkyHttpClient,
actors::session::SessionInfo,
cross_log,
errors::{AuthError, Error, RequestError, Result},
};
pub(crate) const REFRESH_SLACK_SECS: u64 = 300;
const GRANT_SESSION_PATH: &str = "/auth/grant/session";
const STORED_GRANT_CREDENTIAL_PREFIX: &str = "pubky-grant-credential-v1";
const STORED_GRANT_CREDENTIAL_APPROVAL_PREFIX: &str = "pubky-grant-credential-v2";
const STORED_GRANT_CREDENTIAL_PREFIX_FAMILY: &str = "pubky-grant-credential-";
pub(crate) fn now_unix() -> u64 {
web_time::SystemTime::now()
.duration_since(web_time::UNIX_EPOCH)
.map(|d| d.as_secs())
.expect("System time duration_since should always valid")
}
#[derive(Debug)]
pub(crate) struct GrantCredentialState {
pub bearer: String,
pub grant_jws: String,
pub grant_claims: GrantClaims,
pub client_signer: GrantPopSigner,
pub homeserver_pk: PublicKey,
pub session: GrantSessionInfo,
pub coordinator: Option<Arc<dyn GrantSessionCoordinator>>,
}
impl GrantCredentialState {
pub(super) fn needs_refresh(&self, now: u64, slack: u64) -> bool {
self.session.token_expires_at <= now
|| (self.session.token_expires_at < self.grant_claims.exp
&& self.session.token_expires_at.saturating_sub(slack) <= now)
}
}
#[derive(Clone, Debug)]
pub struct GrantCredential {
pub(crate) state: Arc<Mutex<GrantCredentialState>>,
pub(crate) info: SessionInfo,
verified_approval: Option<Arc<VerifiedApproval>>,
}
#[derive(Debug)]
struct GrantRestoreMaterial {
grant_jws: String,
grant_claims: GrantClaims,
client_signer: GrantPopSigner,
homeserver_pk: PublicKey,
verified_approval: Option<VerifiedApproval>,
}
#[derive(Clone)]
struct StoredGrantCredential {
grant_jws: String,
client_key_secret: [u8; 32],
homeserver_pk: PublicKey,
signed_approval: Option<SignedApproval>,
}
#[derive(Clone, PartialEq, Eq)]
pub struct DelegatedGrantCredentialState {
pub grant_jws: String,
pub homeserver_pk: PublicKey,
pub key_id: String,
pub client_pk: PublicKey,
}
impl fmt::Debug for DelegatedGrantCredentialState {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("DelegatedGrantCredentialState")
.field("grant_jws", &"<redacted>")
.field("homeserver_pk", &self.homeserver_pk)
.field("key_id", &self.key_id)
.field("client_pk", &self.client_pk)
.finish()
}
}
impl fmt::Debug for StoredGrantCredential {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("StoredGrantCredential")
.field("grant_jws", &"<redacted>")
.field("client_key_secret", &"<redacted>")
.field("homeserver_pk", &self.homeserver_pk)
.field("signed_approval", &self.signed_approval)
.finish()
}
}
impl StoredGrantCredential {
fn encode(&self) -> String {
let secret = Zeroizing::new(URL_SAFE_NO_PAD.encode(self.client_key_secret));
match &self.signed_approval {
Some(approval) => format!(
"{STORED_GRANT_CREDENTIAL_APPROVAL_PREFIX}:{}:{}:{}:{}",
self.homeserver_pk.z32(),
secret.as_str(),
self.grant_jws,
approval.as_str(),
),
None => format!(
"{STORED_GRANT_CREDENTIAL_PREFIX}:{}:{}:{}",
self.homeserver_pk.z32(),
secret.as_str(),
self.grant_jws,
),
}
}
fn decode(token: &str) -> Result<Self> {
let (prefix, rest) = token.split_once(':').ok_or_else(invalid_stored_grant)?;
if prefix != STORED_GRANT_CREDENTIAL_PREFIX
&& prefix != STORED_GRANT_CREDENTIAL_APPROVAL_PREFIX
{
return Err(RequestError::Validation {
message: "unsupported grant credential token version".into(),
}
.into());
}
let (homeserver, rest) = rest.split_once(':').ok_or_else(invalid_stored_grant)?;
let (secret, grant_jws) = rest.split_once(':').ok_or_else(invalid_stored_grant)?;
let (grant_jws, signed_approval) = if prefix == STORED_GRANT_CREDENTIAL_APPROVAL_PREFIX {
let (grant, approval) = grant_jws.split_once(':').ok_or_else(invalid_stored_grant)?;
if approval.is_empty() {
return Err(invalid_stored_grant().into());
}
(grant, Some(SignedApproval::new(approval)))
} else {
(grant_jws, None)
};
if grant_jws.is_empty() {
return Err(invalid_stored_grant().into());
}
let homeserver_pk =
PublicKey::try_from_z32(homeserver).map_err(|_err| RequestError::Validation {
message: "invalid stored grant credential homeserver public key".into(),
})?;
let secret = Zeroizing::new(URL_SAFE_NO_PAD.decode(secret).map_err(|_err| {
RequestError::Validation {
message: "invalid stored grant credential client secret".into(),
}
})?);
let client_key_secret =
<[u8; 32]>::try_from(secret.as_slice()).map_err(|_err| RequestError::Validation {
message: "stored grant credential client secret must be 32 bytes".into(),
})?;
Ok(Self {
grant_jws: grant_jws.to_string(),
client_key_secret,
homeserver_pk,
signed_approval,
})
}
}
impl GrantCredential {
pub(crate) fn from_response(
response: GrantSessionResponse,
grant_jws: String,
grant_claims: GrantClaims,
client_signer: GrantPopSigner,
homeserver_pk: PublicKey,
) -> Self {
let info = to_session_info(&response.session);
let state = GrantCredentialState {
bearer: response.token,
grant_jws,
grant_claims,
client_signer,
homeserver_pk,
session: response.session,
coordinator: None,
};
Self {
state: Arc::new(Mutex::new(state)),
info,
verified_approval: None,
}
}
#[doc(hidden)]
pub fn from_shared_secret(token: &str) -> Result<Self> {
let material = restore_material(StoredGrantCredential::decode(token)?, true)?;
Ok(Self::from_shared_material(material))
}
#[doc(hidden)]
pub fn from_shared_delegated_state(
state: DelegatedGrantCredentialState,
sign: DelegatedSignFn,
signed_approval: Option<&str>,
) -> Result<Self> {
Ok(Self::from_shared_material(restore_delegated_material(
state,
sign,
true,
signed_approval,
)?))
}
fn from_shared_material(material: GrantRestoreMaterial) -> Self {
let GrantRestoreMaterial {
grant_jws,
grant_claims: claims,
client_signer: signer,
homeserver_pk: homeserver,
verified_approval,
} = material;
let response = GrantSessionResponse {
token: String::new(),
session: GrantSessionInfo {
homeserver: homeserver.clone(),
pubky: claims.iss.clone(),
client_id: claims.client_id.clone(),
capabilities: claims.caps.clone(),
grant_id: claims.jti.clone(),
token_expires_at: 0,
grant_expires_at: claims.exp,
created_at: 0,
},
};
let mut credential = Self::from_response(response, grant_jws, claims, signer, homeserver);
credential.retain_verified_approval(verified_approval);
credential
}
async fn exchange_restored_material(
material: GrantRestoreMaterial,
client: &PubkyHttpClient,
) -> Result<Self> {
let mut credential = credential_from_grant_exchange(
client,
material.grant_jws,
material.grant_claims,
material.client_signer,
material.homeserver_pk,
)
.await?;
credential.retain_verified_approval(material.verified_approval);
Ok(credential)
}
pub(crate) fn retain_verified_approval(&mut self, approval: Option<VerifiedApproval>) {
self.verified_approval = approval.map(Arc::new);
}
pub fn encryption_keys(&self) -> Option<&ScopedEncryptionKeyBundle> {
self.verified_approval
.as_ref()
.map(|approval| &approval.encryption_keys)
}
pub(crate) async fn current_bearer(&self) -> String {
self.state.lock().await.bearer.clone()
}
pub async fn export_local_secret(&self) -> Option<String> {
let state = self.state.lock().await;
let client_key_secret = state.client_signer.local_secret()?;
Some(
StoredGrantCredential {
grant_jws: state.grant_jws.clone(),
client_key_secret,
homeserver_pk: state.homeserver_pk.clone(),
signed_approval: self
.verified_approval
.as_ref()
.map(|approval| approval.signed_approval.clone()),
}
.encode(),
)
}
pub fn signed_approval(&self) -> Option<&str> {
self.verified_approval
.as_ref()
.map(|approval| approval.signed_approval.as_str())
}
pub async fn export_delegated_restore_state(&self) -> Option<DelegatedGrantCredentialState> {
let state = self.state.lock().await;
let signer = state.client_signer.delegated_state()?;
Some(DelegatedGrantCredentialState {
grant_jws: state.grant_jws.clone(),
homeserver_pk: state.homeserver_pk.clone(),
key_id: signer.key_id,
client_pk: signer.public_key,
})
}
pub(crate) fn is_secret_token(token: &str) -> bool {
token.starts_with(STORED_GRANT_CREDENTIAL_PREFIX_FAMILY)
}
pub fn restore_encryption_keys(
token: &str,
) -> Result<Option<(GrantClaims, ScopedEncryptionKeyBundle)>> {
let saved = StoredGrantCredential::decode(token)?;
Self::restore_encryption_keys_from_approval(
&saved.grant_jws,
saved.signed_approval.as_ref().map(SignedApproval::as_str),
)
}
pub fn restore_encryption_keys_from_approval(
grant_jws: &str,
signed_approval: Option<&str>,
) -> Result<Option<(GrantClaims, ScopedEncryptionKeyBundle)>> {
let approval = restore_verified_approval(signed_approval, grant_jws)?;
Ok(approval.map(|(claims, verified)| (claims, verified.encryption_keys)))
}
pub async fn import_secret(token: &str, client: &PubkyHttpClient) -> Result<Self> {
let saved = StoredGrantCredential::decode(token)?;
Self::exchange_restored_material(restore_material(saved, false)?, client).await
}
pub async fn import_delegated_state(
state: DelegatedGrantCredentialState,
client: &PubkyHttpClient,
sign: DelegatedSignFn,
signed_approval: Option<&str>,
) -> Result<Self> {
let material = restore_delegated_material(state, sign, false, signed_approval)?;
Self::exchange_restored_material(material, client).await
}
pub(crate) async fn refresh(&self, client: &PubkyHttpClient) -> Result<()> {
if let Some(coordinator) = self.coordinator().await {
return self
.refresh_shared(client, coordinator.as_ref(), None)
.await;
}
cross_log!(info, "Refreshing grant credential");
let mut state = self.state.lock().await;
if !state.needs_refresh(now_unix(), REFRESH_SLACK_SECS / 2) {
return Ok(());
}
let parsed = post_grant_session(
client,
&state.grant_jws,
&state.grant_claims,
&state.client_signer,
&state.homeserver_pk,
)
.await?;
state.bearer = parsed.token;
state.session = parsed.session;
Ok(())
}
async fn grant_session_request(
&self,
client: &PubkyHttpClient,
method: Method,
) -> Result<RequestBuilder> {
let (homeserver, user) = {
let state = self.state.lock().await;
(state.homeserver_pk.clone(), state.grant_claims.iss.clone())
};
client
.cross_request_via_homeserver(method, &homeserver, &user, GRANT_SESSION_PATH)
.await
}
}
#[cfg_attr(not(target_arch = "wasm32"), async_trait)]
#[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
impl SessionCredential for GrantCredential {
fn info(&self) -> SessionInfo {
self.info.clone()
}
async fn signout(&self, client: &PubkyHttpClient) -> Result<()> {
let lease = match self.coordinator().await {
Some(coordinator) => Some(coordinator.acquire(true).await?),
None => None,
};
if let Some(lease) = &lease {
let Some(mut shared) = lease.load().await? else {
return Ok(());
};
self.state.lock().await.adopt(shared.response.clone())?;
shared.logout_pending = true;
lease.store(&shared).await?;
}
let request = self.grant_session_request(client, Method::DELETE).await?;
let homeserver = self.state.lock().await.homeserver_pk.clone();
let supports_proof_logout = client
.features
.supports(
client,
&homeserver,
pubky_common::constants::features::GRANT_PROOF_LOGOUT,
)
.await;
let proof = {
let state = self.state.lock().await;
if supports_proof_logout {
let pop = sign_pop_for_grant(
&state.client_signer,
&state.homeserver_pk,
&state.grant_claims.jti,
)
.await?;
Some(serde_json::json!({ "grant": state.grant_jws, "pop": pop }))
} else {
None
}
};
let request = match proof {
Some(proof) => request.json(&proof),
None => request.bearer_auth(self.current_bearer().await),
};
let response = request.send().await?;
client.check_http_status(response).await?;
if let Some(lease) = lease {
lease.remove().await?;
}
Ok(())
}
async fn send(
&self,
rb: RequestBuilder,
client: &PubkyHttpClient,
) -> Result<reqwest::Response> {
if let Some(coordinator) = self.coordinator().await {
return self.send_shared(rb, client, coordinator.as_ref()).await;
}
Ok(self.attach(rb, client).await?.send().await?)
}
async fn attach(&self, rb: RequestBuilder, client: &PubkyHttpClient) -> Result<RequestBuilder> {
let needs_refresh = {
let grant_state = self.state.lock().await;
grant_state.needs_refresh(now_unix(), REFRESH_SLACK_SECS)
};
if needs_refresh {
self.refresh(client).await?;
}
let bearer = self.state.lock().await.bearer.clone();
Ok(rb.bearer_auth(bearer))
}
async fn can_attach_to(&self, homeserver: &PublicKey) -> bool {
&self.state.lock().await.homeserver_pk == homeserver
}
async fn revalidate(
&self,
client: &PubkyHttpClient,
_user: &PublicKey,
) -> Result<Option<SessionInfo>> {
let request = self.grant_session_request(client, Method::GET).await?;
let response = if let Some(coordinator) = self.coordinator().await {
match self
.send_shared(request, client, coordinator.as_ref())
.await
{
Err(Error::Request(RequestError::Server {
status: StatusCode::UNAUTHORIZED | StatusCode::NOT_FOUND,
..
})) => return Ok(None),
result => result?,
}
} else {
request
.bearer_auth(self.current_bearer().await)
.send()
.await?
};
if credential_session_missing(&response) {
return Ok(None);
}
let response = client.check_http_status(response).await?;
let session: GrantSessionInfo =
response
.json()
.await
.map_err(|e| RequestError::DecodeJson {
message: format!("decoding /auth/grant/session response: {e}"),
})?;
Ok(Some(to_session_info(&session)))
}
fn as_any(&self) -> &dyn Any {
self
}
}
impl PubkySession {
#[must_use]
pub fn from_grant_credential(client: PubkyHttpClient, credential: GrantCredential) -> Self {
Self::from_credential(client, Arc::new(credential))
}
pub async fn import_grant_secret(token: &str, client: Option<PubkyHttpClient>) -> Result<Self> {
let client = match client {
Some(client) => client,
None => PubkyHttpClient::new()?,
};
let credential = GrantCredential::import_secret(token, &client).await?;
Ok(Self::from_grant_credential(client, credential))
}
}
fn to_session_info(session: &GrantSessionInfo) -> SessionInfo {
SessionInfo::new(session.pubky.clone(), session.capabilities.clone())
}
fn restore_material(
saved: StoredGrantCredential,
allow_expired: bool,
) -> Result<GrantRestoreMaterial> {
let verified_approval = restore_verified_approval(
saved.signed_approval.as_ref().map(SignedApproval::as_str),
&saved.grant_jws,
)?
.map(|(_, approval)| approval);
let grant_claims = GrantClaims::decode(&saved.grant_jws).map_err(|err| {
AuthError::Validation(format!("invalid stored grant credential grant JWS: {err}"))
})?;
if !allow_expired && grant_claims.exp <= now_unix() {
return Err(AuthError::Validation("stored grant credential has expired".into()).into());
}
let client_keypair = Keypair::from_secret(&saved.client_key_secret);
if client_keypair.public_key() != grant_claims.cnf {
return Err(AuthError::Validation(
"stored grant credential client key does not match the grant cnf".into(),
)
.into());
}
Ok(GrantRestoreMaterial {
grant_jws: saved.grant_jws,
grant_claims,
client_signer: GrantPopSigner::local(client_keypair),
homeserver_pk: saved.homeserver_pk,
verified_approval,
})
}
fn restore_verified_approval(
signed: Option<&str>,
grant_jws: &str,
) -> Result<Option<(GrantClaims, VerifiedApproval)>> {
let Some(signed) = signed else {
return Ok(None);
};
let approval = GrantApproval::decode_text(
signed,
crate::actors::auth::deep_links::GrantApprovalFormat::SignedApprovalV1,
)?;
if approval.grant_jws != grant_jws {
return Err(AuthError::Validation(
"stored approval does not match the stored grant".into(),
)
.into());
}
Ok(approval
.verified_approval
.map(|verified| (approval.claims, verified)))
}
fn restore_delegated_material(
saved: DelegatedGrantCredentialState,
sign: DelegatedSignFn,
allow_expired: bool,
signed_approval: Option<&str>,
) -> Result<GrantRestoreMaterial> {
let verified_approval =
restore_verified_approval(signed_approval, &saved.grant_jws)?.map(|(_, approval)| approval);
let grant_claims = GrantClaims::decode(&saved.grant_jws).map_err(|err| {
AuthError::Validation(format!(
"invalid delegated grant credential grant JWS: {err}"
))
})?;
if !allow_expired && grant_claims.exp <= now_unix() {
return Err(AuthError::Validation("delegated grant credential has expired".into()).into());
}
if saved.client_pk != grant_claims.cnf {
return Err(AuthError::Validation(
"delegated grant credential client key does not match the grant cnf".into(),
)
.into());
}
Ok(GrantRestoreMaterial {
grant_jws: saved.grant_jws,
grant_claims,
client_signer: GrantPopSigner::delegated(saved.key_id, saved.client_pk, sign),
homeserver_pk: saved.homeserver_pk,
verified_approval,
})
}
fn invalid_stored_grant() -> AuthError {
AuthError::Validation(format!(
"invalid stored grant credential: expected `{STORED_GRANT_CREDENTIAL_PREFIX}:<homeserver>:<client_secret>:<grant_jws>`"
))
}
pub(crate) async fn sign_pop_for_grant(
client_signer: &GrantPopSigner,
homeserver_pk: &PublicKey,
grant_id: &pubky_common::auth::jws::GrantId,
) -> Result<String> {
let claims = PopProofClaims {
aud: homeserver_pk.clone(),
gid: grant_id.clone(),
nonce: PopNonce::generate(),
iat: now_unix(),
};
client_signer
.sign_jws(POP_JWS_TYP, &claims)
.await
.map_err(|error| AuthError::Validation(error.to_string()).into())
}
#[cfg(test)]
mod tests {
use std::num::NonZeroUsize;
use pkarr::{Cache, InMemoryCache};
use pubky_common::{
auth::jws::{ClientId, GRANT_JWS_TYP, GrantId},
capabilities::Capability,
};
use pubky_testnet::EphemeralTestnet;
use super::*;
#[tokio::test]
#[pubky_testnet::test]
async fn capped_bearer_requests_and_legacy_logout_do_not_refresh() {
let testnet = EphemeralTestnet::builder().build().await.unwrap();
let user = Keypair::random();
let homeserver = testnet.homeserver_app().public_key();
testnet
.sdk()
.unwrap()
.signer(user.clone())
.signup(&homeserver, None)
.await
.unwrap();
let mut builder = PubkyHttpClient::builder();
builder.pkarr(|b| {
*b = testnet.pkarr_client_builder();
b
});
let client = builder.build().unwrap();
client.features.insert(&homeserver, &[]);
let pop_key = Keypair::random();
let now = now_unix();
let grant = GrantClaims {
iss: user.public_key(),
client_id: ClientId::new("refresh.test").unwrap(),
caps: vec![Capability::root()],
cnf: pop_key.public_key(),
jti: GrantId::generate(),
iat: now,
exp: now + 120,
};
let jws = grant.sign(&user, GRANT_JWS_TYP);
let signer = GrantPopSigner::local(pop_key);
let response = post_grant_session(&client, &jws, &grant, &signer, &homeserver)
.await
.unwrap();
let bearer = response.token.clone();
assert_eq!(response.session.token_expires_at, grant.exp);
let credential = GrantCredential::from_response(response, jws, grant, signer, homeserver);
for _ in 0..3 {
credential.refresh(&client).await.unwrap();
let request = credential
.grant_session_request(&client, Method::GET)
.await
.unwrap();
let response = credential
.attach(request, &client)
.await
.unwrap()
.send()
.await
.unwrap();
client.check_http_status(response).await.unwrap();
assert_eq!(credential.current_bearer().await, bearer);
}
credential.state.lock().await.session.token_expires_at = now_unix() + 60;
credential.signout(&client).await.unwrap();
assert!(
credential
.revalidate(&client, &user.public_key())
.await
.unwrap()
.is_none()
);
}
#[test]
fn refresh_policy_preserves_capped_bearers_until_expiry() {
let (stored, claims) = stored_credential(5_000);
let signer = GrantPopSigner::local(Keypair::from_secret(&stored.client_key_secret));
let credential = test_credential(stored, claims, signer);
let mut state = credential.state.blocking_lock();
let now = 1_000;
for (bearer_exp, grant_exp, slack, expected) in [
(1_301, 5_000, 300, false),
(1_300, 5_000, 300, true),
(1_151, 5_000, 150, false),
(1_150, 5_000, 150, true),
(1_120, 1_120, 150, false),
(1_120, 1_100, 150, false),
(1_000, 1_000, 150, true),
(999, 999, 150, true),
(0, 1_120, 150, true),
] {
state.session.token_expires_at = bearer_exp;
state.grant_claims.exp = grant_exp;
assert_eq!(
state.needs_refresh(now, slack),
expected,
"bearer_exp={bearer_exp}, grant_exp={grant_exp}, slack={slack}"
);
}
}
#[test]
fn stored_grant_credential_encode_decode_round_trips() {
let (stored, _claims) = stored_credential(now_unix() + 3600);
let encoded = stored.encode();
let decoded = StoredGrantCredential::decode(&encoded).unwrap();
assert_eq!(decoded.grant_jws, stored.grant_jws);
assert_eq!(decoded.client_key_secret, stored.client_key_secret);
assert_eq!(decoded.homeserver_pk, stored.homeserver_pk);
assert!(decoded.signed_approval.is_none());
}
#[test]
fn key_bearing_secret_round_trip_verifies_approval_and_preserves_keys() {
let identity = Keypair::random();
let (mut stored, mut claims) = stored_credential(now_unix() + 3600);
claims.iss = identity.public_key();
claims.caps = vec!["/pub/chat/:re".parse().unwrap()];
stored.grant_jws = claims.sign(&identity, GRANT_JWS_TYP);
let signed =
super::super::approval_envelope::GrantApprovalEnvelope::sign(&identity, &claims);
stored.signed_approval = Some(SignedApproval::new(&signed));
let encoded = Zeroizing::new(stored.encode());
assert!(encoded.starts_with(STORED_GRANT_CREDENTIAL_APPROVAL_PREFIX));
let decoded = StoredGrantCredential::decode(&encoded).unwrap();
let (_, approval) = restore_verified_approval(
decoded.signed_approval.as_ref().map(SignedApproval::as_str),
&decoded.grant_jws,
)
.unwrap()
.unwrap();
let keys = approval.encryption_keys;
let path = pubky_common::StoragePath::new("/pub/chat/message").unwrap();
let expected = ScopedEncryptionKeyBundle::from_identity_secret(&identity.secret(), [&path]);
assert_eq!(
*keys.derive_for_path(&path).unwrap(),
*expected.derive_for_path(&path).unwrap()
);
keys.derive_for_path(&pubky_common::StoragePath::new("/pub/other/file").unwrap())
.unwrap_err();
assert!(!format!("{decoded:?}").contains(signed.as_str()));
let (other, _) = stored_credential(now_unix() + 3600);
restore_verified_approval(Some(&signed), &other.grant_jws).unwrap_err();
let mut tampered = signed.as_bytes().to_vec();
let signature_start = signed.rfind('.').unwrap() + 1;
tampered[signature_start] = if tampered[signature_start] == b'A' {
b'B'
} else {
b'A'
};
restore_verified_approval(
Some(std::str::from_utf8(&tampered).unwrap()),
&stored.grant_jws,
)
.unwrap_err();
}
fn expired_key_credential() -> (
StoredGrantCredential,
DelegatedGrantCredentialState,
Keypair,
) {
let identity = Keypair::random();
let (mut saved, mut claims) = stored_credential(1);
claims.iat = 0;
claims.iss = identity.public_key();
claims.caps = vec!["/pub/chat/:re".parse().unwrap()];
saved.grant_jws = claims.sign(&identity, GRANT_JWS_TYP);
let signed =
super::super::approval_envelope::GrantApprovalEnvelope::sign(&identity, &claims);
saved.signed_approval = Some(SignedApproval::new(&signed));
let delegated = DelegatedGrantCredentialState {
grant_jws: saved.grant_jws.clone(),
homeserver_pk: saved.homeserver_pk.clone(),
key_id: "missing-browser-key".into(),
client_pk: claims.cnf,
};
(saved, delegated, identity)
}
#[test]
fn offline_key_recovery_accepts_expiry_without_relaxing_session_restore() {
let (saved, delegated, identity) = expired_key_credential();
let token = Zeroizing::new(saved.encode());
let (local_claims, local_keys) = GrantCredential::restore_encryption_keys(&token)
.unwrap()
.unwrap();
let (delegated_claims, delegated_keys) =
GrantCredential::restore_encryption_keys_from_approval(
&delegated.grant_jws,
saved.signed_approval.as_ref().map(SignedApproval::as_str),
)
.unwrap()
.unwrap();
assert_eq!(local_claims.iss, identity.public_key());
assert_eq!(local_claims.jti, delegated_claims.jti);
assert_eq!(delegated_claims.iss, identity.public_key());
let path = pubky_common::StoragePath::new("/pub/chat/message").unwrap();
let expected = ScopedEncryptionKeyBundle::from_identity_secret(&identity.secret(), [&path]);
let shared_local = GrantCredential::from_shared_secret(&token).unwrap();
let shared_delegated = GrantCredential::from_shared_delegated_state(
delegated.clone(),
test_delegated_signer(),
saved.signed_approval.as_ref().map(SignedApproval::as_str),
)
.unwrap();
assert!(shared_local.signed_approval().is_some());
assert!(shared_delegated.signed_approval().is_some());
for keys in [
&local_keys,
&delegated_keys,
shared_local.encryption_keys().unwrap(),
shared_delegated.encryption_keys().unwrap(),
] {
assert_eq!(
*keys.derive_for_path(&path).unwrap(),
*expected.derive_for_path(&path).unwrap()
);
keys.derive_for_path(&pubky_common::StoragePath::new("/pub/other/file").unwrap())
.unwrap_err();
keys.derive_for_path(&pubky_common::StoragePath::new("/pub/chat/").unwrap())
.unwrap_err();
}
assert!(
restore_material(saved, false)
.unwrap_err()
.to_string()
.contains("has expired")
);
assert!(
restore_delegated_material(delegated, test_delegated_signer(), false, None)
.unwrap_err()
.to_string()
.contains("has expired")
);
}
#[test]
fn offline_key_recovery_rejects_tampering_and_mismatched_grants() {
let (mut saved, mut delegated, _) = expired_key_credential();
let signed = saved.signed_approval.clone().unwrap();
let (other, _) = stored_credential(now_unix() + 3600);
delegated.grant_jws = other.grant_jws.clone();
GrantCredential::restore_encryption_keys_from_approval(
&delegated.grant_jws,
Some(signed.as_str()),
)
.unwrap_err();
let original_grant = std::mem::replace(&mut saved.grant_jws, other.grant_jws);
GrantCredential::restore_encryption_keys(&saved.encode()).unwrap_err();
saved.grant_jws = original_grant;
delegated.grant_jws = saved.grant_jws.clone();
let mut tampered = signed.as_str().as_bytes().to_vec();
let signature_start = signed.as_str().rfind('.').unwrap() + 1;
tampered[signature_start] = if tampered[signature_start] == b'A' {
b'B'
} else {
b'A'
};
let tampered = Zeroizing::new(String::from_utf8(tampered).unwrap());
saved.signed_approval = Some(SignedApproval::new(&tampered));
GrantCredential::from_shared_secret(&saved.encode()).unwrap_err();
GrantCredential::from_shared_delegated_state(
delegated.clone(),
test_delegated_signer(),
Some(&tampered),
)
.unwrap_err();
GrantCredential::restore_encryption_keys(&saved.encode()).unwrap_err();
GrantCredential::restore_encryption_keys_from_approval(
&delegated.grant_jws,
Some(&tampered),
)
.unwrap_err();
GrantCredential::restore_encryption_keys("malformed").unwrap_err();
}
#[test]
fn offline_legacy_restore_returns_no_keys() {
let (saved, _) = stored_credential(1);
assert!(
GrantCredential::restore_encryption_keys(&saved.encode())
.unwrap()
.is_none()
);
let (_, delegated, _) = expired_key_credential();
assert!(
GrantCredential::restore_encryption_keys_from_approval(&delegated.grant_jws, None)
.unwrap()
.is_none()
);
}
#[test]
fn v2_secret_rejects_missing_approval() {
let (stored, _) = stored_credential(now_unix() + 3600);
let token = stored.encode().replacen(
STORED_GRANT_CREDENTIAL_PREFIX,
STORED_GRANT_CREDENTIAL_APPROVAL_PREFIX,
1,
);
StoredGrantCredential::decode(&token).unwrap_err();
StoredGrantCredential::decode(&format!("{token}:")).unwrap_err();
}
#[test]
fn restore_material_rejects_mismatched_client_key() {
let (mut stored, _claims) = stored_credential(now_unix() + 3600);
stored.client_key_secret = Keypair::random().secret();
let error = restore_material(stored, false).unwrap_err().to_string();
assert!(error.contains("client key does not match"));
}
#[test]
fn restore_delegated_material_rejects_mismatched_client_key() {
let (stored, _claims) = stored_credential(now_unix() + 3600);
let saved = DelegatedGrantCredentialState {
grant_jws: stored.grant_jws,
homeserver_pk: stored.homeserver_pk,
key_id: "delegated-test-key".into(),
client_pk: Keypair::random().public_key(),
};
let error = restore_delegated_material(saved, test_delegated_signer(), false, None)
.unwrap_err()
.to_string();
assert!(error.contains("client key does not match"));
}
#[test]
fn restore_delegated_material_rejects_expired_grant() {
let (stored, claims) = stored_credential(now_unix().saturating_sub(1));
let saved = DelegatedGrantCredentialState {
grant_jws: stored.grant_jws,
homeserver_pk: stored.homeserver_pk,
key_id: "delegated-test-key".into(),
client_pk: claims.cnf,
};
let error = restore_delegated_material(saved, test_delegated_signer(), false, None)
.unwrap_err()
.to_string();
assert!(error.contains("has expired"));
}
#[tokio::test]
async fn export_local_secret_is_only_available_for_local_signers() {
let (stored, claims) = stored_credential(now_unix() + 3600);
let local_signer = GrantPopSigner::local(Keypair::from_secret(&stored.client_key_secret));
let delegated_signer = GrantPopSigner::delegated(
"delegated-test-key".into(),
claims.cnf.clone(),
test_delegated_signer(),
);
let local = test_credential(stored.clone(), claims.clone(), local_signer);
let delegated = test_credential(stored, claims, delegated_signer);
assert!(local.export_local_secret().await.is_some());
assert!(delegated.export_local_secret().await.is_none());
assert!(delegated.export_delegated_restore_state().await.is_some());
}
#[test]
fn restore_material_rejects_expired_grant() {
let (stored, _claims) = stored_credential(now_unix().saturating_sub(1));
let error = restore_material(stored, false).unwrap_err().to_string();
assert!(error.contains("has expired"));
}
#[test]
fn shared_restore_keeps_expired_material_for_logout() {
let (stored, claims) = stored_credential(now_unix().saturating_sub(1));
GrantCredential::from_shared_secret(&stored.encode()).unwrap();
let delegated = DelegatedGrantCredentialState {
grant_jws: stored.grant_jws,
homeserver_pk: stored.homeserver_pk,
key_id: "delegated-test-key".into(),
client_pk: claims.cnf,
};
GrantCredential::from_shared_delegated_state(delegated, test_delegated_signer(), None)
.unwrap();
}
#[test]
fn stored_grant_credential_decode_rejects_wrong_prefix() {
let error = StoredGrantCredential::decode("wrong:v:secret:grant")
.unwrap_err()
.to_string();
assert!(error.contains("unsupported grant credential token version"));
}
fn stored_credential(exp: u64) -> (StoredGrantCredential, GrantClaims) {
let user_keypair = Keypair::random();
let client_keypair = Keypair::random();
let homeserver_keypair = Keypair::random();
let claims = GrantClaims {
iss: user_keypair.public_key(),
client_id: ClientId::new("stored-grant.test").unwrap(),
caps: vec![Capability::root()],
cnf: client_keypair.public_key(),
jti: GrantId::generate(),
iat: now_unix(),
exp,
};
let grant_jws = claims.sign(&user_keypair, GRANT_JWS_TYP);
let stored = StoredGrantCredential {
grant_jws,
client_key_secret: client_keypair.secret(),
homeserver_pk: homeserver_keypair.public_key(),
signed_approval: None,
};
(stored, claims)
}
fn test_credential(
stored: StoredGrantCredential,
claims: GrantClaims,
client_signer: GrantPopSigner,
) -> GrantCredential {
let now = now_unix();
GrantCredential::from_response(
GrantSessionResponse {
token: "test-bearer".into(),
session: GrantSessionInfo {
homeserver: stored.homeserver_pk.clone(),
pubky: claims.iss.clone(),
client_id: claims.client_id.clone(),
capabilities: claims.caps.clone(),
grant_id: claims.jti.clone(),
token_expires_at: now + 300,
grant_expires_at: claims.exp,
created_at: now,
},
},
stored.grant_jws,
claims,
client_signer,
stored.homeserver_pk,
)
}
fn test_delegated_signer() -> DelegatedSignFn {
super::super::pop_signer::delegated_sign_callback(|_| async { Ok(vec![0; 64]) })
}
#[tokio::test]
async fn can_attach_to_only_matches_bound_homeserver() {
let bound = Keypair::random().public_key();
let other = Keypair::random().public_key();
let (mut stored, claims) = stored_credential(now_unix() + 3600);
stored.homeserver_pk = bound.clone();
let client_signer = GrantPopSigner::local(Keypair::from_secret(&stored.client_key_secret));
let credential = test_credential(stored, claims, client_signer);
assert!(
credential.can_attach_to(&bound).await,
"grant credential must attach to the homeserver it was minted for"
);
assert!(
!credential.can_attach_to(&other).await,
"grant credential must NOT attach to a homeserver it was not minted for"
);
}
#[tokio::test]
async fn grant_session_request_routes_through_bound_homeserver() {
use pkarr::{SignedPacket, dns::rdata::SVCB};
let (mut stored, claims) = stored_credential(now_unix() + 3600);
let homeserver_keypair = pkarr::Keypair::random();
stored.homeserver_pk =
PublicKey::try_from_z32(&homeserver_keypair.public_key().to_string()).unwrap();
let icann = SVCB::new(10, "example.com".try_into().unwrap());
let homeserver_packet = SignedPacket::builder()
.https(".".try_into().unwrap(), icann, 3600)
.sign(&homeserver_keypair)
.unwrap();
let client_signer = GrantPopSigner::local(Keypair::from_secret(&stored.client_key_secret));
let credential = test_credential(stored, claims.clone(), client_signer);
let cache = Arc::new(InMemoryCache::new(NonZeroUsize::MIN));
let mut builder = PubkyHttpClient::builder();
builder
.isolated_pkarr_test()
.pkarr(|b| b.cache(Arc::<InMemoryCache>::clone(&cache)));
let client = builder.build().unwrap();
cache.put(&homeserver_keypair.public_key().into(), &homeserver_packet);
let request = credential
.grant_session_request(&client, Method::POST)
.await
.unwrap()
.build()
.unwrap();
assert_eq!(request.url().host_str(), Some("example.com"));
assert_eq!(request.url().path(), "/auth/grant/session");
assert_eq!(
request.headers().get("pubky-host").unwrap(),
&claims.iss.z32()
);
}
}