Files
alknet/tasks/client/dial-iroh.md
T
deepseek-v4-pro 83a94ec03f chore(client): mark all client tasks as completed
Phase 3 complete: alknet-client crate implemented with:
- AlknetClient struct with builder methods (with_quinn, with_tcp_tls, with_iroh, with_socks5_proxy)
- ClientDialError enum with 5 variants
- dial_quic, dial_tcp_tls, dial_iroh methods
- SOCKS5 proxy support (Socks5ProxyConfig, Socks5Credentials, Socks5UdpSocket)
- Unit tests (18) + integration test (3)
- All feature combos compile and test clean
- Workspace build and tests pass
2026-07-17 12:49:50 +00:00

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 completed
client/client-core
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

  1. No TlsClientConfig: iroh has its own TLS. The dial does not use TlsClientConfig at all — it extracts the key and fingerprint directly from ConnectionCredentials. This is the same exception as the server side (ADR-082, ADR-087 §3).

  2. node_id derived from remote_identity.fingerprint: The fingerprint string (e.g., "ed25519:abcdef123456...") is parsed to extract the Ed25519 public key bytes, then converted to iroh::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.

  3. Unknown iroh remote fails closed: remote_identity: None with iroh returns a TlsConfig error. There is no CA to fall back to for iroh — raw-key remotes are always known peers (ADR-034 §2, Assumption 1).

  4. No addr or server_name parameter: iroh handles addressing internally (via relays, hole-punching, etc.). The dial only needs the NodeId and ALPN.

  5. Returns Connection, not CallConnection: Same as the other dials — the protocol take-over is the caller's concern.

  6. SOCKS5 proxy path: When self.socks5 is Some, the iroh endpoint should have been built with force-relay-only + proxy_url by 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 task
  • dial_tcp_tls — separate task
  • Tests — separate task

Acceptance Criteria

  • AlknetClient::dial_iroh implemented in crates/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 NoTransport error when self.iroh is None
  • Extracts NodeId from creds.remote_identity.fingerprint (supports ed25519:<hex> format)
  • Unknown iroh remote (remote_identity: None) fails closed with TlsConfig error
  • Connects via endpoint.connect(node_id, alpn)
  • Connect errors map to ClientDialError::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 addr or server_name parameters (iroh handles addressing internally)
  • cargo check -p alknet-client --features iroh succeeds
  • cargo clippy -p alknet-client --features iroh succeeds with no warnings
  • cargo build --workspace still succeeds (old code untouched)

References

  • docs/architecture/crates/client/README.md — dial_iroh section (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 from ConnectionCredentials. The node_id is derived from remote_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