Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -42,8 +42,10 @@ postcard = { version = "1.1.3", features = ["use-std"] }
futures-buffered = "0.2.12"
tokio-util = "0.7.16"
n0-error = "0.1.0"
iroh-tickets = "0.2.0"

[dev-dependencies]
temp_env_vars = "0.2.1"
tokio = { version = "1.45", features = ["macros", "rt", "rt-multi-thread"] }

[workspace]
Expand Down
95 changes: 95 additions & 0 deletions src/api_secret.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
use std::{
collections::BTreeSet,
fmt::{self, Display},
str::FromStr,
};

use iroh::{EndpointAddr, EndpointId, SecretKey, TransportAddr};
use iroh_tickets::{ParseError, Ticket};
use serde::{Deserialize, Serialize};

/// The secret material used to connect your n0des.iroh.computer project. The
/// value of these should be treated like any other API key: guard them carefully.
#[derive(Debug, Clone)]
pub struct ApiSecret {
/// ED25519 secret used to construct rcans from
pub secret: SecretKey,
/// the n0des endpoint to direct requests to
pub remote: EndpointAddr,
}

impl Display for ApiSecret {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", Ticket::serialize(self))
}
}

#[derive(Serialize, Deserialize)]
struct Variant0EndpointAddr {
endpoint_id: EndpointId,
addrs: BTreeSet<TransportAddr>,
}

/// Wire format for [`NodeTicket`].
#[derive(Serialize, Deserialize)]
enum TicketWireFormat {
Variant0(Variant0N0desTicket),
}

#[derive(Serialize, Deserialize)]
struct Variant0N0desTicket {
secret: SecretKey,
addr: Variant0EndpointAddr,
}

impl Ticket for ApiSecret {
// KIND is the constant that's added to the front of a serialized ticket
// string. It should be a short, human readable string
const KIND: &'static str = "n0des";

fn to_bytes(&self) -> Vec<u8> {
let data = TicketWireFormat::Variant0(Variant0N0desTicket {
secret: self.secret.clone(),
addr: Variant0EndpointAddr {
endpoint_id: self.remote.id,
addrs: self.remote.addrs.clone(),
},
});
postcard::to_stdvec(&data).expect("postcard serialization failed")
}

fn from_bytes(bytes: &[u8]) -> Result<Self, ParseError> {
let res: TicketWireFormat = postcard::from_bytes(bytes)?;
let TicketWireFormat::Variant0(Variant0N0desTicket { secret, addr }) = res;
Ok(Self {
secret,
remote: EndpointAddr {
id: addr.endpoint_id,
addrs: addr.addrs.clone(),
},
})
}
}

impl FromStr for ApiSecret {
type Err = ParseError;

fn from_str(s: &str) -> Result<Self, Self::Err> {
iroh_tickets::Ticket::deserialize(s)
}
}

impl ApiSecret {
/// Creates a new ticket.
pub fn new(secret: SecretKey, remote: impl Into<EndpointAddr>) -> Self {
Self {
secret,
remote: remote.into(),
}
}

/// The [`EndpointAddr`] of the provider for this ticket.
pub fn addr(&self) -> &EndpointAddr {
&self.remote
}
}
49 changes: 48 additions & 1 deletion src/caps.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ use std::{collections::BTreeSet, fmt, str::FromStr, time::Duration};

use anyhow::{Context, Result, bail};
use ed25519_dalek::SigningKey;
use iroh::EndpointId;
use iroh::{EndpointId, SecretKey};
use rcan::{Capability, Expires, Rcan};
use serde::{Deserialize, Serialize};
use ssh_key::PrivateKey as SshPrivateKey;
Expand Down Expand Up @@ -66,6 +66,8 @@ impl std::ops::Deref for Caps {
pub enum Cap {
#[strum(to_string = "all")]
All,
#[strum(to_string = "client")]
Client,
#[strum(to_string = "relay:{0}")]
Relay(RelayCap),
#[strum(to_string = "metrics:{0}")]
Expand Down Expand Up @@ -107,6 +109,15 @@ impl Caps {
Self::V0(CapSet::new(caps))
}

/// the class of capabilities that n0des will accept when deriving from a
/// shared secret like a [`N0desTicket`]. These should be "client" capabilities:
/// typically for users of an app
pub fn for_shared_secret() -> Self {
Self::new([Cap::Client])
}

/// The maximum set of capabilities. n0des will only accept these capabilities
/// when deriving from a secret that is registered with n0des, like an SSH key
pub fn all() -> Self {
Self::new([Cap::All])
}
Expand Down Expand Up @@ -145,13 +156,23 @@ impl Capability for Cap {
fn permits(&self, other: &Self) -> bool {
match (self, other) {
(Cap::All, _) => true,
(Cap::Client, other) => client_capabilities(other),
(Cap::Relay(slf), Cap::Relay(other)) => slf.permits(other),
(Cap::Metrics(slf), Cap::Metrics(other)) => slf.permits(other),
(_, _) => false,
}
}
}

fn client_capabilities(other: &Cap) -> bool {
match other {
Cap::All => false,
Cap::Client => true,
Cap::Relay(RelayCap::Use) => true,
Cap::Metrics(MetricsCap::PutAny) => true,
}
}

impl Capability for MetricsCap {
fn permits(&self, other: &Self) -> bool {
match (self, other) {
Expand Down Expand Up @@ -257,6 +278,20 @@ pub fn create_api_token(
Ok(can)
}

/// Create an rcan token for the api access from an iroh secret key
pub fn create_api_token_from_secret_key(
private_key: SecretKey,
local_id: EndpointId,
max_age: Duration,
capability: Caps,
) -> Result<Rcan<Caps>> {
let issuer = SigningKey::from_bytes(&private_key.to_bytes());
let audience = local_id.as_verifying_key();
let can =
Rcan::issuing_builder(&issuer, audience, capability).sign(Expires::valid_for(max_age));
Ok(can)
}

#[cfg(test)]
mod tests {
use super::*;
Expand Down Expand Up @@ -301,4 +336,16 @@ mod tests {
assert!(!metrics.permits(&relay));
assert!(!relay.permits(&metrics));
}

#[test]
fn client_caps() {
let client = Caps::new([Cap::Client]);

let all = Caps::new([Cap::All]);
let metrics = Caps::new([MetricsCap::PutAny]);
let relay = Caps::new([RelayCap::Use]);
assert!(client.permits(&metrics));
assert!(client.permits(&relay));
assert!(!client.permits(&all));
}
}
Loading
Loading