Phase 3 of the crate extraction (per findings.md): create the alknet-client crate — the native client dial seam, client-side analogue of AlknetEndpoint. Three dial methods (dial_quic, dial_tcp_tls, dial_iroh) unified on &ConnectionCredentials (ADR-091), pre-built transports via builder methods, optional SOCKS5 proxy support (ADR-090). 9 tasks, 7 generations, no cycles: - client/crate-init: Cargo.toml, feature flags, module skeleton - client/error-type: ClientDialError enum (5 variants) - client/client-core: AlknetClient struct + builder methods - client/dial-quic: QUIC dial via quinn - client/dial-tcp-tls: TCP+TLS dial via tokio-rustls - client/dial-iroh: Iroh dial (key-not-config) - client/socks5-proxy: Socks5ProxyConfig, Socks5UdpSocket, proxy integration - client/tests: Unit tests + integration test - client/review-client: Review checkpoint Depends on: tls/review-tls, endpoint/review-endpoint (both completed). Purely additive — old CallClient::connect stays until Phase 5 prune.
8.6 KiB
id, name, status, depends_on, scope, risk, impact, level
| id | name | status | depends_on | scope | risk | impact | level | |
|---|---|---|---|---|---|---|---|---|
| client/dial-iroh | Implement dial_iroh — iroh dial, producing a Connection | pending |
|
moderate | medium | component | implementation |
Description
Phase 3, Task 6. Implement AlknetClient::dial_iroh in crates/alknet-client/src/dial/iroh.rs.
The iroh dial: extracts the Ed25519SecretKey from ConnectionCredentials.local_identity,
derives the remote NodeId from creds.remote_identity.fingerprint, dials on alpn via
the iroh endpoint, and returns a Connection via Connection::from_iroh.
This is the key-not-config dial — iroh has its own TLS (shares the key, not the
rustls config — ADR-087 §3). The dial does NOT use TlsClientConfig. The consistency
is in the rule (ADR-034 verifier selection), not in the type.
Target shape (per architecture spec)
impl AlknetClient {
/// Iroh dial. Dials on `alpn` via the iroh endpoint. The iroh path
/// does NOT use `TlsClientConfig` — iroh has its own TLS (shares the
/// `Ed25519SecretKey`, not the rustls config — ADR-087 §3, ADR-089
/// §3). The local key is extracted from `creds.local_identity`; the
/// remote `NodeId` is derived from `creds.remote_identity.fingerprint`
/// (`ed25519:<hex>` → `NodeId::from_bytes`). The verifier is iroh's
/// `NodeId` match (fingerprint pin by another name — ADR-034 §3).
/// An unknown iroh remote fails closed (no CA). Feature-gated on
/// `iroh`.
#[cfg(feature = "iroh")]
pub async fn dial_iroh(
&self,
alpn: &[u8],
creds: &ConnectionCredentials,
) -> Result<Connection, ClientDialError>;
}
Implementation outline
#[cfg(feature = "iroh")]
pub async fn dial_iroh(
&self,
alpn: &[u8],
creds: &ConnectionCredentials,
) -> Result<Connection, ClientDialError> {
// 1. Get the iroh endpoint
let endpoint = match &self.iroh {
Some(ep) => ep.clone(),
None => return Err(ClientDialError::NoTransport { transport: "iroh" }),
};
// 2. Extract the remote NodeId from credentials
let node_id = match &creds.remote_identity {
Some(ri) => {
// fingerprint format: "ed25519:<hex>" or "SHA256:<base64>"
// For iroh, we need the ed25519 hex bytes → NodeId
extract_iroh_node_id(&ri.fingerprint)
.map_err(|e| ClientDialError::TlsConfig(
alknet_tls::TlsError::Config(e)
))?
}
None => {
// Unknown iroh remote — fail closed (no CA to fall back to)
return Err(ClientDialError::TlsConfig(
alknet_tls::TlsError::Config(
"iroh requires a known remote (remote_identity must be Some); \
unknown iroh remotes fail closed (ADR-034 §3)".into()
)
));
}
};
// 3. Connect via iroh
let conn = endpoint
.connect(node_id, alpn)
.await
.map_err(|e| ClientDialError::Connect(e.to_string()))?;
// 4. Wrap as Connection
Ok(Connection::from_iroh(conn))
}
Key design decisions
-
No
TlsClientConfig: iroh has its own TLS. The dial does not useTlsClientConfigat all — it extracts the key and fingerprint directly fromConnectionCredentials. This is the same exception as the server side (ADR-082, ADR-087 §3). -
node_idderived fromremote_identity.fingerprint: The fingerprint string (e.g.,"ed25519:abcdef123456...") is parsed to extract the Ed25519 public key bytes, then converted toiroh::NodeId::from_bytes. This is the same extraction pattern the rustls dials use for the verifier — the consistency is in the rule (ADR-034), not in the type. -
Unknown iroh remote fails closed:
remote_identity: Nonewith iroh returns aTlsConfigerror. There is no CA to fall back to for iroh — raw-key remotes are always known peers (ADR-034 §2, Assumption 1). -
No
addrorserver_nameparameter: iroh handles addressing internally (via relays, hole-punching, etc.). The dial only needs theNodeIdand ALPN. -
Returns
Connection, notCallConnection: Same as the other dials — the protocol take-over is the caller's concern. -
SOCKS5 proxy path: When
self.socks5isSome, the iroh endpoint should have been built with force-relay-only +proxy_urlby the assembly layer (ADR-090 §5). The dial itself doesn't change — the proxy is applied at endpoint construction time, not at dial time. This task does not need to handle the proxy path specially.
Helper: extract_iroh_node_id
/// Extract an `iroh::NodeId` from a fingerprint string.
///
/// Supports two formats:
/// - `"ed25519:<hex>"` — raw Ed25519 public key (64 hex chars)
/// - `"SHA256:<base64>"` — SHA-256 hash of the cert (for X.509; not valid for iroh)
///
/// For iroh, only the `ed25519:` prefix is valid — iroh uses Ed25519 keys.
fn extract_iroh_node_id(fingerprint: &str) -> Result<iroh::NodeId, String> {
if let Some(hex) = fingerprint.strip_prefix("ed25519:") {
let bytes = hex::decode(hex).map_err(|e| format!("invalid ed25519 fingerprint hex: {e}"))?;
if bytes.len() != 32 {
return Err(format!(
"invalid ed25519 fingerprint length: expected 32 bytes, got {}",
bytes.len()
));
}
let arr: [u8; 32] = bytes.try_into().map_err(|_| "invalid ed25519 fingerprint length".to_string())?;
Ok(iroh::NodeId::from_bytes(&arr)?)
} else {
Err(format!(
"iroh requires an ed25519: fingerprint, got: {}",
fingerprint
))
}
}
What this does NOT include
- The SOCKS5 proxy path for iroh — the proxy is applied at endpoint construction time by the assembly layer (ADR-090 §5), not at dial time
dial_quic— separate taskdial_tcp_tls— separate task- Tests — separate task
Acceptance Criteria
AlknetClient::dial_irohimplemented incrates/alknet-client/src/dial/iroh.rs- Signature:
pub async fn dial_iroh(&self, alpn: &[u8], creds: &ConnectionCredentials) -> Result<Connection, ClientDialError> - Feature-gated on
#[cfg(feature = "iroh")] - Uses pre-built iroh endpoint from
self.iroh(cloned) - Returns
NoTransporterror whenself.irohisNone - Extracts
NodeIdfromcreds.remote_identity.fingerprint(supportsed25519:<hex>format) - Unknown iroh remote (
remote_identity: None) fails closed withTlsConfigerror - Connects via
endpoint.connect(node_id, alpn) Connecterrors map toClientDialError::Connect(String)- Returns
Connection::from_iroh(conn) - Does NOT use
TlsClientConfig(iroh has its own TLS) - Does NOT call
spawn_dispatch(protocol take-over is caller's concern) - Does NOT take
addrorserver_nameparameters (iroh handles addressing internally) cargo check -p alknet-client --features irohsucceedscargo clippy -p alknet-client --features irohsucceeds with no warningscargo build --workspacestill succeeds (old code untouched)
References
- docs/architecture/crates/client/README.md —
dial_irohsection (lines 186-201) - docs/architecture/decisions/089-alknetclient-native-dial-seam.md — ADR-089 §3
- docs/architecture/decisions/091-connectioncredentials-decouple-dial-from-call.md — ADR-091
- docs/architecture/decisions/087-tlsclientconfig-not-blocked-on-dial.md — ADR-087 §3 (iroh shares the key, not the config)
- docs/architecture/decisions/034-outgoing-only-x509-and-three-peer-roles.md — ADR-034 §2-3 (verifier selection, fail-closed for unknown raw-key)
- docs/architecture/decisions/090-client-dial-socks5-proxy-seam.md — ADR-090 §5 (iroh proxy: force relay-only, applied at endpoint construction)
- crates/alknet-core/src/types.rs —
Connection::from_iroh(lines 528-536) - crates/alknet-core/src/credentials.rs —
ConnectionCredentials(the credential bundle) - crates/alknet-core/src/config.rs —
Ed25519SecretKey(the key type)
Notes
This is the key-not-config dial — iroh has its own TLS and does not use
TlsClientConfig. The dial extracts the key and fingerprint directly fromConnectionCredentials. Thenode_idis derived fromremote_identity.fingerprint(the same extraction pattern the rustls dials use for the verifier). Unknown iroh remotes fail closed (no CA to fall back to). The SOCKS5 proxy for iroh is applied at endpoint construction time by the assembly layer (force relay-only +proxy_url), not at dial time — this task does not need to handle it.
Summary
To be filled on completion