use std::sync::Arc;
use alloy_primitives::{B256, keccak256};
use base64::Engine as _;
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use polyc_ceremony::{
CeremonyAuthority, CeremonyClassification, CeremonyError, CeremonyKind, CeremonyPayload,
CeremonyToken, MAX_TTL_MS, MintCeremony,
};
#[cfg(any(test, feature = "test-util"))]
use polyc_crypto::session::RevokedTokens;
use tempo_primitives::transaction::TempoSignature;
use crate::{MintedSession, ScopeRoot};
use polyc_session_family::authority::BrowserSessionAuthority;
const WALLET_LOGIN_DOMAIN_PREFIX: &[u8] = b"polychrome.wallet-login.v1\0";
const WALLET_LOGIN_SCHEMA: &[u8] = b"\x01";
fn wallet_login_sig_hash(challenge_token: &str) -> B256 {
domain_separated_sig_hash(WALLET_LOGIN_DOMAIN_PREFIX, challenge_token)
}
#[must_use]
pub fn domain_separated_sig_hash(domain: &[u8], token: &str) -> B256 {
let mut preimage = Vec::with_capacity(domain.len() + token.len());
preimage.extend_from_slice(domain);
preimage.extend_from_slice(token.as_bytes());
keccak256(preimage)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RecoverRootWalletAddressError {
MalformedEnvelope,
KeychainNotYetSupported,
AssertionRejected,
}
pub fn recover_root_wallet_address(
sig_hash: &B256,
serialized_envelope: &[u8],
) -> Result<String, RecoverRootWalletAddressError> {
let sig = TempoSignature::from_bytes(serialized_envelope)
.map_err(|_| RecoverRootWalletAddressError::MalformedEnvelope)?;
if sig.is_keychain() {
return Err(RecoverRootWalletAddressError::KeychainNotYetSupported);
}
let address = sig
.recover_signer(sig_hash)
.map_err(|_| RecoverRootWalletAddressError::AssertionRejected)?;
Ok(format!("{address:#x}"))
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct WalletLoginChallenge {
pub token: String,
pub sig_hash_b64: String,
}
#[derive(Debug, Clone)]
pub struct PostedWalletAssertion {
pub serialized_envelope: Vec<u8>,
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum WalletLoginError {
#[error("login challenge was missing, already used, or expired")]
ChallengeExpired,
#[error("the posted signature envelope is malformed")]
MalformedEnvelope,
#[error("the wallet signature did not verify")]
AssertionRejected,
#[error("delegated (keychain) wallet keys are not yet accepted here")]
KeychainNotYetSupported,
#[error("this wallet is not permitted to sign in to this deployment")]
AddressDenied,
#[error("a persona-store read failed during login")]
Storage,
#[error("a session-store write failed during login")]
FamilyStore(#[from] polyc_session_family::authority::SessionAuthorityError),
#[error("the one-time ceremony authority is unavailable")]
CeremonyStore,
}
#[derive(Debug, Default)]
pub struct WalletAddressDenylist {
denied: std::collections::HashSet<String>,
}
impl WalletAddressDenylist {
#[must_use]
pub fn new(addresses: impl IntoIterator<Item = String>) -> Self {
Self {
denied: addresses.into_iter().map(|a| a.to_lowercase()).collect(),
}
}
#[must_use]
pub fn empty() -> Self {
Self::default()
}
#[must_use]
pub fn contains(&self, address: &str) -> bool {
self.denied.contains(&address.to_lowercase())
}
}
pub struct WalletLoginSessions {
ceremonies: Arc<dyn CeremonyAuthority>,
#[cfg(any(test, feature = "test-util"))]
revoked: Arc<RevokedTokens>,
address_denylist: Arc<WalletAddressDenylist>,
}
impl WalletLoginSessions {
#[must_use]
pub fn new(
ceremonies: Arc<dyn CeremonyAuthority>,
address_denylist: Arc<WalletAddressDenylist>,
) -> Self {
Self {
ceremonies,
address_denylist,
#[cfg(any(test, feature = "test-util"))]
revoked: Arc::new(RevokedTokens::new()),
}
}
#[cfg(any(test, feature = "test-util"))]
#[must_use]
pub fn new_with_legacy_revocations(
ceremonies: Arc<dyn CeremonyAuthority>,
revoked: Arc<RevokedTokens>,
address_denylist: Arc<WalletAddressDenylist>,
) -> Self {
Self {
ceremonies,
revoked,
address_denylist,
}
}
pub async fn challenge(&self, now_ms: u64) -> Result<WalletLoginChallenge, CeremonyError> {
let token = self
.ceremonies
.mint(MintCeremony {
kind: CeremonyKind::WalletLogin,
schema_version: 1,
classification: CeremonyClassification::Internal,
payload: CeremonyPayload::new(WALLET_LOGIN_SCHEMA.to_vec()),
now_ms,
ttl_ms: MAX_TTL_MS,
})
.await?;
let sig_hash = wallet_login_sig_hash(token.expose());
let sig_hash_b64 = URL_SAFE_NO_PAD.encode(sig_hash.as_slice());
Ok(WalletLoginChallenge {
token: token.expose().to_owned(),
sig_hash_b64,
})
}
pub async fn login(
&self,
challenge_token: &str,
proof: &PostedWalletAssertion,
persona: &(impl crate::PersonaSessionReader + ?Sized),
sessions: &dyn BrowserSessionAuthority,
now_ms: u64,
) -> Result<MintedSession, WalletLoginError> {
let redemption = self
.ceremonies
.redeem(
CeremonyKind::WalletLogin,
CeremonyToken::new(challenge_token),
now_ms,
)
.await;
match redemption {
Ok(payload) if payload.as_bytes() == WALLET_LOGIN_SCHEMA => {}
Ok(_) | Err(CeremonyError::Invalid | CeremonyError::Unavailable) => {
return Err(WalletLoginError::CeremonyStore);
}
Err(
CeremonyError::NotRecognized
| CeremonyError::Expired
| CeremonyError::Spent
| CeremonyError::Conflict,
) => return Err(WalletLoginError::ChallengeExpired),
}
let sig_hash = wallet_login_sig_hash(challenge_token);
let wallet_address = recover_root_wallet_address(&sig_hash, &proof.serialized_envelope)
.map_err(|err| match err {
RecoverRootWalletAddressError::MalformedEnvelope => {
WalletLoginError::MalformedEnvelope
}
RecoverRootWalletAddressError::KeychainNotYetSupported => {
WalletLoginError::KeychainNotYetSupported
}
RecoverRootWalletAddressError::AssertionRejected => {
WalletLoginError::AssertionRejected
}
})?;
if self.address_denylist.contains(&wallet_address) {
return Err(WalletLoginError::AddressDenied);
}
let (subject, scopes) = crate::resolve_subject_and_scopes(
persona,
ScopeRoot::Wallet {
wallet_address: wallet_address.clone(),
},
)
.await
.map_err(|_| WalletLoginError::Storage)?;
let (family, grant) = sessions
.create_family(ScopeRoot::Wallet { wallet_address }, now_ms)
.await
.map_err(WalletLoginError::FamilyStore)?;
let session_token = sessions
.mint_bearer(&subject, &scopes, now_ms, crate::SESSION_TTL_MS)
.await
.map_err(WalletLoginError::FamilyStore)?;
Ok(MintedSession {
session_token,
grant,
family_expires_ms: family.absolute_expires_ms,
})
}
#[cfg(any(test, feature = "test-util"))]
pub fn logout(&self, session_token: &str) {
self.revoked.revoke(session_token);
}
#[cfg(any(test, feature = "test-util"))]
#[must_use]
pub const fn revoked(&self) -> &Arc<RevokedTokens> {
&self.revoked
}
}
#[cfg(any(test, feature = "test-util"))]
pub mod test_util {
use alloy_primitives::{Address, B256, keccak256};
use base64::Engine as _;
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use p256::ecdsa::Signature as P256Signature;
use polyc_passkey::softkey::SoftPasskey;
#[must_use]
pub fn sig_hash(challenge_token: &str) -> B256 {
super::wallet_login_sig_hash(challenge_token)
}
#[must_use]
pub fn build_root_webauthn_envelope(soft: &SoftPasskey, hash: &B256) -> Vec<u8> {
let challenge_b64 = URL_SAFE_NO_PAD.encode(hash.as_slice());
let signed = soft.sign_assertion(&challenge_b64, "unused.rp", "https://unused.origin");
let mut webauthn_data = signed.authenticator_data.clone();
webauthn_data.extend_from_slice(&signed.client_data_json);
let der_sig = P256Signature::from_der(&signed.signature).expect("valid DER signature");
let normalized = der_sig.normalize_s();
let rs = normalized.to_bytes();
let pubkey = soft.public_key_sec1();
assert_eq!(pubkey.len(), 65, "uncompressed SEC1 point");
let mut envelope = Vec::with_capacity(1 + webauthn_data.len() + 128);
envelope.push(0x02u8); envelope.extend_from_slice(&webauthn_data);
envelope.extend_from_slice(&rs[0..32]); envelope.extend_from_slice(&rs[32..64]); envelope.extend_from_slice(&pubkey[1..33]); envelope.extend_from_slice(&pubkey[33..65]); envelope
}
#[must_use]
pub fn expected_address(soft: &SoftPasskey) -> String {
let pubkey = soft.public_key_sec1();
let hash = keccak256(&pubkey[1..65]);
let address = Address::from_slice(&hash[12..]);
format!("{address:#x}")
}
}
#[cfg(test)]
mod tests {
#![allow(clippy::pedantic, clippy::nursery, missing_docs)]
use polyc_ceremony::test_util::MemoryCeremonies;
use polyc_crypto::session::{
SessionScope, SessionSubject, mint_session, verify_authorized_session, verify_session,
};
use polyc_crypto::signing_role::WebSessionGrantSigner;
use polyc_passkey::softkey::SoftPasskey;
use polyc_persona::PersonaHost;
use super::test_util::{build_root_webauthn_envelope, expected_address, sig_hash};
use super::*;
use polyc_session_family::family_host::test_util::spawn_families;
const NOW: u64 = 1_700_000_000_000;
fn test_signer() -> WebSessionGrantSigner {
WebSessionGrantSigner::from_key_bytes(&[7u8; 32])
.expect("valid 32-byte ed25519 key material")
}
fn test_sessions() -> WalletLoginSessions {
WalletLoginSessions::new_with_legacy_revocations(
Arc::new(MemoryCeremonies::new()),
Arc::new(RevokedTokens::new()),
Arc::new(WalletAddressDenylist::empty()),
)
}
struct TestPersona {
host: PersonaHost,
shutdown: tokio_util::sync::CancellationToken,
dir: std::path::PathBuf,
}
impl std::ops::Deref for TestPersona {
type Target = PersonaHost;
fn deref(&self) -> &Self::Target {
&self.host
}
}
impl Drop for TestPersona {
fn drop(&mut self) {
self.shutdown.cancel();
let _ = std::fs::remove_dir_all(&self.dir);
}
}
fn spawn_persona() -> TestPersona {
let dir = std::env::temp_dir().join(format!(
"polychrome-wallet-login-{}-{}",
std::process::id(),
uuid::Uuid::new_v4()
));
let shutdown = tokio_util::sync::CancellationToken::new();
let host = PersonaHost::spawn(dir.clone(), shutdown.clone()).expect("spawn persona host");
TestPersona {
host,
shutdown,
dir,
}
}
#[tokio::test]
async fn valid_root_signature_mints_a_wallet_manage_only_session_when_unlinked() {
let sessions = test_sessions();
let persona = spawn_persona();
let families = spawn_families(test_signer());
let signer = test_signer();
let soft = SoftPasskey::from_seed(1);
let challenge = sessions.challenge(NOW).await.expect("mint challenge");
let hash = sig_hash(&challenge.token);
let envelope = build_root_webauthn_envelope(&soft, &hash);
let proof = PostedWalletAssertion {
serialized_envelope: envelope,
};
let minted = sessions
.login(&challenge.token, &proof, &*persona, &*families, NOW)
.await
.expect("a valid root-key signature over the minted challenge logs in");
let resolved =
verify_authorized_session(&signer.public_key_bytes(), &minted.session_token, NOW)
.expect("a freshly minted session verifies");
assert_eq!(
resolved.subject.wallet_address().map(str::to_owned),
Some(expected_address(&soft))
);
assert_eq!(resolved.subject.persona_id(), None);
assert!(resolved.has_scope(SessionScope::WalletManage));
assert!(
!resolved.has_scope(SessionScope::ExplorerRead),
"no persona is linked yet, so no ExplorerRead"
);
assert!(
!resolved.has_scope(SessionScope::AgentTurn),
"a wallet with no linked persona has nobody to dispatch a turn as, so the mint side \
must withhold AgentTurn — not leave the route's own refusal as the only guard"
);
let claims =
polyc_crypto::session_grant::verify_grant(&signer.public_key_bytes(), &minted.grant)
.expect("a freshly minted grant verifies");
let family = families
.get(claims.family_id)
.await
.expect("get")
.expect("the family login created is present");
assert_eq!(family.wallet_address, expected_address(&soft));
assert!(family.persona_id.is_empty());
assert_eq!(minted.family_expires_ms, family.absolute_expires_ms);
}
#[tokio::test]
async fn valid_root_signature_also_grants_explorer_read_for_a_linked_persona() {
let sessions = test_sessions();
let persona = spawn_persona();
let families = spawn_families(test_signer());
let signer = test_signer();
let soft = SoftPasskey::from_seed(2);
let address = expected_address(&soft);
let actor = polyc_proto::proto::polychrome::persona::v1::ExternalIdentity {
provider: "test".to_owned(),
external_id: "actor".to_owned(),
..Default::default()
};
persona
.attribute(
actor.clone(),
"conv-bootstrap".to_owned(),
polyc_persona::ROLE_INITIATOR.to_owned(),
NOW,
)
.await
.expect("bootstrap actor persona");
let target = polyc_proto::proto::polychrome::persona::v1::ExternalIdentity {
provider: "test".to_owned(),
external_id: uuid::Uuid::new_v4().to_string(),
..Default::default()
};
let code = uuid::Uuid::new_v4().to_string();
persona
.start_link(actor, code.clone(), 3_600_000, NOW)
.await
.expect("start_link");
let persona_id = match persona
.complete_link(code, target, NOW)
.await
.expect("complete_link")
{
polyc_persona::CompleteLinkOutcome::Linked { persona_id } => persona_id,
other => panic!("a fresh target must link cleanly: {other:?}"),
};
persona
.set_wallet_link(
&persona_id,
address.clone(),
"USD".to_owned(),
"unused-key-ref".to_owned(),
0,
NOW,
String::new(),
String::new(),
None,
)
.await
.expect("set_wallet_link");
let challenge = sessions.challenge(NOW).await.expect("mint challenge");
let hash = sig_hash(&challenge.token);
let envelope = build_root_webauthn_envelope(&soft, &hash);
let proof = PostedWalletAssertion {
serialized_envelope: envelope,
};
let minted = sessions
.login(&challenge.token, &proof, &*persona, &*families, NOW)
.await
.expect("a linked wallet logs in");
let resolved =
verify_authorized_session(&signer.public_key_bytes(), &minted.session_token, NOW)
.expect("a freshly minted session verifies");
assert_eq!(resolved.subject.wallet_address(), Some(address.as_str()));
assert_eq!(resolved.subject.persona_id(), Some(persona_id.as_str()));
assert!(resolved.has_scope(SessionScope::WalletManage));
assert!(
resolved.has_scope(SessionScope::ExplorerRead),
"a STATUS_LINKED persona resolves -> ExplorerRead too"
);
assert!(
resolved.has_scope(SessionScope::AgentTurn),
"a STATUS_LINKED persona resolves -> AgentTurn too, so the chat surface is reachable \
from a wallet-rooted login and not only a persona-credential one"
);
let claims =
polyc_crypto::session_grant::verify_grant(&signer.public_key_bytes(), &minted.grant)
.expect("a freshly minted grant verifies");
let family = families
.get(claims.family_id.clone())
.await
.expect("get")
.expect("the family login created is present");
assert_eq!(family.wallet_address, address);
}
#[tokio::test]
async fn expired_challenge_is_rejected() {
let sessions = test_sessions();
let persona = spawn_persona();
let families = spawn_families(test_signer());
let _signer = test_signer();
let soft = SoftPasskey::from_seed(3);
let challenge = sessions.challenge(NOW).await.expect("mint challenge");
let hash = sig_hash(&challenge.token);
let envelope = build_root_webauthn_envelope(&soft, &hash);
let proof = PostedWalletAssertion {
serialized_envelope: envelope,
};
let err = sessions
.login(
&challenge.token,
&proof,
&*persona,
&*families,
NOW + polyc_ceremony::MAX_TTL_MS + 1,
)
.await
.expect_err("a challenge older than the shared TTL must be rejected");
assert_eq!(err, WalletLoginError::ChallengeExpired);
}
#[tokio::test]
async fn replayed_challenge_token_is_rejected_proving_single_use() {
let sessions = test_sessions();
let persona = spawn_persona();
let families = spawn_families(test_signer());
let _signer = test_signer();
let soft = SoftPasskey::from_seed(4);
let challenge = sessions.challenge(NOW).await.expect("mint challenge");
let hash = sig_hash(&challenge.token);
let envelope = build_root_webauthn_envelope(&soft, &hash);
let proof = PostedWalletAssertion {
serialized_envelope: envelope,
};
sessions
.login(&challenge.token, &proof, &*persona, &*families, NOW)
.await
.expect("first redemption succeeds");
let replay = sessions
.login(&challenge.token, &proof, &*persona, &*families, NOW)
.await;
assert_eq!(
replay.unwrap_err(),
WalletLoginError::ChallengeExpired,
"a spent challenge token must never redeem twice"
);
}
#[test]
fn recover_root_wallet_address_returns_the_signers_address() {
let soft = SoftPasskey::from_seed(42);
let hash = sig_hash("some-domain-separated-token");
let envelope = build_root_webauthn_envelope(&soft, &hash);
let recovered =
recover_root_wallet_address(&hash, &envelope).expect("a valid envelope recovers");
assert_eq!(recovered, expected_address(&soft));
}
#[test]
fn recover_root_wallet_address_rejects_a_mismatched_hash() {
let soft = SoftPasskey::from_seed(43);
let signed_hash = sig_hash("token-a");
let checked_hash = sig_hash("token-b");
let envelope = build_root_webauthn_envelope(&soft, &signed_hash);
let err = recover_root_wallet_address(&checked_hash, &envelope)
.expect_err("a mismatched hash must be rejected");
assert_eq!(err, RecoverRootWalletAddressError::AssertionRejected);
}
#[test]
fn recover_root_wallet_address_rejects_malformed_bytes() {
let hash = sig_hash("whatever");
let err = recover_root_wallet_address(&hash, &[0x02, 0x00])
.expect_err("garbage bytes must be rejected");
assert_eq!(err, RecoverRootWalletAddressError::MalformedEnvelope);
}
#[test]
fn recover_root_wallet_address_refuses_a_keychain_envelope() {
let soft = SoftPasskey::from_seed(44);
let hash = sig_hash("token");
let inner = build_root_webauthn_envelope(&soft, &hash);
let victim = alloy_primitives::Address::from([0xEEu8; 20]);
let mut keychain_envelope = vec![0x04u8];
keychain_envelope.extend_from_slice(victim.as_slice());
keychain_envelope.extend_from_slice(&inner);
let err = recover_root_wallet_address(&hash, &keychain_envelope)
.expect_err("a keychain envelope must be refused");
assert_eq!(err, RecoverRootWalletAddressError::KeychainNotYetSupported);
}
#[tokio::test]
async fn signature_over_the_wrong_challenge_is_rejected() {
let sessions = test_sessions();
let persona = spawn_persona();
let families = spawn_families(test_signer());
let _signer = test_signer();
let soft = SoftPasskey::from_seed(5);
let challenge = sessions.challenge(NOW).await.expect("mint challenge");
let wrong_hash = sig_hash("some-other-token-entirely");
let envelope = build_root_webauthn_envelope(&soft, &wrong_hash);
let proof = PostedWalletAssertion {
serialized_envelope: envelope,
};
let err = sessions
.login(&challenge.token, &proof, &*persona, &*families, NOW)
.await
.expect_err("a mismatched challenge must be rejected");
assert_eq!(err, WalletLoginError::AssertionRejected);
}
#[tokio::test]
async fn malformed_envelope_bytes_are_rejected() {
let sessions = test_sessions();
let persona = spawn_persona();
let families = spawn_families(test_signer());
let _signer = test_signer();
let challenge = sessions.challenge(NOW).await.expect("mint challenge");
let proof = PostedWalletAssertion {
serialized_envelope: vec![0x02, 0x00, 0x01],
};
let err = sessions
.login(&challenge.token, &proof, &*persona, &*families, NOW)
.await
.expect_err("garbage bytes must be rejected");
assert_eq!(err, WalletLoginError::MalformedEnvelope);
}
#[tokio::test]
async fn keychain_envelope_is_refused_not_half_verified() {
let sessions = test_sessions();
let persona = spawn_persona();
let families = spawn_families(test_signer());
let _signer = test_signer();
let soft = SoftPasskey::from_seed(6);
let challenge = sessions.challenge(NOW).await.expect("mint challenge");
let hash = sig_hash(&challenge.token);
let inner = build_root_webauthn_envelope(&soft, &hash);
let victim = alloy_primitives::Address::from([0xEEu8; 20]);
let mut keychain_envelope = Vec::new();
keychain_envelope.push(0x04u8); keychain_envelope.extend_from_slice(victim.as_slice());
keychain_envelope.extend_from_slice(&inner);
let proof = PostedWalletAssertion {
serialized_envelope: keychain_envelope,
};
let err = sessions
.login(&challenge.token, &proof, &*persona, &*families, NOW)
.await
.expect_err("a keychain envelope must be refused, not trusted");
assert_eq!(err, WalletLoginError::KeychainNotYetSupported);
}
#[tokio::test]
async fn a_denylisted_address_is_refused_even_with_a_valid_signature() {
let persona = spawn_persona();
let families = spawn_families(test_signer());
let _signer = test_signer();
let soft = SoftPasskey::from_seed(7);
let address = expected_address(&soft);
let sessions = WalletLoginSessions::new_with_legacy_revocations(
Arc::new(MemoryCeremonies::new()),
Arc::new(RevokedTokens::new()),
Arc::new(WalletAddressDenylist::new([address.clone()])),
);
let challenge = sessions.challenge(NOW).await.expect("mint challenge");
let hash = sig_hash(&challenge.token);
let envelope = build_root_webauthn_envelope(&soft, &hash);
let proof = PostedWalletAssertion {
serialized_envelope: envelope,
};
let err = sessions
.login(&challenge.token, &proof, &*persona, &*families, NOW)
.await
.expect_err("a denylisted address must be refused");
assert_eq!(err, WalletLoginError::AddressDenied);
}
#[tokio::test]
async fn denylist_comparison_is_case_insensitive() {
let persona = spawn_persona();
let families = spawn_families(test_signer());
let _signer = test_signer();
let soft = SoftPasskey::from_seed(8);
let address = expected_address(&soft);
let sessions = WalletLoginSessions::new_with_legacy_revocations(
Arc::new(MemoryCeremonies::new()),
Arc::new(RevokedTokens::new()),
Arc::new(WalletAddressDenylist::new([address.to_uppercase()])),
);
let challenge = sessions.challenge(NOW).await.expect("mint challenge");
let hash = sig_hash(&challenge.token);
let envelope = build_root_webauthn_envelope(&soft, &hash);
let proof = PostedWalletAssertion {
serialized_envelope: envelope,
};
let err = sessions
.login(&challenge.token, &proof, &*persona, &*families, NOW)
.await
.expect_err("case must not matter for the denylist comparison");
assert_eq!(err, WalletLoginError::AddressDenied);
}
#[tokio::test]
async fn a_non_denylisted_address_signs_in_normally() {
let persona = spawn_persona();
let families = spawn_families(test_signer());
let _signer = test_signer();
let soft = SoftPasskey::from_seed(9);
let other = alloy_primitives::Address::from([0xAAu8; 20]);
let sessions = WalletLoginSessions::new_with_legacy_revocations(
Arc::new(MemoryCeremonies::new()),
Arc::new(RevokedTokens::new()),
Arc::new(WalletAddressDenylist::new([format!("{other:#x}")])),
);
let challenge = sessions.challenge(NOW).await.expect("mint challenge");
let hash = sig_hash(&challenge.token);
let envelope = build_root_webauthn_envelope(&soft, &hash);
let proof = PostedWalletAssertion {
serialized_envelope: envelope,
};
sessions
.login(&challenge.token, &proof, &*persona, &*families, NOW)
.await
.expect("an address absent from the denylist signs in normally");
}
#[test]
fn logout_revokes_a_previously_accepted_session() {
let sessions = test_sessions();
let signer = test_signer();
let session_signer = signer.relabel_for_test();
let token = mint_session(
&session_signer,
&SessionSubject::Wallet {
wallet_address: "0xabc".to_owned(),
persona_id: None,
},
&[SessionScope::WalletManage],
NOW,
crate::SESSION_TTL_MS,
);
assert!(
verify_session(
&session_signer.public_key_bytes(),
&token,
NOW,
&sessions.revoked
)
.is_some(),
"a freshly minted session verifies before logout"
);
sessions.logout(&token);
assert!(
verify_session(
&session_signer.public_key_bytes(),
&token,
NOW,
&sessions.revoked
)
.is_none(),
"the same session must be rejected once revoked"
);
}
}