Skip to content
Closed
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
275 changes: 189 additions & 86 deletions Native/cmux-iroh/Cargo.lock

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion Native/cmux-iroh/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ crate-type = ["staticlib"]
# Pinned exactly: 1.0.0-rc.1 is the newest published iroh as of 2026-06-11 and
# the version the FFI spike proved (experiments/iroh-swift-ffi-spike). Bump
# deliberately and re-run the spike proof, see README.md.
iroh = "=1.0.0-rc.1"
iroh = "=1.0.2"
serde_json = "1.0"
tokio = { version = "1.48", features = ["rt-multi-thread", "time"] }

Expand Down
124 changes: 104 additions & 20 deletions Native/cmux-iroh/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -382,15 +382,20 @@ pub unsafe extern "C" fn cmux_iroh_secret_key_endpoint_id(
/// - `enable_relay == true` and `relay_url` set: a custom single-relay map
/// (`RelayMode::Custom`), i.e. the user's own `iroh-relay`. This is the *only*
/// relay (no n0 fallback), so the dialer must be told the same relay URL (it
/// travels in the published route / pairing code).
/// travels in the published route / pairing code). When `relay_auth_token`
/// is non-null and non-empty, it authenticates this endpoint to that custom
/// relay. A token is ignored when relays are disabled or no custom relay URL
/// is supplied.
///
/// Returns null on failure with the cause in the error out-params. A malformed
/// `relay_url` fails with `InvalidArgument`.
/// `relay_url` fails with `InvalidArgument`. The relay auth token is never
/// logged or included in route JSON.
///
/// # Safety
///
/// - `secret_key` must point at `secret_key_len` readable bytes.
/// - `relay_url`, when non-null, must be a NUL-terminated C string.
/// - `relay_auth_token`, when non-null, must be a NUL-terminated C string.
/// - `err_kind`, when non-null, must point at a writable `int32_t`.
/// - `err_buf`, when non-null, must point at `err_cap` writable bytes.
#[unsafe(no_mangle)]
Expand All @@ -400,6 +405,7 @@ pub unsafe extern "C" fn cmux_iroh_endpoint_bind(
secret_key_len: usize,
enable_relay: bool,
relay_url: *const c_char,
relay_auth_token: *const c_char,
accept_connections: bool,
err_kind: *mut i32,
err_buf: *mut c_char,
Expand All @@ -411,6 +417,7 @@ pub unsafe extern "C" fn cmux_iroh_endpoint_bind(
secret_key_len,
enable_relay,
relay_url,
relay_auth_token,
accept_connections,
false,
);
Expand Down Expand Up @@ -442,6 +449,7 @@ pub unsafe extern "C" fn cmux_iroh_endpoint_bind_relay_only(
secret_key_len: usize,
enable_relay: bool,
relay_url: *const c_char,
relay_auth_token: *const c_char,
accept_connections: bool,
err_kind: *mut i32,
err_buf: *mut c_char,
Expand All @@ -453,6 +461,7 @@ pub unsafe extern "C" fn cmux_iroh_endpoint_bind_relay_only(
secret_key_len,
enable_relay,
relay_url,
relay_auth_token,
accept_connections,
true,
);
Expand All @@ -470,28 +479,12 @@ fn bind_impl(
secret_key_len: usize,
enable_relay: bool,
relay_url: *const c_char,
relay_auth_token: *const c_char,
accept_connections: bool,
relay_only: bool,
) -> Result<Endpoint, FfiError> {
let key = parse_secret_key(secret_key, secret_key_len)?;
// Parse an optional custom relay URL up front so a malformed value fails
// fast with `InvalidArgument` (mirrors the dial path's relay handling).
let custom_relay = match c_to_str(relay_url) {
Some(raw) if !raw.is_empty() => Some(RelayUrl::from_str(raw).map_err(|error| {
FfiError::new(
CmuxIrohErrorKind::InvalidArgument,
format!("invalid relay url: {error:#}"),
)
})?),
_ => None,
};
let relay_mode = if !enable_relay {
RelayMode::Disabled
} else if let Some(url) = custom_relay {
RelayMode::Custom(RelayMap::from(url))
} else {
RelayMode::Default
};
let relay_mode = relay_mode(enable_relay, relay_url, relay_auth_token)?;
runtime()?
.block_on(async move {
let mut builder = Endpoint::builder(presets::N0)
Expand All @@ -513,6 +506,42 @@ fn bind_impl(
})
}

/// Builds the relay mode used by both endpoint bind entrypoints.
fn relay_mode(
enable_relay: bool,
relay_url: *const c_char,
relay_auth_token: *const c_char,
) -> Result<RelayMode, FfiError> {
// Parse an optional custom relay URL up front so a malformed value fails
// fast with `InvalidArgument` (mirrors the dial path's relay handling).
let custom_relay = match c_to_str(relay_url) {
Some(raw) if !raw.is_empty() => Some(RelayUrl::from_str(raw).map_err(|error| {
FfiError::new(
CmuxIrohErrorKind::InvalidArgument,
format!("invalid relay url: {error:#}"),
)
})?),
_ => None,
};
let relay_auth_token = c_to_str(relay_auth_token).filter(|token| !token.is_empty());
let relay_mode = if !enable_relay {
RelayMode::Disabled
} else if let Some(url) = custom_relay {
let relay_map = RelayMap::from(url);
let relay_map = if let Some(token) = relay_auth_token {
relay_map.with_auth_token(token)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Borrowed Token Can Escape

When Swift passes relayAuthToken through withCString, that buffer is only valid for the FFI call. If with_auth_token stores the &str instead of cloning it into the relay map, the endpoint can later read freed memory while authenticating to the relay, causing corrupt auth headers or a crash. Make the ownership boundary explicit before storing the token in the long-lived relay configuration.

} else {
relay_map
};
RelayMode::Custom(relay_map)
} else {
// A token without a custom relay URL is intentionally ignored: relay
// auth applies only to the caller-selected private relay map.
RelayMode::Default
};
Ok(relay_mode)
}

/// Returns the endpoint's `EndpointId` (z-base-32) as a heap string.
/// Free with `cmux_iroh_string_free`. Null if the handle is null, unknown, or
/// closed.
Expand Down Expand Up @@ -1416,6 +1445,7 @@ mod ffi_seam_tests {
key.len(),
false, // relay disabled: hermetic, local UDP only
ptr::null(),
ptr::null(),
accept,
&raw mut err.kind,
err.buf.as_mut_ptr(),
Expand Down Expand Up @@ -1473,6 +1503,7 @@ mod ffi_seam_tests {
CMUX_IROH_SECRET_KEY_LEN,
false,
ptr::null(),
ptr::null(),
false,
&raw mut err.kind,
err.buf.as_mut_ptr(),
Expand All @@ -1491,6 +1522,7 @@ mod ffi_seam_tests {
16,
false,
ptr::null(),
ptr::null(),
false,
&raw mut err.kind,
err.buf.as_mut_ptr(),
Expand All @@ -1514,6 +1546,7 @@ mod ffi_seam_tests {
key.len(),
true,
bogus.as_ptr(),
ptr::null(),
false,
&raw mut err.kind,
err.buf.as_mut_ptr(),
Expand All @@ -1525,6 +1558,54 @@ mod ffi_seam_tests {
assert!(err.message().contains("relay url"), "{}", err.message());
}

#[test]
fn bind_relay_mode_applies_auth_token_only_to_custom_map() {
let relay_url = RelayUrl::from_str("https://127.0.0.1:9/").expect("valid relay url");
let relay_cstr = CString::new(relay_url.to_string()).expect("cstring");
let token = "cmux.test.jwt";
let token_cstr = CString::new(token).expect("cstring");
let empty_token = CString::new("").expect("cstring");

let mode = relay_mode(true, relay_cstr.as_ptr(), token_cstr.as_ptr())
.ok()
.expect("custom relay mode should parse");
let RelayMode::Custom(map) = mode else {
panic!("bind path should build a custom relay map");
};
let config = map
.get(&relay_url)
.expect("custom relay config must be installed");
assert!(
config.auth_token.as_deref() == Some(token),
"custom relay config should carry the auth token"
);

for absent_token in [ptr::null(), empty_token.as_ptr()] {
let mode = relay_mode(true, relay_cstr.as_ptr(), absent_token)
.ok()
.expect("custom relay mode should parse without a token");
let RelayMode::Custom(map) = mode else {
panic!("bind path should preserve the custom relay map");
};
let config = map
.get(&relay_url)
.expect("custom relay config must be installed");
assert!(
config.auth_token.is_none(),
"null and empty tokens must preserve unauthenticated relay config"
);
}

assert!(matches!(
relay_mode(true, ptr::null(), token_cstr.as_ptr()),
Ok(RelayMode::Default)
));
assert!(matches!(
relay_mode(false, relay_cstr.as_ptr(), token_cstr.as_ptr()),
Ok(RelayMode::Disabled)
));
}

#[test]
fn bind_uses_caller_key_for_endpoint_identity() {
let key = generate_key();
Expand Down Expand Up @@ -1606,6 +1687,7 @@ mod ffi_seam_tests {
usize,
bool,
*const c_char,
*const c_char,
bool,
*mut i32,
*mut c_char,
Expand Down Expand Up @@ -1675,6 +1757,7 @@ mod ffi_seam_tests {
listener_key.len(),
true,
ptr::null(),
ptr::null(),
true,
&raw mut err.kind,
err.buf.as_mut_ptr(),
Expand Down Expand Up @@ -1712,6 +1795,7 @@ mod ffi_seam_tests {
dialer_key.len(),
true,
ptr::null(),
ptr::null(),
false,
&raw mut err.kind,
err.buf.as_mut_ptr(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -59,13 +59,17 @@ char *cmux_iroh_secret_key_endpoint_id(
// (cmux-hosted iroh).
// - enable_relay == true, relay_url set: a custom single-relay map (the
// user's own iroh-relay). A malformed relay_url fails with InvalidArgument.
// relay_url, when non-null, must be a NUL-terminated C string. Returns null on
// failure with the cause in the error out-params.
// relay_auth_token authenticates this endpoint to that custom relay when
// non-null/non-empty; it is ignored when relays are disabled or without a
// custom relay_url, and is never logged or included in route JSON. relay_url
// and relay_auth_token, when non-null, must be NUL-terminated C strings.
// Returns null on failure with the cause in the error out-params.
CmuxIrohEndpoint *cmux_iroh_endpoint_bind(
const uint8_t *secret_key,
size_t secret_key_len,
bool enable_relay,
const char *relay_url,
const char *relay_auth_token,
bool accept_connections,
int32_t *err_kind,
char *err_buf,
Expand All @@ -74,12 +78,14 @@ CmuxIrohEndpoint *cmux_iroh_endpoint_bind(
// Binds an endpoint with relays enabled as requested but with all local IP
// transports removed. Pair with cmux_iroh_endpoint_connect_relay_only for
// DEBUG/simulator relay-path verification; the default bind/connect APIs keep
// direct+relay behavior.
// direct+relay behavior. relay_url and relay_auth_token have the same contract
// as cmux_iroh_endpoint_bind.
CmuxIrohEndpoint *cmux_iroh_endpoint_bind_relay_only(
const uint8_t *secret_key,
size_t secret_key_len,
bool enable_relay,
const char *relay_url,
const char *relay_auth_token,
bool accept_connections,
int32_t *err_kind,
char *err_buf,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ public actor CmxIrohByteListener {
private let secretKey: [UInt8]?
private let enableRelay: Bool
private let relayURL: String?
private let relayAuthToken: String?
private let relayOnly: Bool
private let maximumReceiveLength: Int

Expand All @@ -30,6 +31,7 @@ public actor CmxIrohByteListener {
attributes: .concurrent
)

/// Creates a listener that binds an iroh endpoint when ``start()`` runs.
/// - Parameters:
/// - secretKey: The Mac's 32-byte iroh secret key. When nil a fresh
/// ephemeral key is generated on ``start()``; the host supplies a stable
Expand All @@ -39,18 +41,23 @@ public actor CmxIrohByteListener {
/// - relayURL: When set (and `enableRelay` is true), the endpoint homes on
/// this custom relay (the user's own `iroh-relay`) instead of the default
/// n0 fleet. nil/empty keeps the default fleet (cmux-hosted iroh).
/// - relayAuthToken: An optional token that authenticates this endpoint to
/// the custom relay at bind time. It is never logged or published.
/// - relayOnly: Whether the listener disables local IP transports.
/// Defaults to false.
/// - maximumReceiveLength: The maximum bytes returned by one receive.
public init(
secretKey: [UInt8]? = nil,
enableRelay: Bool = true,
relayURL: String? = nil,
relayAuthToken: String? = nil,
relayOnly: Bool = false,
maximumReceiveLength: Int = CmxIrohByteTransport.defaultMaximumReceiveLength
) {
self.secretKey = secretKey
self.enableRelay = enableRelay
self.relayURL = relayURL
self.relayAuthToken = relayAuthToken
self.relayOnly = relayOnly
self.maximumReceiveLength = maximumReceiveLength
}
Expand All @@ -74,37 +81,22 @@ public actor CmxIrohByteListener {
}
let relayEnabled = enableRelay
let relay = relayURL
let relayAuth = relayAuthToken
let relayOnlyBind = relayOnly

let result = await runBlocking { () -> Result<CmxIrohUnsafeBox<OpaquePointer>, CmxIrohByteTransportError> in
let bind = CmxIrohByteTransport.withErrorBuffer { kindPtr, errBuf, cap in
CmxIrohByteTransport.withOptionalCString(relay) { relayC in
key.withUnsafeBufferPointer { keyBuffer in
if relayOnlyBind {
cmux_iroh_endpoint_bind_relay_only(
keyBuffer.baseAddress,
keyBuffer.count,
relayEnabled,
relayC,
true,
kindPtr,
errBuf,
cap
)
} else {
cmux_iroh_endpoint_bind(
keyBuffer.baseAddress,
keyBuffer.count,
relayEnabled,
relayC,
true,
kindPtr,
errBuf,
cap
)
}
}
}
CmxIrohByteTransport.bindEndpoint(
key: key,
enableRelay: relayEnabled,
relayURL: relay,
relayAuthToken: relayAuth,
relayOnly: relayOnlyBind,
acceptConnections: true,
kindPtr,
errBuf,
cap
)
}
guard let endpoint = bind.result else {
return .failure(.bindFailed(bind.message))
Expand Down
Loading
Loading