use base64::Engine as _;
use clap::{Args, Subcommand};
use boatramp_core::cose::{
OverlayMemberRole, TokenPublicKey, join_challenge, mint_overlay_dial, mint_overlay_enroll,
mint_overlay_publish, verify_overlay_enroll,
};
use boatramp_overlay::{OverlayId, OverlaySecret, SasCode};
use crate::output::Rendered;
type Result<T> = std::result::Result<T, Error>;
#[derive(Debug, thiserror::Error)]
pub enum Error {
#[error("overlay: {0}")]
Overlay(String),
#[error("overlay signer: {0}")]
Signer(String),
}
const TICKET_MAGIC: &str = "brfwd1";
const EXPOSE_TICKET_MAGIC: &str = "brexp1";
const ENROLL_TICKET_MAGIC: &str = "brojoin1";
#[derive(Args, Debug)]
pub struct OverlayArgs {
#[command(subcommand)]
command: OverlayCommand,
}
#[derive(Subcommand, Debug)]
enum OverlayCommand {
Grant {
service: String,
#[arg(long)]
relay: String,
#[arg(long)]
relay_id: String,
#[arg(long)]
egress_id: String,
#[arg(long)]
holder_pub: Option<String>,
#[arg(long, default_value_t = 3600)]
ttl_secs: u64,
#[arg(long, default_value = "boatramp.cfg")]
config: std::path::PathBuf,
},
GrantExpose {
service: String,
#[arg(long)]
relay: String,
#[arg(long)]
relay_id: String,
#[arg(long)]
holder_pub: Option<String>,
#[arg(long, default_value_t = 3600)]
ttl_secs: u64,
#[arg(long, default_value = "boatramp.cfg")]
config: std::path::PathBuf,
},
Invite {
#[arg(long, value_parser = ["node", "device", "workload"])]
role: String,
#[arg(long)]
scope: String,
#[arg(long, default_value_t = 3600)]
ttl_secs: u64,
#[arg(long)]
relay: Option<String>,
#[arg(long, default_value = "boatramp.cfg")]
config: std::path::PathBuf,
},
Join {
token: String,
#[arg(long)]
identity: Option<String>,
#[arg(long, env = "BOATRAMP_SERVER")]
server: Option<String>,
},
Id,
Ls {
#[arg(long, env = "BOATRAMP_SERVER")]
server: Option<String>,
},
Resolve {
name: String,
#[arg(long, env = "BOATRAMP_SERVER")]
server: Option<String>,
},
Pair {
#[arg(long, value_name = "ADDR", conflicts_with = "peer")]
listen: Option<String>,
#[arg(long, value_name = "CODE", conflicts_with_all = ["listen", "peer"])]
accept: Option<String>,
#[arg(long, value_name = "CAP", requires = "accept")]
park_cap: Option<String>,
#[arg(long, value_name = "CODE", conflicts_with_all = ["listen", "accept", "peer"])]
short: Option<String>,
#[arg(value_name = "PEER_OR_CODE", conflicts_with_all = ["listen", "short"])]
peer: Option<String>,
#[arg(long)]
identity: Option<String>,
#[arg(long, value_name = "SAS_WORDS")]
expect: Option<String>,
#[arg(long, default_value_t = 30)]
timeout_secs: u64,
#[arg(long, env = "BOATRAMP_SERVER")]
server: Option<String>,
},
PairCode {
#[arg(long, default_value_t = 300)]
ttl_secs: u64,
#[arg(long)]
short: bool,
#[arg(long, value_name = "PUBKEY")]
accepter_pubkey: Option<String>,
#[arg(long, env = "BOATRAMP_SERVER")]
server: Option<String>,
},
Policy {
#[command(subcommand)]
cmd: PolicyCommand,
},
}
#[derive(Subcommand, Debug)]
enum PolicyCommand {
Ls {
#[arg(long, env = "BOATRAMP_SERVER")]
server: Option<String>,
},
Set {
file: String,
#[arg(long)]
if_match: Option<String>,
#[arg(long, env = "BOATRAMP_SERVER")]
server: Option<String>,
},
Test {
#[arg(long, value_parser = ["dial", "expose"])]
action: String,
#[arg(long)]
service: String,
#[arg(long)]
holder: Option<String>,
#[arg(long)]
role: Option<String>,
#[arg(long)]
scope: Option<String>,
#[arg(long)]
enrolled: bool,
#[arg(long, default_value_t = 3600)]
ttl_secs: u64,
#[arg(long, env = "BOATRAMP_SERVER")]
server: Option<String>,
},
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct ForwardTicket {
pub relay: String,
pub relay_id: String,
pub egress_id: String,
pub service: String,
pub token: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub identity: Option<String>,
}
impl ForwardTicket {
pub fn encode(&self) -> Result<String> {
let json = serde_json::to_vec(self).map_err(|e| Error::Overlay(e.to_string()))?;
let b64 = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(json);
Ok(format!("{TICKET_MAGIC}.{b64}"))
}
pub fn decode(blob: &str) -> Result<Self> {
let b64 = blob
.trim()
.strip_prefix(TICKET_MAGIC)
.and_then(|r| r.strip_prefix('.'))
.ok_or_else(|| {
Error::Overlay(format!(
"not a forward ticket (expected `{TICKET_MAGIC}.…`)"
))
})?;
let json = base64::engine::general_purpose::URL_SAFE_NO_PAD
.decode(b64)
.map_err(|e| Error::Overlay(format!("malformed forward ticket: {e}")))?;
let t: Self = serde_json::from_slice(&json)
.map_err(|e| Error::Overlay(format!("malformed forward ticket: {e}")))?;
if t.relay.is_empty()
|| t.relay_id.is_empty()
|| t.egress_id.is_empty()
|| t.service.is_empty()
|| t.token.is_empty()
{
return Err(Error::Overlay(
"forward ticket is missing a required field".into(),
));
}
Ok(t)
}
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct ExposeTicket {
pub relay: String,
pub relay_id: String,
pub service: String,
pub token: String,
pub trust_root: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub identity: Option<String>,
}
impl ExposeTicket {
pub fn encode(&self) -> Result<String> {
let json = serde_json::to_vec(self).map_err(|e| Error::Overlay(e.to_string()))?;
let b64 = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(json);
Ok(format!("{EXPOSE_TICKET_MAGIC}.{b64}"))
}
pub fn decode(blob: &str) -> Result<Self> {
let b64 = blob
.trim()
.strip_prefix(EXPOSE_TICKET_MAGIC)
.and_then(|r| r.strip_prefix('.'))
.ok_or_else(|| {
Error::Overlay(format!(
"not an expose ticket (expected `{EXPOSE_TICKET_MAGIC}.…`)"
))
})?;
let json = base64::engine::general_purpose::URL_SAFE_NO_PAD
.decode(b64)
.map_err(|e| Error::Overlay(format!("malformed expose ticket: {e}")))?;
let t: Self = serde_json::from_slice(&json)
.map_err(|e| Error::Overlay(format!("malformed expose ticket: {e}")))?;
if t.relay.is_empty()
|| t.relay_id.is_empty()
|| t.service.is_empty()
|| t.token.is_empty()
|| t.trust_root.is_empty()
{
return Err(Error::Overlay(
"expose ticket is missing a required field".into(),
));
}
Ok(t)
}
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct EnrollTicket {
pub token: String,
pub trust_root: String,
pub role: String,
pub scope: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub relay: Option<String>,
}
impl EnrollTicket {
pub fn encode(&self) -> Result<String> {
let json = serde_json::to_vec(self).map_err(|e| Error::Overlay(e.to_string()))?;
let b64 = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(json);
Ok(format!("{ENROLL_TICKET_MAGIC}.{b64}"))
}
pub fn decode(blob: &str) -> Result<Self> {
let b64 = blob
.trim()
.strip_prefix(ENROLL_TICKET_MAGIC)
.and_then(|r| r.strip_prefix('.'))
.ok_or_else(|| {
Error::Overlay(format!(
"not an enrollment ticket (expected `{ENROLL_TICKET_MAGIC}.…`)"
))
})?;
let json = base64::engine::general_purpose::URL_SAFE_NO_PAD
.decode(b64)
.map_err(|e| Error::Overlay(format!("malformed enrollment ticket: {e}")))?;
let t: Self = serde_json::from_slice(&json)
.map_err(|e| Error::Overlay(format!("malformed enrollment ticket: {e}")))?;
if t.token.is_empty() || t.trust_root.is_empty() || t.scope.is_empty() {
return Err(Error::Overlay(
"enrollment ticket is missing a required field".into(),
));
}
Ok(t)
}
}
#[derive(serde::Serialize)]
pub enum OverlayOutput {
Granted {
ticket: String,
client_id: String,
enroll: String,
secret_ticket: bool,
},
ExposeGranted {
ticket: String,
client_id: String,
enroll: String,
secret_ticket: bool,
},
Invited {
ticket: String,
token: String,
role: String,
scope: String,
expires_at: u64,
},
Enrolled {
member: String,
role: String,
scope: String,
expires_at: u64,
},
Identity {
id: String,
bech32: String,
},
Directory { services: Vec<ServiceEntry> },
Resolved(ServiceEntry),
FlowPolicy {
policy: String,
version: String,
},
FlowPolicyStored {
version: String,
warning: Option<String>,
cleared: bool,
},
FlowPolicyTest {
decision: String,
ttl_ceiling: Option<u64>,
deciding_policies: Vec<String>,
diagnostics: Vec<String>,
note: Option<String>,
},
PairCodeMinted {
code: String,
relay: String,
relay_id: String,
expires_at: u64,
short_code: Option<String>,
park_cap: Option<String>,
},
Paired {
peer_id: String,
fingerprint: String,
sas_words: String,
sas_digits: String,
},
}
#[derive(serde::Serialize, serde::Deserialize, Clone)]
pub struct ServiceEntry {
pub service: String,
pub relay: String,
pub relay_id: String,
pub egress_id: String,
#[serde(default)]
pub client_auth: String,
}
impl Rendered for OverlayOutput {
fn render_human(&self, w: &mut dyn std::io::Write) -> std::io::Result<()> {
match self {
Self::Invited {
ticket,
token: _,
role,
scope,
expires_at,
} => {
writeln!(w, "{ticket}")?;
writeln!(w)?;
writeln!(
w,
"# This enrollment ticket embeds a single-use invitation — treat it as a secret \
(a leaked unspent ticket grants enrollment until it expires or is spent)."
)?;
writeln!(w, "# role: {role}")?;
writeln!(w, "# scope: {scope}")?;
writeln!(w, "# expires at (unix): {expires_at}")?;
writeln!(
w,
"# Redeem on the enrollee: boatramp overlay join <ticket> --server <url>"
)
}
Self::Enrolled {
member,
role,
scope,
expires_at,
} => {
writeln!(w, "enrolled for scope {scope} (role {role})")?;
writeln!(w, "# member credential expires at (unix): {expires_at}")?;
writeln!(w, "{member}")
}
Self::Identity { id, bech32 } => {
writeln!(w, "{id}")?;
writeln!(w, "{bech32}")
}
Self::Directory { services } => {
if services.is_empty() {
return writeln!(w, "no overlay services in the directory");
}
for s in services {
writeln!(
w,
"{}\t{}\t{}\t{}",
s.service, s.relay, s.egress_id, s.client_auth
)?;
}
Ok(())
}
Self::Resolved(s) => {
writeln!(w, "service: {}", s.service)?;
writeln!(w, "relay: {} ({})", s.relay, s.relay_id)?;
writeln!(w, "egress id: {}", s.egress_id)?;
if !s.client_auth.is_empty() {
writeln!(w, "client auth: {}", s.client_auth)?;
}
Ok(())
}
Self::FlowPolicy { policy, version } => {
if policy.trim().is_empty() {
writeln!(w, "# no overlay flow policy in effect (the gate is OFF)")?;
} else {
write!(w, "{policy}")?;
if !policy.ends_with('\n') {
writeln!(w)?;
}
}
writeln!(w, "# version: {version}")
}
Self::FlowPolicyStored {
version,
warning,
cleared,
} => {
if *cleared {
writeln!(w, "overlay flow policy cleared (the gate is now OFF)")?;
} else {
writeln!(w, "overlay flow policy stored")?;
}
writeln!(w, "# version: {version}")?;
if let Some(warn) = warning {
writeln!(w, "# WARNING: {warn}")?;
}
Ok(())
}
Self::FlowPolicyTest {
decision,
ttl_ceiling,
deciding_policies,
diagnostics,
note,
} => {
writeln!(w, "decision: {decision}")?;
if let Some(c) = ttl_ceiling {
writeln!(
w,
"ttl ceiling: {c} (the policy would shrink the requested TTL)"
)?;
}
if !deciding_policies.is_empty() {
writeln!(w, "deciding policies: {}", deciding_policies.join(", "))?;
}
for d in diagnostics {
writeln!(w, "# diagnostic: {d}")?;
}
if let Some(n) = note {
writeln!(w, "# {n}")?;
}
Ok(())
}
Self::PairCodeMinted {
code,
relay,
relay_id,
expires_at,
short_code,
park_cap,
} => {
writeln!(w, "{code}")?;
writeln!(w)?;
writeln!(
w,
"# Single-use pairing rendezvous code — share it with the new peer over a private \
channel (a leaked code only lets someone RACE to pair; the SAS still catches it)."
)?;
writeln!(w, "# relay: {relay} ({relay_id})")?;
writeln!(w, "# expires at (unix): {expires_at}")?;
match park_cap {
None => {
writeln!(
w,
"# Park it (operator): boatramp overlay pair --accept {code}"
)?;
}
Some(cap) => {
writeln!(w)?;
writeln!(
w,
"# AUTHENTICATED pairing-park: give the accepter this capability too (a \
secret bound to their key — deliver it over a private channel):"
)?;
writeln!(w, "{cap}")?;
writeln!(w)?;
writeln!(
w,
"# Park it (accepter): boatramp overlay pair --accept {code} --park-cap {cap}"
)?;
}
}
writeln!(w, "# Redeem it (peer): boatramp overlay pair {code}")?;
if let Some(short) = short_code {
writeln!(w)?;
writeln!(
w,
"# Human-typeable short code (an INDEPENDENT alias — low-entropy, rate-limited \
+ lockout-guarded at the relay; the SAS still catches a MITM): {short}"
)?;
writeln!(
w,
"# Redeem it (peer): boatramp overlay pair --short {short}"
)?;
}
Ok(())
}
Self::Paired {
peer_id,
fingerprint,
sas_words,
sas_digits,
} => {
writeln!(w, "paired with {peer_id}")?;
writeln!(w, " fingerprint: {fingerprint}")?;
writeln!(w, " SAS words: {sas_words}")?;
writeln!(w, " SAS digits: {sas_digits}")?;
writeln!(
w,
"# identity verified out-of-band via the SAS and pinned (in-memory this session)"
)
}
Self::Granted {
ticket,
client_id,
enroll,
secret_ticket,
} => {
writeln!(w, "{ticket}")?;
writeln!(w)?;
if *secret_ticket {
writeln!(
w,
"# This ticket embeds the client's private seed — treat it as a secret."
)?;
}
writeln!(
w,
"# Redeem on the client: boatramp forward <local-addr> --ticket <ticket>"
)?;
writeln!(
w,
"# If the relay is mutual-pin, enroll this client id in [overlay] allowed_clients"
)?;
writeln!(
w,
"# and reload; a capability-only relay needs NO enrollment — the ticket works as-is."
)?;
writeln!(w, "# client id: {client_id}")?;
writeln!(w, "# allowed_clients entry (mutual-pin only): {enroll}")
}
Self::ExposeGranted {
ticket,
client_id,
enroll,
secret_ticket,
} => {
writeln!(w, "{ticket}")?;
writeln!(w)?;
if *secret_ticket {
writeln!(
w,
"# This ticket embeds the exposer's private seed — treat it as a secret."
)?;
}
writeln!(
w,
"# Redeem on the exposer: boatramp expose <local-addr> as <service> --ticket <ticket>"
)?;
writeln!(
w,
"# If the relay is mutual-pin, enroll this exposer id in [overlay] allowed_clients"
)?;
writeln!(
w,
"# and reload; a capability-only relay needs NO enrollment — the ticket works as-is."
)?;
writeln!(w, "# exposer id: {client_id}")?;
writeln!(w, "# allowed_clients entry (mutual-pin only): {enroll}")
}
}
}
}
fn parse_id(s: &str) -> Result<OverlayId> {
if let Ok(id) = OverlayId::from_bech32(s) {
return Ok(id);
}
OverlayId::from_hex(s).map_err(|e| Error::Overlay(format!("overlay identity `{s}`: {e}")))
}
pub async fn run(
args: OverlayArgs,
config: &crate::config::ProjectConfig,
) -> Result<OverlayOutput> {
match args.command {
OverlayCommand::Id => {
let secret = operator_identity()?;
let id = secret.id();
Ok(OverlayOutput::Identity {
id: id.to_hex(),
bech32: id.to_bech32(),
})
}
OverlayCommand::Ls { server } => {
let services = fetch_directory(server, config).await?;
Ok(OverlayOutput::Directory { services })
}
OverlayCommand::Resolve { name, server } => {
let services = fetch_directory(server, config).await?;
services
.into_iter()
.find(|s| s.service == name)
.map(OverlayOutput::Resolved)
.ok_or_else(|| {
Error::Overlay(format!(
"no overlay service `{name}` in the directory — run `boatramp overlay ls` to \
see available services"
))
})
}
OverlayCommand::Pair {
listen,
accept,
park_cap,
short,
peer,
identity,
expect,
timeout_secs,
server,
} => {
pair_run(
listen,
accept,
park_cap,
short,
peer,
identity,
expect,
timeout_secs,
server,
config,
)
.await
}
OverlayCommand::PairCode {
ttl_secs,
short,
accepter_pubkey,
server,
} => pair_code_run(ttl_secs, short, accepter_pubkey, server, config).await,
OverlayCommand::Policy { cmd } => policy_run(cmd, config).await,
OverlayCommand::Invite {
role,
scope,
ttl_secs,
relay,
config,
} => {
let Some(role_enum) = OverlayMemberRole::parse(&role) else {
return Err(Error::Overlay(format!(
"role `{role}` must be node|device|workload"
)));
};
if let Some(r) = &relay {
let _ = parse_id(r)?; }
let (token, trust_root, expires_at) =
mint_enroll(role_enum, &scope, ttl_secs, &config).await?;
let ticket = EnrollTicket {
token: token.clone(),
trust_root,
role: role.clone(),
scope: scope.clone(),
relay,
}
.encode()?;
Ok(OverlayOutput::Invited {
ticket,
token,
role,
scope,
expires_at,
})
}
OverlayCommand::Join {
token,
identity,
server,
} => {
let ticket = EnrollTicket::decode(&token)?;
let trust_root = TokenPublicKey::from_hex(&ticket.trust_root)
.map_err(|e| Error::Overlay(format!("enrollment ticket trust_root: {e}")))?;
let now = now_secs();
let verified = verify_overlay_enroll(&ticket.token, &trust_root, now).map_err(|e| {
Error::Overlay(format!("enrollment invitation did not verify: {e}"))
})?;
let secret = load_identity(identity.as_deref())?;
let overlay_pubkey = secret.id().to_hex();
let rpk = secret
.rpk_identity()
.map_err(|e| Error::Overlay(e.to_string()))?;
let proof_iat = now;
let challenge = join_challenge(&verified.jti, &overlay_pubkey, proof_iat);
let proof = rpk
.sign(&challenge)
.map_err(|e| Error::Overlay(format!("building possession proof: {e}")))?;
let body = redeem_enroll(
server,
config,
&ticket.token,
&overlay_pubkey,
&hex::encode(proof),
proof_iat,
)
.await?;
Ok(OverlayOutput::Enrolled {
member: body.member,
role: body.role,
scope: body.scope,
expires_at: body.expires_at,
})
}
OverlayCommand::Grant {
service,
relay,
relay_id,
egress_id,
holder_pub,
ttl_secs,
config,
} => {
let _ = parse_id(&relay_id)?;
let _ = parse_id(&egress_id)?;
let (holder, client_id, identity) = match &holder_pub {
Some(pub_spec) => {
let id = parse_id(pub_spec)?;
(id.to_public(), id.to_hex(), None)
}
None => {
let secret =
OverlaySecret::generate().map_err(|e| Error::Overlay(e.to_string()))?;
let id = secret.id();
(
id.to_public(),
id.to_hex(),
Some(secret.seed_hex().to_string()),
)
}
};
let token = mint_dial(&holder, &service, ttl_secs, &config).await?;
let secret_ticket = identity.is_some();
let ticket = ForwardTicket {
relay,
relay_id,
egress_id,
service,
token,
identity,
}
.encode()?;
let enroll = format!("\"{client_id}\"");
Ok(OverlayOutput::Granted {
ticket,
client_id,
enroll,
secret_ticket,
})
}
OverlayCommand::GrantExpose {
service,
relay,
relay_id,
holder_pub,
ttl_secs,
config,
} => {
let _ = parse_id(&relay_id)?;
let (holder, client_id, identity) = match &holder_pub {
Some(pub_spec) => {
let id = parse_id(pub_spec)?;
(id.to_public(), id.to_hex(), None)
}
None => {
let secret =
OverlaySecret::generate().map_err(|e| Error::Overlay(e.to_string()))?;
let id = secret.id();
(
id.to_public(),
id.to_hex(),
Some(secret.seed_hex().to_string()),
)
}
};
let (token, trust_root) = mint_publish(&holder, &service, ttl_secs, &config).await?;
let secret_ticket = identity.is_some();
let ticket = ExposeTicket {
relay,
relay_id,
service,
token,
trust_root,
identity,
}
.encode()?;
let enroll = format!("\"{client_id}\"");
Ok(OverlayOutput::ExposeGranted {
ticket,
client_id,
enroll,
secret_ticket,
})
}
}
}
#[derive(Debug, PartialEq, Eq)]
enum ConfirmOutcome {
Confirm,
Reject,
}
fn decide_sas(sas: &SasCode, typed: &str) -> ConfirmOutcome {
match SasCode::from_words(typed) {
Some(got) if got == *sas => ConfirmOutcome::Confirm,
_ => ConfirmOutcome::Reject,
}
}
#[allow(clippy::too_many_arguments)]
async fn pair_run(
listen: Option<String>,
accept_code: Option<String>,
park_cap: Option<String>,
short_code: Option<String>,
peer: Option<String>,
identity: Option<String>,
expect: Option<String>,
timeout_secs: u64,
server: Option<String>,
config: &crate::config::ProjectConfig,
) -> Result<OverlayOutput> {
use boatramp_overlay::{
OverlayPeerPolicy, PairCode, Role, ShortCode, VerifiedPeers, accept,
client_endpoint_server_auth, dial, identity_fp, pair_dial_endpoint, pair_relay_park,
pair_relay_park_authenticated, pair_relay_redeem, pair_relay_redeem_short, server_endpoint,
};
use boatramp_overlay_client::OverlayClient;
let secret = load_identity(identity.as_deref())?;
let client = OverlayClient::builder(
boatramp_overlay::OverlaySecret::from_seed_hex(secret.seed_hex().as_str())
.map_err(|e| Error::Overlay(e.to_string()))?,
)
.build()
.map_err(|e| Error::Overlay(e.to_string()))?;
let deadline = std::time::Duration::from_secs(timeout_secs);
let q = |e: boatramp_overlay::QuicError| Error::Overlay(e.to_string());
let decode_code = |raw: &str| -> Result<(std::net::SocketAddr, PairCode)> {
let code =
PairCode::decode(raw).map_err(|e| Error::Overlay(format!("pairing code: {e}")))?;
let addr = code
.relay_addr
.parse::<std::net::SocketAddr>()
.map_err(|e| {
Error::Overlay(format!(
"pairing code relay address `{}`: {e}",
code.relay_addr
))
})?;
Ok((addr, code))
};
let _hold;
let (sas, peer_id) = match (accept_code, listen, peer, short_code) {
(Some(code_s), _, _, _) => {
let (relay_addr, code) = decode_code(&code_s)?;
let ep =
client_endpoint_server_auth(&OverlayPeerPolicy::Pin(code.relay_id)).map_err(q)?;
eprintln!(
"boatramp overlay pair: parked at relay {} as {} — waiting for the peer to redeem the code",
code.relay_addr,
secret.id().to_bech32()
);
let (conn, mut send, mut recv) = match park_cap {
Some(cap) => pair_relay_park_authenticated(
&ep,
relay_addr,
code.relay_id,
code.rendezvous,
&secret,
cap,
)
.await
.map_err(|e| {
Error::Overlay(format!("relay rendezvous (authenticated park): {e}"))
})?,
None => pair_relay_park(&ep, relay_addr, code.rendezvous)
.await
.map_err(|e| Error::Overlay(format!("relay rendezvous (park): {e}")))?,
};
let out = client
.pair(&mut send, &mut recv, Role::Responder, deadline)
.await
.map_err(|e| Error::Overlay(format!("pairing handshake: {e}")))?;
_hold = (ep, conn);
out
}
(None, None, Some(ref p), None)
if p.trim().to_ascii_lowercase().starts_with("bropair1") =>
{
let (relay_addr, code) = decode_code(p)?;
let ep =
client_endpoint_server_auth(&OverlayPeerPolicy::Pin(code.relay_id)).map_err(q)?;
let (conn, mut send, mut recv) = pair_relay_redeem(&ep, relay_addr, code.rendezvous)
.await
.map_err(|e| Error::Overlay(format!("relay rendezvous (redeem): {e}")))?;
let out = client
.pair(&mut send, &mut recv, Role::Initiator, deadline)
.await
.map_err(|e| Error::Overlay(format!("pairing handshake: {e}")))?;
_hold = (ep, conn);
out
}
(None, None, None, Some(code_s)) => {
let digits = ShortCode::normalize(&code_s)
.map_err(|e| Error::Overlay(format!("pairing short code: {e}")))?;
let (relay_addr, relay_id) = fetch_pair_relay(server.clone(), config).await?;
let ep = client_endpoint_server_auth(&OverlayPeerPolicy::Pin(relay_id)).map_err(q)?;
let (conn, mut send, mut recv) = pair_relay_redeem_short(&ep, relay_addr, digits)
.await
.map_err(|e| Error::Overlay(format!("relay rendezvous (short redeem): {e}")))?;
let out = client
.pair(&mut send, &mut recv, Role::Initiator, deadline)
.await
.map_err(|e| Error::Overlay(format!("pairing handshake: {e}")))?;
_hold = (ep, conn);
out
}
(None, Some(bind), _, None) => {
let addr = bind
.parse::<std::net::SocketAddr>()
.map_err(|e| Error::Overlay(format!("--listen `{bind}`: {e}")))?;
let ep = server_endpoint(&secret, &OverlayPeerPolicy::AcceptAny, addr).map_err(q)?;
eprintln!(
"boatramp overlay pair: listening on {} as {}",
ep.local_addr().map_err(|e| Error::Overlay(e.to_string()))?,
secret.id().to_bech32()
);
let conn = accept(&ep)
.await
.map_err(q)?
.ok_or_else(|| Error::Overlay("listener closed before a peer dialed".into()))?;
let (mut send, mut recv) = conn
.accept_bi()
.await
.map_err(|e| Error::Overlay(format!("accepting the pairing stream: {e}")))?;
let out = client
.pair(&mut send, &mut recv, Role::Responder, deadline)
.await
.map_err(|e| Error::Overlay(format!("pairing handshake: {e}")))?;
_hold = (ep, conn);
out
}
(None, None, Some(addr_s), None) => {
let addr = addr_s
.parse::<std::net::SocketAddr>()
.map_err(|e| Error::Overlay(format!("peer address `{addr_s}`: {e}")))?;
let ep = pair_dial_endpoint().map_err(q)?;
let conn = dial(&ep, addr)
.await
.map_err(|e| Error::Overlay(format!("dialing {addr}: {e}")))?;
let (mut send, mut recv) = conn
.open_bi()
.await
.map_err(|e| Error::Overlay(format!("opening the pairing stream: {e}")))?;
let out = client
.pair(&mut send, &mut recv, Role::Initiator, deadline)
.await
.map_err(|e| Error::Overlay(format!("pairing handshake: {e}")))?;
_hold = (ep, conn);
out
}
(None, None, None, None) => {
return Err(Error::Overlay(
"specify a transport: `--listen <addr>` or a peer `<addr>` (direct), \
`--accept <bropair1…>` or a `<bropair1…>` code (relay rendezvous), or \
`--short <code>` (short-code relay rendezvous)"
.into(),
));
}
_ => {
return Err(Error::Overlay(
"`--short` cannot be combined with `--listen` or a peer address".into(),
));
}
};
let fp = identity_fp(&peer_id);
match confirm_sas(&sas, &peer_id, &fp, expect.as_deref())? {
ConfirmOutcome::Confirm => {
let mut pins = VerifiedPeers::new();
let vp = pins.confirm(peer_id);
Ok(OverlayOutput::Paired {
peer_id: peer_id.to_hex(),
fingerprint: vp.fingerprint.to_string(),
sas_words: sas.words(),
sas_digits: sas.digits(),
})
}
ConfirmOutcome::Reject => {
eprintln!("DO NOT CONFIRM — safety numbers differ; a man-in-the-middle may be present");
tracing::warn!(
target: "overlay.pair",
peer = %peer_id.to_bech32(),
"overlay pairing REJECTED: SAS did not match — identity NOT pinned"
);
Err(Error::Overlay(
"pairing aborted: the safety numbers did not match — the peer was NOT pinned"
.into(),
))
}
}
}
#[derive(serde::Serialize)]
struct PairCodeRequestBody {
ttl_secs: u64,
#[serde(default)]
short: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
accepter_pubkey: Option<String>,
}
#[derive(serde::Deserialize)]
struct PairCodeResponseBody {
relay: String,
relay_id: String,
rendezvous: String,
expires_at: u64,
#[serde(default)]
short_code: Option<String>,
#[serde(default)]
park_cap: Option<String>,
}
async fn pair_code_run(
ttl_secs: u64,
short: bool,
accepter_pubkey: Option<String>,
server: Option<String>,
config: &crate::config::ProjectConfig,
) -> Result<OverlayOutput> {
use boatramp_overlay::PairCode;
let (server, http) =
crate::client::connect(server, config).map_err(|e| Error::Overlay(e.to_string()))?;
let resp = http
.post(format!("{server}/api/overlay/pair-code"))
.json(&PairCodeRequestBody {
ttl_secs,
short,
accepter_pubkey,
})
.send()
.await
.map_err(|e| Error::Overlay(e.to_string()))?;
let status = resp.status();
if !status.is_success() {
let body = resp.text().await.unwrap_or_default();
return Err(Error::Overlay(format!(
"overlay pair-code refused ({}): {}",
status.as_u16(),
body.trim()
)));
}
let body: PairCodeResponseBody = resp
.json()
.await
.map_err(|e| Error::Overlay(e.to_string()))?;
let rendezvous = hex::decode(body.rendezvous.trim())
.map_err(|e| Error::Overlay(format!("pair-code rendezvous id: {e}")))?;
let relay_id = parse_id(&body.relay_id)?;
let code = PairCode::new(rendezvous, body.relay.clone(), relay_id, body.expires_at)
.map_err(|e| Error::Overlay(format!("assembling pairing code: {e}")))?
.encode()
.map_err(|e| Error::Overlay(format!("encoding pairing code: {e}")))?;
Ok(OverlayOutput::PairCodeMinted {
code,
relay: body.relay,
relay_id: body.relay_id,
expires_at: body.expires_at,
short_code: body.short_code,
park_cap: body.park_cap,
})
}
#[derive(serde::Deserialize)]
struct PairRelayResponseBody {
relay: String,
relay_id: String,
}
async fn fetch_pair_relay(
server: Option<String>,
config: &crate::config::ProjectConfig,
) -> Result<(std::net::SocketAddr, OverlayId)> {
let server =
crate::client::resolve_server(server, config).map_err(|e| Error::Overlay(e.to_string()))?;
let http = crate::client::http_client(Some(""));
let resp = http
.get(format!("{server}/api/overlay/pair-relay"))
.send()
.await
.map_err(|e| Error::Overlay(e.to_string()))?;
let status = resp.status();
if !status.is_success() {
let body = resp.text().await.unwrap_or_default();
return Err(Error::Overlay(format!(
"overlay pair-relay lookup refused ({}): {}",
status.as_u16(),
body.trim()
)));
}
let body: PairRelayResponseBody = resp
.json()
.await
.map_err(|e| Error::Overlay(e.to_string()))?;
let addr = body
.relay
.parse::<std::net::SocketAddr>()
.map_err(|e| Error::Overlay(format!("pair-relay address `{}`: {e}", body.relay)))?;
let relay_id = parse_id(&body.relay_id)?;
Ok((addr, relay_id))
}
fn confirm_sas(
sas: &SasCode,
peer_id: &OverlayId,
fp: &boatramp_overlay::Fingerprint,
expect: Option<&str>,
) -> Result<ConfirmOutcome> {
if let Some(words) = expect {
return Ok(decide_sas(sas, words));
}
use std::io::IsTerminal;
if !std::io::stdin().is_terminal() {
return Err(Error::Overlay(
"pairing needs confirmation: pass `--expect <sas-words>` (the SAS shown on the peer \
device) for non-interactive use — stdin is not a terminal"
.into(),
));
}
use std::io::Write as _;
let mut err = std::io::stderr();
writeln!(err).ok();
writeln!(err, "Pairing with overlay peer:").ok();
writeln!(err, " identity: {}", peer_id.to_hex()).ok();
writeln!(err, " fingerprint: {fp}").ok();
writeln!(err, " SAS words: {}", sas.words()).ok();
writeln!(err, " SAS digits: {}", sas.digits()).ok();
writeln!(err).ok();
writeln!(
err,
"Compare the SAS with the peer's device — it MUST match on BOTH sides."
)
.ok();
write!(
err,
"To confirm, type the SAS words shown on the PEER device (anything else aborts): "
)
.ok();
err.flush().ok();
let mut line = String::new();
std::io::stdin()
.read_line(&mut line)
.map_err(|e| Error::Overlay(format!("reading confirmation: {e}")))?;
Ok(decide_sas(sas, line.trim()))
}
async fn mint_dial(
holder: &TokenPublicKey,
service: &str,
ttl_secs: u64,
config: &std::path::Path,
) -> Result<String> {
let signer = crate::token::resolve_signer(config)
.await
.map_err(|e| Error::Signer(e.to_string()))?;
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
mint_overlay_dial(holder, service, ttl_secs, now, &*signer)
.await
.map_err(|e| Error::Overlay(format!("minting dial capability: {e}")))
}
async fn mint_publish(
holder: &TokenPublicKey,
service: &str,
ttl_secs: u64,
config: &std::path::Path,
) -> Result<(String, String)> {
let signer = crate::token::resolve_signer(config)
.await
.map_err(|e| Error::Signer(e.to_string()))?;
let trust_root = signer.public_key().to_hex();
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
let token = mint_overlay_publish(holder, service, ttl_secs, now, &*signer)
.await
.map_err(|e| Error::Overlay(format!("minting publish capability: {e}")))?;
Ok((token, trust_root))
}
fn now_secs() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
}
async fn mint_enroll(
role: OverlayMemberRole,
scope: &str,
ttl_secs: u64,
config: &std::path::Path,
) -> Result<(String, String, u64)> {
let signer = crate::token::resolve_signer(config)
.await
.map_err(|e| Error::Signer(e.to_string()))?;
let trust_root = signer.public_key().to_hex();
let now = now_secs();
let token = mint_overlay_enroll(role, scope, ttl_secs, now, &*signer)
.await
.map_err(|e| Error::Overlay(format!("minting enrollment invitation: {e}")))?;
Ok((token, trust_root, now.saturating_add(ttl_secs)))
}
fn load_identity(spec: Option<&str>) -> Result<OverlaySecret> {
match spec {
None => operator_identity(),
Some(s) => {
let s = s.trim();
if let Ok(secret) = OverlaySecret::from_seed_hex(s) {
return Ok(secret);
}
let seed =
std::fs::read_to_string(s).map_err(|e| Error::Overlay(format!("{s}: {e}")))?;
OverlaySecret::from_seed_hex(seed.trim())
.map_err(|e| Error::Overlay(format!("{s}: {e}")))
}
}
}
#[derive(serde::Serialize)]
struct EnrollRequestBody {
token: String,
overlay_pubkey: String,
possession_proof: String,
proof_iat: u64,
}
#[derive(serde::Deserialize)]
struct EnrollResponseBody {
member: String,
#[serde(default)]
role: String,
#[serde(default)]
scope: String,
#[serde(default)]
expires_at: u64,
}
async fn redeem_enroll(
server: Option<String>,
config: &crate::config::ProjectConfig,
token: &str,
overlay_pubkey: &str,
possession_proof: &str,
proof_iat: u64,
) -> Result<EnrollResponseBody> {
let server =
crate::client::resolve_server(server, config).map_err(|e| Error::Overlay(e.to_string()))?;
let http = crate::client::http_client(Some(token));
let resp = http
.post(format!("{server}/api/overlay/enroll"))
.json(&EnrollRequestBody {
token: token.to_string(),
overlay_pubkey: overlay_pubkey.to_string(),
possession_proof: possession_proof.to_string(),
proof_iat,
})
.send()
.await
.map_err(|e| Error::Overlay(e.to_string()))?;
let status = resp.status();
if !status.is_success() {
let body = resp.text().await.unwrap_or_default();
return Err(Error::Overlay(format!(
"enroll refused ({}): {}",
status.as_u16(),
body.trim()
)));
}
resp.json().await.map_err(|e| Error::Overlay(e.to_string()))
}
fn operator_key_path() -> Result<std::path::PathBuf> {
let base = std::env::var_os("XDG_CONFIG_HOME")
.map(std::path::PathBuf::from)
.or_else(|| std::env::var_os("HOME").map(|h| std::path::PathBuf::from(h).join(".config")))
.ok_or_else(|| {
Error::Overlay("cannot locate the config dir (set HOME or XDG_CONFIG_HOME)".into())
})?;
Ok(base.join("boatramp").join("overlay.key"))
}
pub(crate) fn operator_identity() -> Result<OverlaySecret> {
let path = operator_key_path()?;
let io = |e: std::io::Error| Error::Overlay(format!("{}: {e}", path.display()));
match std::fs::read_to_string(&path) {
Ok(seed) => {
OverlaySecret::from_seed_hex(seed.trim()).map_err(|e| Error::Overlay(e.to_string()))
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
let secret = OverlaySecret::generate().map_err(|e| Error::Overlay(e.to_string()))?;
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).map_err(io)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let _ =
std::fs::set_permissions(parent, std::fs::Permissions::from_mode(0o700));
}
}
#[cfg(unix)]
{
use std::io::Write as _;
use std::os::unix::fs::OpenOptionsExt;
let mut f = std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.mode(0o600)
.open(&path)
.map_err(io)?;
f.write_all(secret.seed_hex().as_bytes()).map_err(io)?;
}
#[cfg(not(unix))]
{
std::fs::write(&path, secret.seed_hex().as_bytes()).map_err(io)?;
}
eprintln!(
"boatramp overlay: generated a persistent overlay identity at {}",
path.display()
);
Ok(secret)
}
Err(e) => Err(io(e)),
}
}
#[derive(serde::Serialize)]
pub(crate) struct DialRequestBody {
pub service: String,
pub holder_pubkey: String,
}
#[derive(serde::Deserialize)]
pub(crate) struct DialResponseBody {
pub relay: String,
pub relay_id: String,
pub egress_id: String,
#[serde(default)]
pub relay_client_auth: String,
pub token: String,
}
#[derive(serde::Serialize)]
pub(crate) struct ExposeRequestBody {
pub service: String,
pub holder_pubkey: String,
}
#[derive(serde::Deserialize)]
pub(crate) struct ExposeResponseBody {
pub relay: String,
pub relay_id: String,
pub service: String,
pub token: String,
pub trust_root: String,
#[serde(default)]
pub relay_client_auth: String,
}
#[derive(serde::Serialize)]
struct FlowPolicyTestRequest {
action: String,
service: String,
holder: String,
#[serde(skip_serializing_if = "Option::is_none")]
role: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
scope: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
enrolled: Option<bool>,
ttl_requested: u64,
}
#[derive(serde::Deserialize)]
struct FlowPolicyTestResponse {
decision: String,
ttl_ceiling: Option<u64>,
#[serde(default)]
deciding_policies: Vec<String>,
#[serde(default)]
diagnostics: Vec<String>,
#[serde(default)]
note: Option<String>,
}
#[derive(serde::Deserialize)]
struct FlowPolicyPutResponse {
version: String,
#[serde(default)]
warning: Option<String>,
}
async fn policy_run(
cmd: PolicyCommand,
config: &crate::config::ProjectConfig,
) -> Result<OverlayOutput> {
match cmd {
PolicyCommand::Ls { server } => {
let (server, http) = crate::client::connect(server, config)
.map_err(|e| Error::Overlay(e.to_string()))?;
let resp = http
.get(format!("{server}/api/overlay/policy"))
.send()
.await
.map_err(|e| Error::Overlay(e.to_string()))?;
let version = resp
.headers()
.get("etag")
.and_then(|v| v.to_str().ok())
.map(|v| v.trim_matches('"').to_string())
.unwrap_or_default();
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
if !status.is_success() {
return Err(Error::Overlay(format!(
"overlay policy ls refused ({}): {}",
status.as_u16(),
body.trim()
)));
}
Ok(OverlayOutput::FlowPolicy {
policy: body,
version,
})
}
PolicyCommand::Set {
file,
if_match,
server,
} => {
let policy = if file == "-" {
use std::io::Read as _;
let mut buf = String::new();
std::io::stdin()
.read_to_string(&mut buf)
.map_err(|e| Error::Overlay(format!("reading policy from stdin: {e}")))?;
buf
} else {
std::fs::read_to_string(&file)
.map_err(|e| Error::Overlay(format!("reading policy file `{file}`: {e}")))?
};
let (server, http) = crate::client::connect(server, config)
.map_err(|e| Error::Overlay(e.to_string()))?;
let mut req = http
.put(format!("{server}/api/overlay/policy"))
.header("content-type", "text/plain")
.body(policy.clone());
if let Some(m) = &if_match {
req = req.header("if-match", m);
}
let resp = req
.send()
.await
.map_err(|e| Error::Overlay(e.to_string()))?;
let status = resp.status();
if !status.is_success() {
let body = resp.text().await.unwrap_or_default();
return Err(Error::Overlay(format!(
"overlay policy set refused ({}): {}",
status.as_u16(),
body.trim()
)));
}
let parsed: FlowPolicyPutResponse = resp
.json()
.await
.map_err(|e| Error::Overlay(e.to_string()))?;
Ok(OverlayOutput::FlowPolicyStored {
version: parsed.version,
warning: parsed.warning,
cleared: policy.trim().is_empty(),
})
}
PolicyCommand::Test {
action,
service,
holder,
role,
scope,
enrolled,
ttl_secs,
server,
} => {
let holder = match holder {
Some(h) => parse_id(&h)?.to_hex(),
None => operator_identity()?.id().to_hex(),
};
let (server, http) = crate::client::connect(server, config)
.map_err(|e| Error::Overlay(e.to_string()))?;
let resp = http
.post(format!("{server}/api/overlay/policy/test"))
.json(&FlowPolicyTestRequest {
action,
service,
holder,
role,
scope,
enrolled: enrolled.then_some(true),
ttl_requested: ttl_secs,
})
.send()
.await
.map_err(|e| Error::Overlay(e.to_string()))?;
let status = resp.status();
if !status.is_success() {
let body = resp.text().await.unwrap_or_default();
return Err(Error::Overlay(format!(
"overlay policy test refused ({}): {}",
status.as_u16(),
body.trim()
)));
}
let parsed: FlowPolicyTestResponse = resp
.json()
.await
.map_err(|e| Error::Overlay(e.to_string()))?;
Ok(OverlayOutput::FlowPolicyTest {
decision: parsed.decision,
ttl_ceiling: parsed.ttl_ceiling,
deciding_policies: parsed.deciding_policies,
diagnostics: parsed.diagnostics,
note: parsed.note,
})
}
}
}
async fn fetch_directory(
server: Option<String>,
config: &crate::config::ProjectConfig,
) -> Result<Vec<ServiceEntry>> {
let (server, http) =
crate::client::connect(server, config).map_err(|e| Error::Overlay(e.to_string()))?;
http.get(format!("{server}/api/overlay/services"))
.send()
.await
.map_err(|e| Error::Overlay(e.to_string()))?
.error_for_status()
.map_err(|e| Error::Overlay(e.to_string()))?
.json()
.await
.map_err(|e| Error::Overlay(e.to_string()))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn forward_ticket_round_trips() {
let t = ForwardTicket {
relay: "10.0.0.1:9100".into(),
relay_id: "ed25519:aa".into(),
egress_id: "ed25519:bb".into(),
service: "svc/db".into(),
token: "COSEBLOB".into(),
identity: Some("ed25519:cc".into()),
};
let blob = t.encode().unwrap();
assert!(blob.starts_with("brfwd1."));
let back = ForwardTicket::decode(&blob).unwrap();
assert_eq!(back.relay, "10.0.0.1:9100");
assert_eq!(back.service, "svc/db");
assert_eq!(back.token, "COSEBLOB");
assert_eq!(back.identity.as_deref(), Some("ed25519:cc"));
}
#[test]
fn forward_ticket_rejects_garbage() {
assert!(ForwardTicket::decode("not-a-ticket").is_err());
assert!(ForwardTicket::decode("brfwd1.@@@@").is_err());
let bad = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(b"{\"relay\":\"\",\"relay_id\":\"x\",\"egress_id\":\"y\",\"service\":\"s\",\"token\":\"t\"}");
assert!(ForwardTicket::decode(&format!("brfwd1.{bad}")).is_err());
}
#[test]
fn expose_ticket_round_trips() {
let t = ExposeTicket {
relay: "10.0.0.1:9100".into(),
relay_id: "ed25519:aa".into(),
service: "svc/db".into(),
token: "PUBLISHCOSE".into(),
trust_root: "es256:beef".into(),
identity: Some("ed25519:cc".into()),
};
let blob = t.encode().unwrap();
assert!(blob.starts_with("brexp1."));
let back = ExposeTicket::decode(&blob).unwrap();
assert_eq!(back.relay, "10.0.0.1:9100");
assert_eq!(back.service, "svc/db");
assert_eq!(back.token, "PUBLISHCOSE");
assert_eq!(back.trust_root, "es256:beef");
assert_eq!(back.identity.as_deref(), Some("ed25519:cc"));
}
#[test]
fn expose_ticket_rejects_garbage_and_a_forward_ticket() {
assert!(ExposeTicket::decode("not-a-ticket").is_err());
assert!(ExposeTicket::decode("brexp1.@@@@").is_err());
let fwd = ForwardTicket {
relay: "1.2.3.4:5".into(),
relay_id: "ed25519:aa".into(),
egress_id: "ed25519:bb".into(),
service: "svc/db".into(),
token: "t".into(),
identity: None,
}
.encode()
.unwrap();
assert!(ExposeTicket::decode(&fwd).is_err());
let bad = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(b"{\"relay\":\"a:1\",\"relay_id\":\"x\",\"service\":\"s\",\"token\":\"t\",\"trust_root\":\"\"}");
assert!(ExposeTicket::decode(&format!("brexp1.{bad}")).is_err());
}
#[test]
fn enroll_ticket_round_trips() {
let t = EnrollTicket {
token: "ENROLLCOSE".into(),
trust_root: "ed25519:root".into(),
role: "device".into(),
scope: "svc/db".into(),
relay: Some("ed25519:relay".into()),
};
let blob = t.encode().unwrap();
assert!(blob.starts_with("brojoin1."));
let back = EnrollTicket::decode(&blob).unwrap();
assert_eq!(back.token, "ENROLLCOSE");
assert_eq!(back.trust_root, "ed25519:root");
assert_eq!(back.role, "device");
assert_eq!(back.scope, "svc/db");
assert_eq!(back.relay.as_deref(), Some("ed25519:relay"));
}
#[test]
fn decide_sas_confirms_only_a_full_matching_code() {
let sas = SasCode::from_words("acorn-admiral-igloo").unwrap();
assert_eq!(
decide_sas(&sas, "acorn-admiral-igloo"),
ConfirmOutcome::Confirm
);
assert_eq!(
decide_sas(&sas, "ACORN ADMIRAL IGLOO"),
ConfirmOutcome::Confirm
);
assert_eq!(
decide_sas(&sas, "acorn-admiral-icon"),
ConfirmOutcome::Reject
);
assert_eq!(decide_sas(&sas, "yes"), ConfirmOutcome::Reject);
assert_eq!(decide_sas(&sas, ""), ConfirmOutcome::Reject);
assert_eq!(decide_sas(&sas, "acorn-admiral"), ConfirmOutcome::Reject);
}
#[test]
fn enroll_ticket_rejects_garbage_and_other_tickets() {
assert!(EnrollTicket::decode("not-a-ticket").is_err());
assert!(EnrollTicket::decode("brojoin1.@@@@").is_err());
let fwd = ForwardTicket {
relay: "1.2.3.4:5".into(),
relay_id: "ed25519:aa".into(),
egress_id: "ed25519:bb".into(),
service: "svc/db".into(),
token: "t".into(),
identity: None,
}
.encode()
.unwrap();
assert!(EnrollTicket::decode(&fwd).is_err());
let bad = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(
b"{\"token\":\"t\",\"trust_root\":\"\",\"role\":\"device\",\"scope\":\"svc/db\"}",
);
assert!(EnrollTicket::decode(&format!("brojoin1.{bad}")).is_err());
}
}