ilink-hub 0.3.0

iLink-compatible multiplexer hub for WeChat ClawBot — route one WeChat account to multiple AI agent backends
Documentation
//! Persistent device identity for zero-config pairing relay.

use anyhow::{Context, Result};
use base64::{engine::general_purpose::STANDARD as B64, Engine};
use ed25519_dalek::{SigningKey, VerifyingKey};
use percent_encoding;
use rand_core::OsRng;
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::PathBuf;
use uuid::Uuid;

use super::auth::{public_key_b64, sign_register};

const DEVICE_ID_FILE: &str = "device_id";
const DEVICE_IDENTITY_FILE: &str = "device_identity.json";

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DeviceIdentity {
    pub device_id: String,
    #[serde(rename = "signing_key")]
    signing_key_b64: String,
}

impl DeviceIdentity {
    pub fn load_or_create() -> Result<Self> {
        let path = device_identity_path()?;
        if path.exists() {
            let raw =
                fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?;
            let id: Self = serde_json::from_str(&raw).context("parse device_identity.json")?;
            if validate_device_id(&id.device_id) && !id.signing_key_b64.is_empty() {
                return Ok(id);
            }
            tracing::warn!("invalid device_identity.json, regenerating");
        }

        // Migrate legacy device_id file if present.
        let legacy_path = device_id_path()?;
        let device_id = if legacy_path.exists() {
            let id = fs::read_to_string(&legacy_path)?.trim().to_string();
            if validate_device_id(&id) {
                id
            } else {
                Uuid::new_v4().to_string()
            }
        } else {
            Uuid::new_v4().to_string()
        };

        let signing_key = SigningKey::generate(&mut OsRng);
        let identity = Self {
            device_id,
            signing_key_b64: B64.encode(signing_key.to_bytes()),
        };
        identity.save()?;
        tracing::info!(
            device_id = %identity.device_id,
            path = %path.display(),
            "created device identity"
        );
        Ok(identity)
    }

    fn save(&self) -> Result<()> {
        let path = device_identity_path()?;
        if let Some(parent) = path.parent() {
            fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?;
        }
        fs::write(&path, serde_json::to_string_pretty(self)?)
            .with_context(|| format!("write {}", path.display()))?;
        // Restrict to owner-read/write only — the file contains the Ed25519 signing key.
        #[cfg(unix)]
        {
            use std::os::unix::fs::PermissionsExt;
            fs::set_permissions(&path, fs::Permissions::from_mode(0o600))
                .with_context(|| format!("chmod 0600 {}", path.display()))?;
        }
        Ok(())
    }

    pub fn device_id(&self) -> &str {
        &self.device_id
    }

    fn signing_key(&self) -> Result<SigningKey> {
        let bytes = B64
            .decode(&self.signing_key_b64)
            .context("decode signing_key")?;
        Ok(SigningKey::from_bytes(
            bytes
                .as_slice()
                .try_into()
                .map_err(|_| anyhow::anyhow!("signing_key must be 32 bytes"))?,
        ))
    }

    pub fn verifying_key(&self) -> Result<VerifyingKey> {
        Ok(self.signing_key()?.verifying_key())
    }

    pub fn public_key_b64(&self) -> Result<String> {
        Ok(public_key_b64(&self.verifying_key()?))
    }

    pub fn sign_register(&self, timestamp: i64) -> Result<String> {
        Ok(sign_register(
            &self.signing_key()?,
            &self.device_id,
            timestamp,
        ))
    }

    /// Build an identity from already-known material. Used by tests so they do
    /// not need to write to the user's real local data dir.
    #[doc(hidden)]
    pub fn for_testing(device_id: String, signing_key_b64: String) -> Self {
        Self {
            device_id,
            signing_key_b64,
        }
    }
}

/// Backward-compatible helper for code that only needs the device id string.
pub fn load_or_create_device_id() -> Result<String> {
    Ok(DeviceIdentity::load_or_create()?.device_id)
}

pub fn device_identity_path() -> Result<PathBuf> {
    let base = dirs::data_local_dir().context("could not resolve data local dir")?;
    Ok(base.join("ilink-hub").join(DEVICE_IDENTITY_FILE))
}

pub fn device_id_path() -> Result<PathBuf> {
    let base = dirs::data_local_dir().context("could not resolve data local dir")?;
    Ok(base.join("ilink-hub").join(DEVICE_ID_FILE))
}

pub fn validate_device_id(id: &str) -> bool {
    if id.len() < 8 || id.len() > 64 {
        return false;
    }
    id.chars().all(|c| c.is_ascii_alphanumeric() || c == '-')
}

/// Only pairing endpoints may be forwarded from the public relay.
///
/// Rejects any path that could trick reqwest into routing the request
/// somewhere other than `/hub/pair/...` on the local hub:
///   * network-path references (`//evil.example.com/...`) — reqwest would
///     treat `//authority` as the host and rewrite the destination.
///   * query strings (`?...`) and fragments (`#...`) — relay-supplied
///     noise that the local hub has no contract for.
///   * path traversal (`..`) — checked on both the literal path **and** the
///     percent-decoded form to prevent `%2e%2e` bypass (SEC-011).
///   * anything that does not start with the single `/hub/pair/` prefix.
pub fn is_allowed_relay_path(path: &str) -> bool {
    if !path.starts_with('/') {
        return false;
    }
    // Network-path reference: a second `/` immediately after the leading
    // one would make reqwest parse the URL with `path` as authority.
    if path.starts_with("//") {
        return false;
    }
    if path.contains('?') || path.contains('#') {
        return false;
    }
    // Check for path traversal on both the raw bytes and the percent-decoded
    // form to prevent `%2e%2e` / `%2E.` / `.%2e` bypass (SEC-011).
    let decoded = percent_encoding::percent_decode_str(path)
        .decode_utf8_lossy()
        .into_owned();
    if path.contains("..") || decoded.contains("..") {
        return false;
    }
    path.starts_with("/hub/pair/")
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn validates_device_id() {
        assert!(validate_device_id("550e8400-e29b-41d4-a716-446655440000"));
        assert!(!validate_device_id("bad id"));
        assert!(!validate_device_id("x"));
    }

    #[test]
    fn relay_path_whitelist() {
        assert!(is_allowed_relay_path("/hub/pair/pair_abc"));
        assert!(is_allowed_relay_path("/hub/pair/pair_abc/confirm"));
        assert!(!is_allowed_relay_path("/hub/clients"));
        assert!(!is_allowed_relay_path("/hub/pair/../admin"));
    }

    /// Adversarial coverage for F-2: paths that could trick reqwest into
    /// routing the request somewhere other than `/hub/pair/...` on the
    /// local hub. Each case must be rejected.
    #[test]
    fn relay_path_whitelist_rejects_network_path_and_query_and_fragment() {
        // Network-path reference: reqwest parses `//authority/path` as
        // scheme-relative with `authority` as the host.
        assert!(!is_allowed_relay_path("//evil.example.com/hub/pair/x"));
        assert!(!is_allowed_relay_path("//127.0.0.1:9999/hub/pair/x"));

        // Query and fragment.
        assert!(!is_allowed_relay_path("/hub/pair/abc?forward=evil"));
        assert!(!is_allowed_relay_path("/hub/pair/abc#fragment"));

        // Path traversal (already covered, kept here for completeness).
        assert!(!is_allowed_relay_path("/hub/pair/../admin"));

        // Must start with `/`.
        assert!(!is_allowed_relay_path("hub/pair/abc"));
        assert!(!is_allowed_relay_path(""));
        assert!(!is_allowed_relay_path("/"));
    }

    /// SEC-011: URL-encoded path traversal variants must be rejected.
    /// `is_allowed_relay_path` must decode the path before the `..` check so
    /// that `%2e%2e`, `%2E%2E`, `%2E.`, and `.%2e` are all blocked.
    #[test]
    fn relay_path_rejects_url_encoded_path_traversal() {
        // All-encoded: %2e%2e
        assert!(!is_allowed_relay_path("/hub/pair/%2e%2e/admin"));
        // Mixed-case: %2E%2E
        assert!(!is_allowed_relay_path("/hub/pair/%2E%2E/admin"));
        // Half-encoded: %2E.
        assert!(!is_allowed_relay_path("/hub/pair/%2E./admin"));
        // Half-encoded: .%2e
        assert!(!is_allowed_relay_path("/hub/pair/.%2e/admin"));
        // Double-slash variant after decoding.
        assert!(!is_allowed_relay_path("/hub/pair/%2e%2e/%2e%2e/etc/passwd"));
    }

    /// M6-device-1: validate_device_id boundary — minimum length is 8 (strict >).
    /// Catches the `< 8` → `<= 8` mutant (141:17): if <= is used, a string of
    /// exactly 8 chars would be rejected; if < is used (correct), it is accepted.
    #[test]
    fn validate_device_id_length_boundaries() {
        // Exactly 7 characters → too short, must be rejected.
        let too_short = "abcdefg"; // 7 chars
        assert!(!validate_device_id(too_short), "7-char id must be rejected");

        // Exactly 8 characters → minimum valid length.
        let min_valid = "abcdefgh"; // 8 chars
        assert!(validate_device_id(min_valid), "8-char id must be accepted");

        // Exactly 64 characters → maximum valid length.
        let max_valid = "a".repeat(64);
        assert!(
            validate_device_id(&max_valid),
            "64-char id must be accepted"
        );

        // Exactly 65 characters → too long, must be rejected.
        let too_long = "a".repeat(65);
        assert!(
            !validate_device_id(&too_long),
            "65-char id must be rejected"
        );
    }

    /// M6-device-2: DeviceIdentity::device_id() getter must return the actual id.
    /// Catches the `→ ""` and `→ "xyzzy"` mutants (83:9).
    #[test]
    fn device_identity_device_id_getter_returns_actual_id() {
        use ed25519_dalek::SigningKey;
        use rand_core::OsRng;
        let sk = SigningKey::generate(&mut OsRng);
        let signing_key_b64 = base64::engine::general_purpose::STANDARD.encode(sk.to_bytes());
        let expected_id = "550e8400-e29b-41d4-a716-446655440000".to_string();

        let identity = DeviceIdentity::for_testing(expected_id.clone(), signing_key_b64);
        assert_eq!(
            identity.device_id(),
            expected_id,
            "device_id() must return the stored id"
        );
    }

    /// M6-device-3: DeviceIdentity::verifying_key() must return the actual key.
    /// Catches the `→ Ok(Default::default())` mutant (99:9) — Default::default()
    /// for VerifyingKey is all-zeros, which would always differ from the real key.
    #[test]
    fn device_identity_verifying_key_matches_signing_key() {
        use ed25519_dalek::SigningKey;
        use rand_core::OsRng;
        let sk = SigningKey::generate(&mut OsRng);
        let expected_vk = sk.verifying_key();
        let signing_key_b64 = B64.encode(sk.to_bytes());

        let identity = DeviceIdentity::for_testing("test-device-id".to_string(), signing_key_b64);
        let vk = identity
            .verifying_key()
            .expect("verifying_key must succeed");
        assert_eq!(
            vk.to_bytes(),
            expected_vk.to_bytes(),
            "verifying_key() must match the stored signing key"
        );
    }

    /// M6-device-4: public_key_b64 and sign_register must produce non-trivial results.
    /// Catches the `→ Ok(String::new())` / `→ Ok("xyzzy")` mutants (103:9, 107:9).
    #[test]
    fn device_identity_public_key_and_sign_register_are_non_trivial() {
        use ed25519_dalek::SigningKey;
        use rand_core::OsRng;
        let sk = SigningKey::generate(&mut OsRng);
        let signing_key_b64 = B64.encode(sk.to_bytes());
        let identity = DeviceIdentity::for_testing("test-device-abc".to_string(), signing_key_b64);

        let pk = identity
            .public_key_b64()
            .expect("public_key_b64 must succeed");
        assert!(
            !pk.is_empty() && pk != "xyzzy",
            "public_key_b64 must be a real base64 string"
        );

        let sig = identity
            .sign_register(1_700_000_000)
            .expect("sign_register must succeed");
        assert!(
            !sig.is_empty() && sig != "xyzzy",
            "sign_register must produce a real signature string"
        );

        // Verify the signature is valid using the auth module's verify_register.
        use super::super::auth::verify_register;
        verify_register(
            &identity.verifying_key().unwrap(),
            &identity.device_id,
            1_700_000_000,
            &sig,
            1_700_000_000,
        )
        .expect("produced signature must be verifiable");
    }
}