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");
}
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()))?;
#[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,
))
}
#[doc(hidden)]
pub fn for_testing(device_id: String, signing_key_b64: String) -> Self {
Self {
device_id,
signing_key_b64,
}
}
}
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 == '-')
}
pub fn is_allowed_relay_path(path: &str) -> bool {
if !path.starts_with('/') {
return false;
}
if path.starts_with("//") {
return false;
}
if path.contains('?') || path.contains('#') {
return false;
}
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"));
}
#[test]
fn relay_path_whitelist_rejects_network_path_and_query_and_fragment() {
assert!(!is_allowed_relay_path("//evil.example.com/hub/pair/x"));
assert!(!is_allowed_relay_path("//127.0.0.1:9999/hub/pair/x"));
assert!(!is_allowed_relay_path("/hub/pair/abc?forward=evil"));
assert!(!is_allowed_relay_path("/hub/pair/abc#fragment"));
assert!(!is_allowed_relay_path("/hub/pair/../admin"));
assert!(!is_allowed_relay_path("hub/pair/abc"));
assert!(!is_allowed_relay_path(""));
assert!(!is_allowed_relay_path("/"));
}
#[test]
fn relay_path_rejects_url_encoded_path_traversal() {
assert!(!is_allowed_relay_path("/hub/pair/%2e%2e/admin"));
assert!(!is_allowed_relay_path("/hub/pair/%2E%2E/admin"));
assert!(!is_allowed_relay_path("/hub/pair/%2E./admin"));
assert!(!is_allowed_relay_path("/hub/pair/.%2e/admin"));
assert!(!is_allowed_relay_path("/hub/pair/%2e%2e/%2e%2e/etc/passwd"));
}
#[test]
fn validate_device_id_length_boundaries() {
let too_short = "abcdefg"; assert!(!validate_device_id(too_short), "7-char id must be rejected");
let min_valid = "abcdefgh"; assert!(validate_device_id(min_valid), "8-char id must be accepted");
let max_valid = "a".repeat(64);
assert!(
validate_device_id(&max_valid),
"64-char id must be accepted"
);
let too_long = "a".repeat(65);
assert!(
!validate_device_id(&too_long),
"65-char id must be rejected"
);
}
#[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"
);
}
#[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"
);
}
#[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"
);
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");
}
}