use notochord::{
AdmittedSession, DenyReason, IoHandshakeError, LocalNetworkPolicy, NetworkId, ProfileRef,
RevocationLedger, ServiceAccess, ServiceRule, TrustedRoot, admit_session,
};
use tokio::io::AsyncWriteExt;
use transport::{Alpn, Transport, TransportError};
use crate::admission::{
CONNECT_ACTION, GRAPHSHELL_DOMAIN, PROJECTION_PROTOCOL, PROJECTION_SERVICE, serves_action,
};
pub fn projection_alpn() -> Alpn {
Alpn::from_bytes(PROJECTION_PROTOCOL)
}
pub fn projection_policy(
network: NetworkId,
trusted_roots: Vec<TrustedRoot>,
accepted_profiles: Vec<ProfileRef>,
max_sessions: Option<u32>,
) -> LocalNetworkPolicy {
let mut policy = LocalNetworkPolicy::closed(network);
policy.trusted_roots = trusted_roots;
policy.accepted_profiles = accepted_profiles;
policy.services.insert(
PROJECTION_SERVICE.to_string(),
ServiceRule::new(
ServiceAccess::MemberOnly,
GRAPHSHELL_DOMAIN,
[CONNECT_ACTION],
false,
max_sessions,
),
);
policy
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum ProjectionRefusal {
NotAdmitted(DenyReason),
ActionNotServed(String),
}
#[derive(Debug, thiserror::Error)]
pub enum ProjectionAcceptError {
#[error("projection carrier accept failed: {0}")]
Carrier(#[from] TransportError),
#[error(transparent)]
Handshake(#[from] IoHandshakeError),
}
pub async fn accept_projection_session<T: Transport>(
transport: &T,
policy: &LocalNetworkPolicy,
ledger: &RevocationLedger,
now_ms: u64,
active_sessions: u32,
) -> Result<Result<AdmittedSession<T::Stream>, ProjectionRefusal>, ProjectionAcceptError> {
let accepted = transport.accept(projection_alpn()).await?;
let (stream, facts) = accepted.into_session();
let admitted = admit_session(stream, policy, ledger, &facts, now_ms, active_sessions).await?;
let mut session = match admitted {
Ok(session) => session,
Err(reason) => return Ok(Err(ProjectionRefusal::NotAdmitted(reason))),
};
if !serves_action(&session.principal) {
let action = session.principal.action.action.clone();
let _ = session.stream.shutdown().await;
return Ok(Err(ProjectionRefusal::ActionNotServed(action)));
}
Ok(Ok(session))
}
#[cfg(test)]
mod tests {
use std::sync::OnceLock;
use super::*;
use crate::admission::{CONNECT_ACTION, GRAPHSHELL_DOMAIN, connect_action, open_session};
use notochord::{CarrierKind, RequestedAction, SessionHello, TrafficClass, initiate_session};
use personae::IdentityProvider;
use personae::InMemoryProvider;
use personae::delegation::{
CapabilityScope, DelegationCertificate, DelegationParent, SignedDelegationCertificate,
};
use tokio::io::AsyncReadExt;
use transport::memory::MemoryTransport;
use transport::{P2pandaTransport, PeerID, initiator_binding};
const NETWORK: NetworkId = NetworkId([3; 32]);
const ROOT_AUTHORITY: [u8; 32] = [7; 32];
const NOW_MS: u64 = 50;
fn p2panda_receipt_lock() -> &'static tokio::sync::Mutex<()> {
static LOCK: OnceLock<tokio::sync::Mutex<()>> = OnceLock::new();
LOCK.get_or_init(|| tokio::sync::Mutex::new(()))
}
fn owner() -> InMemoryProvider {
InMemoryProvider::from_seed([1; 32])
}
fn viewer() -> InMemoryProvider {
InMemoryProvider::from_seed([4; 32])
}
fn profile_ref() -> ProfileRef {
ProfileRef {
id: "mere.base".into(),
revision: 1,
}
}
fn grant_for(
subject: [u8; 32],
domain: &str,
path: &str,
action: &str,
) -> SignedDelegationCertificate {
SignedDelegationCertificate::issue(
&owner(),
DelegationCertificate::new(
DelegationParent::Root(ROOT_AUTHORITY),
owner().master_public_key().to_bytes(),
subject,
CapabilityScope {
domain: domain.into(),
resource: NETWORK.0.to_vec(),
path_prefix: path.into(),
actions: [action.to_string()].into_iter().collect(),
},
5,
10,
Some(100),
1,
[1; 32],
),
)
.expect("issue certificate")
}
fn grant(subject: [u8; 32], action: &str) -> SignedDelegationCertificate {
grant_for(subject, GRAPHSHELL_DOMAIN, PROJECTION_SERVICE, action)
}
fn policy() -> LocalNetworkPolicy {
projection_policy(
NETWORK,
vec![TrustedRoot {
authority: ROOT_AUTHORITY,
issuer: owner().master_public_key().to_bytes(),
}],
vec![profile_ref()],
None,
)
}
async fn run(action: &str) -> Result<String, ProjectionRefusal> {
let viewer = viewer();
let subject = viewer.master_public_key().to_bytes();
let client_peer = PeerID::from_bytes(&subject).expect("client peer");
let server_peer =
PeerID::from_bytes(&owner().master_public_key().to_bytes()).expect("server peer");
let (server, client) = MemoryTransport::pair(server_peer, client_peer);
let mut requested = connect_action();
requested.action = action.to_string();
let delegations = vec![grant(subject, action)];
let action_owned = action.to_string();
let mut serving_policy = policy();
serving_policy
.services
.get_mut(PROJECTION_SERVICE)
.expect("projection rule")
.actions
.insert(action.to_string());
let client_task = tokio::spawn(async move {
let mut stream = client
.connect(server_peer, projection_alpn())
.await
.expect("dial");
let binding = initiator_binding(&projection_alpn(), client_peer);
let hello = if action_owned == CONNECT_ACTION {
open_session(
&viewer,
NETWORK,
profile_ref(),
TrafficClass::Interactive,
[5; 32],
&binding,
delegations,
)
} else {
SessionHello::issue(
&viewer,
NETWORK,
profile_ref(),
RequestedAction {
domain: GRAPHSHELL_DOMAIN.into(),
path: PROJECTION_SERVICE.into(),
action: action_owned,
},
TrafficClass::Interactive,
[5; 32],
&binding,
delegations,
)
}
.expect("issue hello");
let _ = initiate_session(&mut stream, &hello, &policy().limits.clamped()).await;
tokio::time::sleep(std::time::Duration::from_millis(200)).await;
});
let outcome = accept_projection_session(
&server,
&serving_policy,
&RevocationLedger::default(),
NOW_MS,
0,
)
.await
.expect("accept path");
client_task.abort();
outcome.map(|session| session.principal.action.action.clone())
}
#[tokio::test]
async fn a_connect_grant_is_admitted_and_served() {
let served = run(CONNECT_ACTION).await.expect("must be served");
assert_eq!(served, CONNECT_ACTION);
}
#[tokio::test]
async fn an_admitted_action_this_service_does_not_serve_is_refused() {
let refusal = run("administer").await.expect_err("must be refused");
assert_eq!(
refusal,
ProjectionRefusal::ActionNotServed("administer".to_string())
);
}
async fn p2panda_pair() -> (P2pandaTransport, P2pandaTransport, PeerID, PeerID) {
let client = P2pandaTransport::builder_from_seed(viewer().master_keypair().to_seed())
.alpns(vec![projection_alpn()])
.bind()
.await
.expect("bind projection client");
let server = P2pandaTransport::builder_from_seed(owner().master_keypair().to_seed())
.alpns(vec![projection_alpn()])
.bind()
.await
.expect("bind projection server");
let client_peer = client.local_peer_id();
let server_peer = server.local_peer_id();
let client_addr =
tokio::time::timeout(std::time::Duration::from_secs(10), client.endpoint_addr())
.await
.expect("client endpoint address timeout")
.expect("client endpoint address");
let server_addr =
tokio::time::timeout(std::time::Duration::from_secs(10), server.endpoint_addr())
.await
.expect("server endpoint address timeout")
.expect("server endpoint address");
client
.add_peer(server_addr)
.await
.expect("client registers server");
server
.add_peer(client_addr)
.await
.expect("server registers client");
(server, client, server_peer, client_peer)
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn p2panda_admits_the_authenticated_viewer_before_application_bytes() {
let _receipt_guard = p2panda_receipt_lock().lock().await;
const APPLICATION_BYTES: &[u8] = b"graphshell-session-open-may-start";
let viewer = viewer();
let subject = viewer.master_public_key().to_bytes();
let (server, client, server_peer, client_peer) = p2panda_pair().await;
let (server_finished_tx, server_finished_rx) = tokio::sync::oneshot::channel();
assert_eq!(
client_peer.to_bytes(),
subject,
"the carrier identity is the Personae subject that signs the hello"
);
let client_task = tokio::spawn(async move {
let alpn = projection_alpn();
let mut stream = tokio::time::timeout(
std::time::Duration::from_secs(10),
client.connect(server_peer, alpn.clone()),
)
.await
.expect("projection dial timeout")
.expect("projection dial");
let binding = initiator_binding(&alpn, client_peer);
let hello = open_session(
&viewer,
NETWORK,
profile_ref(),
TrafficClass::Interactive,
[5; 32],
&binding,
vec![grant(subject, CONNECT_ACTION)],
)
.expect("issue projection hello");
let reply = tokio::time::timeout(
std::time::Duration::from_secs(10),
initiate_session(&mut stream, &hello, &policy().limits.clamped()),
)
.await
.expect("projection handshake timeout")
.expect("projection handshake");
assert!(reply.is_accept(), "the initiator sees admission");
let mut application = vec![0; APPLICATION_BYTES.len()];
tokio::time::timeout(
std::time::Duration::from_secs(10),
stream.read_exact(&mut application),
)
.await
.expect("application read timeout")
.expect("application read");
let _ = server_finished_rx.await;
application
});
let mut session = tokio::time::timeout(
std::time::Duration::from_secs(10),
accept_projection_session(&server, &policy(), &RevocationLedger::default(), NOW_MS, 0),
)
.await
.expect("projection accept timeout")
.expect("projection accept path")
.expect("projection session admitted");
assert_eq!(session.principal.subject, subject);
assert_eq!(session.facts.transport, CarrierKind::P2panda);
assert_eq!(
session.facts.authenticated_initiator,
Some(client_peer.to_bytes()),
"the admitted session retains the peer p2panda authenticated"
);
let retained = crate::lifecycle::SessionAuthority::retain_admitted(&session);
assert_eq!(
retained.deadline_ms(),
Some(100),
"the carrier also retains the verified chain needed for later revocation checks"
);
session
.stream
.write_all(APPLICATION_BYTES)
.await
.expect("application write");
session.stream.shutdown().await.expect("finish application");
let _ = server_finished_tx.send(());
let principal = session.principal;
let application = tokio::time::timeout(std::time::Duration::from_secs(10), client_task)
.await
.expect("projection client timeout")
.expect("projection client task");
assert_eq!(application, APPLICATION_BYTES);
assert_eq!(principal.action, connect_action());
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn p2panda_murm_grant_is_refused_before_projection_bytes() {
let _receipt_guard = p2panda_receipt_lock().lock().await;
let viewer = viewer();
let subject = viewer.master_public_key().to_bytes();
let (server, client, server_peer, client_peer) = p2panda_pair().await;
let client_task = tokio::spawn(async move {
let alpn = projection_alpn();
let mut stream = tokio::time::timeout(
std::time::Duration::from_secs(10),
client.connect(server_peer, alpn.clone()),
)
.await
.expect("projection dial timeout")
.expect("projection dial");
let binding = initiator_binding(&alpn, client_peer);
let hello = open_session(
&viewer,
NETWORK,
profile_ref(),
TrafficClass::Interactive,
[5; 32],
&binding,
vec![grant_for(
subject,
"mere.network",
"/services/murm",
CONNECT_ACTION,
)],
)
.expect("issue projection hello with foreign grant");
let reply = tokio::time::timeout(
std::time::Duration::from_secs(10),
initiate_session(&mut stream, &hello, &policy().limits.clamped()),
)
.await
.expect("projection refusal timeout")
.expect("projection refusal");
assert!(!reply.is_accept(), "the initiator sees refusal");
let mut application = Vec::new();
tokio::time::timeout(
std::time::Duration::from_secs(10),
stream.read_to_end(&mut application),
)
.await
.expect("refused stream close timeout")
.expect("read refused stream");
application
});
let refusal = tokio::time::timeout(
std::time::Duration::from_secs(10),
accept_projection_session(&server, &policy(), &RevocationLedger::default(), NOW_MS, 0),
)
.await
.expect("projection accept timeout")
.expect("projection accept path")
.expect_err("Murm authority must not open Graphshell");
let application = tokio::time::timeout(std::time::Duration::from_secs(10), client_task)
.await
.expect("projection client timeout")
.expect("projection client task");
assert!(
application.is_empty(),
"a refusal exposes no application bytes"
);
assert_eq!(
refusal,
ProjectionRefusal::NotAdmitted(DenyReason::ActionNotCovered)
);
}
}