diff --git a/easytier/docs/credential_peer.md b/easytier/docs/credential_peer.md new file mode 100644 index 00000000..bf9837ad --- /dev/null +++ b/easytier/docs/credential_peer.md @@ -0,0 +1,724 @@ +# 临时凭据(Credential)系统实现计划 + +## Context + +EasyTier 的 secure mode 已实现 Noise XX 握手 + X25519 静态公钥认证。当前节点通过 `network_secret` 双向确认身份。用户需要一种"临时凭据"机制: + +- **管理节点**(任何持有 network_secret 的节点)可为当前网络生成凭据 +- **新节点**可使用凭据代替 `network_secret` 加入网络 +- **管理节点**可撤销凭据 +- **撤销后**,使用该凭据接入的节点被全网踢出 + +**核心设计**:凭据 = X25519 密钥对。完全复用现有 Noise `Noise_XX_25519_ChaChaPoly_SHA256` 握手流程,无需修改握手消息格式。通过 OSPF 路由同步传播可信公钥列表,撤销时全网自然断开。 + +## 整体架构 + +``` +凭据 = X25519 密钥对 + - 管理节点生成密钥对,将公钥加入可信列表 + - 临时节点持有私钥,用作 Noise static key + - 全网通过 OSPF 路由同步可信公钥列表 + +管理节点 (持有 network_secret): + 1. generate_credential() → 生成 X25519 密钥对 + 2. 公钥记入 trusted_credential_pubkeys → 随 RoutePeerInfo 通过 OSPF 传播 + 3. revoke → 从 trusted 列表移除 → OSPF 同步 → 全网感知 + +临时节点 (持有凭据私钥): + 1. 使用凭据私钥作为 SecureModeConfig.local_private_key + 2. Noise 握手完全走现有流程(XX 模式交换 static pubkey) + 3. 不持有 network_secret,secret_proof 验证会失败,但公钥在可信列表中即可 + 4. RoutePeerInfo.noise_static_pubkey 自然携带凭据公钥 + +校验逻辑(每个节点在路由同步时执行): + 1. 从全网 RoutePeerInfo 中收集管理节点的 trusted_credential_pubkeys(取并集) + **安全约束: 仅信任 secure_auth_level=NetworkSecretConfirmed 的节点发布的列表** + 临时节点(CredentialAuthenticated)发布的 trusted_credential_pubkeys 必须被忽略 + 2. 对每个 peer,如果其 secure_auth_level < NetworkSecretConfirmed: + - 检查其 noise_static_pubkey 是否在可信公钥集合中 + - 不在 → 从路由表移除 → 断开连接 +``` + +## 详细设计 + +### Step 1: Protobuf 定义 + +**文件: `easytier/src/proto/peer_rpc.proto`** + +在 `RoutePeerInfo` 新增字段(利用已有 `noise_static_pubkey` 字段 #18): +```protobuf +message TrustedCredentialPubkey { + bytes pubkey = 1; // X25519 公钥 (32 bytes) + repeated string groups = 2; // 该凭据所属的 ACL group(管理节点声明,无需 proof) + bool allow_relay = 3; // 是否允许该临时节点提供 peer relay 能力 + int64 expiry_unix = 4; // 必选:过期时间(Unix timestamp),过期后自动失效 + repeated string allowed_proxy_cidrs = 5; // 允许该临时节点声明的 proxy_cidrs 范围 +} + +message RoutePeerInfo { + // ... existing fields 1-18 ... + // 管理节点发布的可信凭据公钥列表(含 group 关联) + repeated TrustedCredentialPubkey trusted_credential_pubkeys = 19; +} +``` + +临时节点无需新字段——其 `noise_static_pubkey`(字段 18)已经在 OSPF 中传播,只需在校验端判断该公钥是否在可信列表中。 + +新增 `SecureAuthLevel` 枚举值: +```protobuf +enum SecureAuthLevel { + None = 0; + EncryptedUnauthenticated = 1; + SharedNodePubkeyVerified = 2; + NetworkSecretConfirmed = 3; + CredentialAuthenticated = 4; // 新增:凭据公钥已验证 +} +``` + +**文件: `easytier/src/proto/api_instance.proto`** + +新增凭据管理 RPC: +```protobuf +message GenerateCredentialRequest { + repeated string groups = 1; // 可选: 凭据关联的 ACL group + bool allow_relay = 2; // 可选: 是否允许该临时节点提供 peer relay + repeated string allowed_proxy_cidrs = 3; // 可选: 限制可声明的 proxy_cidrs + int64 ttl_seconds = 4; // 必选: 凭据有效期(秒) +} +message GenerateCredentialResponse { + string credential_id = 1; // 公钥的 base64 + string credential_secret = 2; // 私钥的 base64 +} +message RevokeCredentialRequest { string credential_id = 1; } +message RevokeCredentialResponse { bool success = 1; } +message ListCredentialsRequest {} +message CredentialInfo { + string credential_id = 1; // 公钥 base64 + google.protobuf.Timestamp created_at = 2; +} +message ListCredentialsResponse { repeated CredentialInfo credentials = 1; } + +service CredentialManageRpc { + rpc GenerateCredential(GenerateCredentialRequest) returns (GenerateCredentialResponse); + rpc RevokeCredential(RevokeCredentialRequest) returns (RevokeCredentialResponse); + rpc ListCredentials(ListCredentialsRequest) returns (ListCredentialsResponse); +} +``` + +### Step 2: 凭据管理模块 + +**新文件: `easytier/src/peers/credential_manager.rs`** + +```rust +use x25519_dalek::{StaticSecret, PublicKey}; + +pub struct CredentialManager { + // 本节点管理的可信凭据 + credentials: DashMap, // credential_id (pubkey base64) -> entry + storage_path: Option, // 可选: 凭据 JSON 文件路径 +} + +struct CredentialEntry { + pubkey_bytes: [u8; 32], + groups: Vec, // 关联的 ACL group(管理节点声明) + allow_relay: bool, // 是否允许 relay + allowed_proxy_cidrs: Vec, // 允许声明的 proxy_cidrs 范围 + expiry: SystemTime, // 过期时间(必选) + created_at: SystemTime, +} + +impl CredentialManager { + /// 生成新凭据(含 group 关联) + /// 返回 (credential_id=公钥base64, credential_secret=私钥base64) + pub fn generate_credential(&self, groups: Vec, allow_relay: bool, expiry: SystemTime) -> (String, String) { + let private = StaticSecret::random_from_rng(OsRng); + let public = PublicKey::from(&private); + let id = BASE64_STANDARD.encode(public.as_bytes()); + let secret = BASE64_STANDARD.encode(private.as_bytes()); + self.credentials.insert(id.clone(), CredentialEntry { + pubkey_bytes: *public.as_bytes(), + groups, + allow_relay, + expiry, // 由调用方传入 + created_at: SystemTime::now(), + }); + self.save_to_disk(); // 持久化 + (id, secret) + } + + /// 撤销凭据 + pub fn revoke_credential(&self, credential_id: &str) -> bool; + + /// 获取可信凭据列表(用于 RoutePeerInfo.trusted_credential_pubkeys) + pub fn get_trusted_pubkeys(&self) -> Vec; + + /// 列出所有凭据 + pub fn list_credentials(&self) -> Vec; +} +``` + +### Step 3: Noise 握手适配(最小改动) + +**文件: `easytier/src/peers/peer_conn.rs`** + +临时节点的握手流程**完全不需要修改**,因为: +- 临时节点配置 `SecureModeConfig { enabled: true, local_private_key: 凭据私钥, local_public_key: 凭据公钥 }` +- `get_keypair()` (line 434) 自然返回凭据密钥对 +- Noise XX 握手正常交换 static pubkey +- 唯一区别:`secret_proof_32` 验证会失败(临时节点没有 network_secret) + +需要修改 `do_noise_handshake_as_server()` (line 934): +- **当前行为**: `secret_proof` 验证失败 → 返回错误断开连接 (line 1059) +- **修改为**: `secret_proof` 验证失败时,不立即断开,而是将 `secure_auth_level` 保持为 `EncryptedUnauthenticated` +- 后续由 OSPF 路由同步阶段决定该 peer 是否可信(公钥是否在 trusted 列表中) + +同样修改 `do_noise_handshake_as_client()` (line 680): +- 当临时节点连接管理节点时,`secret_proof` 验证失败不应报错 +- 临时节点可以通过 `pinned_remote_pubkey` 或不验证来处理 + +**NoiseHandshakeResult** 新增: +```rust +// 标记此连接使用了凭据而非 network_secret +is_credential_conn: bool, +``` + +### Step 4: RoutePeerInfo 传播凭据信息 + +**文件: `easytier/src/peers/peer_ospf_route.rs`** + +修改 `RoutePeerInfo::new_updated_self()` (line 164): +- 管理节点(持有 network_secret): 从 `CredentialManager.get_trusted_pubkeys()` 获取列表,填入 `trusted_credential_pubkeys` +- 临时节点: **不填写 `trusted_credential_pubkeys`**(该字段留空),即使收到其他管理节点传播的列表也不转发 + - 实现方式: 在 `new_updated_self()` 中检查节点身份,临时节点跳过 trusted_credential_pubkeys 填充 +- 临时节点: 无需额外操作,`noise_static_pubkey` 已自然包含凭据公钥 + +### Step 5: 全网校验与自动踢出(核心逻辑) + +**文件: `easytier/src/peers/peer_ospf_route.rs`** + +在 `SyncedRouteInfo` 中新增: +```rust +// 从全网管理节点汇总的可信凭据公钥集合 +trusted_credential_pubkeys: DashSet>, // pubkey bytes +``` + +新增校验方法(类似 `verify_and_update_group_trusts` line 743): +```rust +fn verify_credential_peers(&self, peer_infos: &[RoutePeerInfo]) { + // 1. 收集管理节点的 trusted_credential_pubkeys(取并集) + // **安全约束: 仅信任 secret_digest 与本网络匹配的节点(即持有 network_secret 的管理节点)** + // 临时节点的 trusted_credential_pubkeys 直接忽略,防止恶意临时节点自我授权 + let mut all_trusted = HashSet::new(); + for info in peer_infos { + if self.is_peer_secret_verified(info.peer_id) { + // 该 peer 通过了 network_secret 双向确认,是合法管理节点 + for tc in &info.trusted_credential_pubkeys { + all_trusted.insert(tc.pubkey.clone()); + } + } + // else: 该 peer 未通过 network_secret 确认(含临时节点),忽略其 trusted 列表 + } + self.trusted_credential_pubkeys = all_trusted; + + // 2. 检查所有 peer 的凭据状态 + for info in peer_infos { + if !self.is_peer_secret_verified(info.peer_id) + && !info.noise_static_pubkey.is_empty() + { + if !self.trusted_credential_pubkeys.contains(&info.noise_static_pubkey) { + // 该 peer 既不持有 network_secret,其公钥也不在可信列表中 + // → 标记为不可信,后续从路由表移除 + self.mark_peer_untrusted(info.peer_id); + } + } + } +} +``` + +在 `do_sync_route_info()` (line 2614) 中调用此校验。 + +在路由表构建中(`update_route_table_and_cached_local_conn_bitmap()`): +- 不可信 peer 不加入路由图 +- 已连接的不可信 peer 调用 `PeerMap::close_peer()` 断开 + +**判断 peer 是否持有 network_secret**: 利用现有 `secret_digest` 字段。管理节点的 `RoutePeerInfo` 中 `secret_digest` 与本节点匹配,说明双方持有相同的 network_secret。 + +### Step 6: GlobalCtx / Config 集成 + +**文件: `easytier/src/common/global_ctx.rs`** + +在 `GlobalCtx` 新增: +```rust +credential_manager: Arc, // 所有节点都持有,管理节点用于生成/撤销 +``` + +**文件: `easytier/src/common/global_ctx.rs` - `GlobalCtxEvent`** + +新增: +```rust +CredentialChanged, // 触发 OSPF 立即同步 +``` + +**文件: `easytier/src/common/config.rs`** + +临时节点的配置方式: 直接使用凭据私钥作为 `SecureModeConfig.local_private_key`。 +可在 `TomlConfigLoader` 中新增便捷字段或 CLI 参数: +- `--credential <私钥base64>`: 临时节点使用凭据私钥加入网络 +- `--credential-file `: 管理节点指定凭据存储 JSON 文件路径 + +### Step 7: RPC 服务 + CLI + +**文件: `easytier/src/peers/rpc_service.rs`** + +实现 `CredentialManageRpc`,参考 `PeerManagerRpcService` 模式。 + +**CLI** (`easytier-cli`): +``` +easytier-cli credential generate + 输出: credential_id=<公钥base64> credential_secret=<私钥base64> + +easytier-cli credential revoke +easytier-cli credential list +``` + +**临时节点启动**: +```bash +# 方式1: 直接传入凭据私钥 +easytier-core --network-name test \ + --secure-mode \ + --credential <私钥base64> \ + --peers tcp://管理节点:11010 + +# 内部实现: 将凭据私钥设为 SecureModeConfig.local_private_key +``` + +### Step 8: 连接时验证(握手后快速拒绝,必选) + +在 `do_noise_handshake_as_server()` 完成后,**必须**进行快速检查: +- 如果对端 `secret_proof` 验证失败(非管理节点),且对端 `noise_static_pubkey` 不在本节点已知的 `trusted_credential_pubkeys` 中 +- 立即断开连接 + +这是**必选的安全措施**(非可选优化)。因为 Step 3 放宽了 secret_proof 失败的处理,如果不做快速拒绝,任何随机节点都能与管理节点建立加密连接并持有,浪费资源。 + +```rust +// 在 handshake 完成后 +if !secret_proof_verified { + let remote_pubkey = handshake_result.remote_static_pubkey; + if !self.global_ctx.credential_manager.is_pubkey_trusted(&remote_pubkey) { + return Err(Error::AuthError("unknown credential".to_string())); + } + // 公钥在 trusted 列表中 → 允许连接,标记为 CredentialAuthenticated + handshake_result.secure_auth_level = SecureAuthLevel::CredentialAuthenticated; +} +``` + +## 关键文件清单 + +| 文件 | 修改内容 | +|------|----------| +| `easytier/src/proto/peer_rpc.proto` | `RoutePeerInfo` 加 `trusted_credential_pubkeys`; `SecureAuthLevel` 加 `CredentialAuthenticated` | +| `easytier/src/proto/api_instance.proto` | 新增 `CredentialManageRpc` 服务及消息定义 | +| `easytier/src/peers/credential_manager.rs` | **新文件** — 凭据管理器(密钥对生成/撤销/列表) | +| `easytier/src/peers/mod.rs` | 导出 credential_manager | +| `easytier/src/peers/peer_ospf_route.rs` | `new_updated_self()` 填 trusted_pubkeys; 新增 `verify_credential_peers()`; 路由表过滤 | +| `easytier/src/peers/peer_conn.rs` | `do_noise_handshake_as_server()` 放宽 secret_proof 失败为非致命; 可选握手阶段快速拒绝 | +| `easytier/src/peers/peer_manager.rs` | 集成 CredentialManager; 不可信 peer 断连逻辑 | +| `easytier/src/common/global_ctx.rs` | 持有 CredentialManager; 新增 CredentialChanged 事件 | +| `easytier/src/common/config.rs` | 新增 `--credential` 参数处理 | +| `easytier/src/peers/rpc_service.rs` | 实现 CredentialManageRpc | +| `easytier/src/proto/common.rs` | SecureModeConfig 可选: credential 模式识别 | + +## 复用现有机制 + +| 现有机制 | 路径 | 复用方式 | +|----------|------|----------| +| Noise XX 握手 | `peer_conn.rs:680,934` | 临时节点直接使用凭据密钥对走完整 Noise 流程 | +| `SecureModeConfig` | `proto/common.rs:367` | 临时节点的凭据私钥直接设为 local_private_key | +| `noise_static_pubkey` | `RoutePeerInfo` 字段 18 | 临时节点的凭据公钥已在 OSPF 中传播 | +| `verify_and_update_group_trusts()` | `peer_ospf_route.rs:743` | 凭据校验逻辑参考此模式 | +| `PeerMap::close_peer()` | `peer_map.rs:317` | 断开不可信 peer | +| OSPF 路由同步 | `SyncRouteInfoRequest` | 可信公钥列表随 RoutePeerInfo 自然传播 | +| `PeerManagerRpcService` | `rpc_service.rs:24` | RPC 服务实现模式 | +| `GlobalCtxEvent` | `global_ctx.rs:32` | 新增事件触发同步 | + +## 验证方案 + +1. **单元测试**: + - `credential_manager.rs`: 密钥对生成、撤销、列表 + - `peer_conn.rs`: 凭据节点 Noise 握手成功(无 network_secret) + +2. **集成测试** (参考 `tests/three_node.rs`): + - 3 节点: A + B (管理节点, network_secret) + C (临时节点, credential) + - A 生成凭据(groups=["guest"])→ C 使用凭据连接 → 验证 C 加入路由表、可达 + - 验证 C 的 ACL group 为 "guest",配置 group ACL 规则后生效 + - A 撤销凭据 → 等待 OSPF 同步 (~1-3s) → 验证 C 被 A 和 B 断开 + - C 尝试重连 → 验证握手阶段被拒 + +3. **手动测试**: + ```bash + # A: 管理节点 + easytier-core -n test -s secret --secure-mode --listeners tcp://0.0.0.0:11010 + easytier-cli credential generate # → credential_id + credential_secret + + # C: 临时节点 + easytier-core -n test --secure-mode --credential <私钥base64> --peers tcp://A:11010 + + # 验证后撤销 + easytier-cli credential revoke + # C 数秒内被踢出 + ``` + +### Step 9: 临时节点 OSPF 路由限制 + +**约束**: 临时节点传播的路由信息不可信,需严格限制。 + +#### 9a. 管理节点不主动发起到临时节点的 OSPF session + +**核心原则**: OSPF `maintain_sessions()` 构建最小生成树时,只在管理节点之间选择 initiator,不将临时节点纳入 `dst_peer_id_to_initiate`。但管理节点**被动接受**临时节点发起的 session。 + +**文件: `easytier/src/peers/peer_ospf_route.rs`** + +修改 `maintain_sessions()` (line 2485): +- 在构建 `dst_peer_id_to_initiate` 候选列表时,过滤掉临时节点 +- 管理节点之间的 MST 不受影响 + +```rust +// 在 maintain_sessions() 中,构建 initiator 候选时过滤临时节点 +let peers: Vec = peers.into_iter().filter(|peer_id| { + // 只主动发起到管理节点的 session,不主动连临时节点 + !self.is_credential_peer(*peer_id) +}).collect(); +``` + +- **临时节点自身**: 在 `maintain_sessions()` 中只将管理节点作为 initiator 候选,跳过其他临时节点 + +```rust +// 临时节点侧: 只主动连管理节点 +if self.is_credential_node() { + let peers: Vec = peers.into_iter().filter(|peer_id| { + !self.is_credential_peer(*peer_id) // 只连管理节点 + }).collect(); +} +``` + +**session 建立方式**: +- **管理节点 → 管理节点**: 正常 MST initiator 选择(不变) +- **临时节点 → 管理节点**: 临时节点主动发起 session,管理节点被动接受 +- **临时节点 → 临时节点**: 不建立(双方都过滤掉对方) +- **管理节点 → 临时节点**: 不主动发起(不在 initiator 候选中) + +**路由信息传播**: 临时节点通过其主动发起的 session 调用 `sync_route_info` 推送自身 RoutePeerInfo。管理节点在正常 OSPF sync 中将其代理传播给其他管理节点。管理节点也通过该 session 向临时节点推送完整路由表。 + +#### 9b. 管理节点只选择性接收临时节点的路由信息 + +**文件: `easytier/src/peers/peer_ospf_route.rs`** + +临时节点通过其主动发起的 session 调用 `sync_route_info`,管理节点在处理时需做过滤: + +- 只接收该临时节点**自己的** `RoutePeerInfo`(`route_info.peer_id == dst_peer_id`),丢弃其声称的其他 peer 的路由信息 +- 对临时节点自身的 RoutePeerInfo,过滤其 `proxy_cidrs`:只保留在 `TrustedCredentialPubkey.allowed_proxy_cidrs` 范围内的网段,移除超出范围的声明 +- 临时节点的 `foreign_network_infos` 应忽略 +- 临时节点的 `conn_info`(连接拓扑)**根据 `allow_relay` 标志决定**(见下方) + +修改 `update_peer_infos()` (line 461): + +```rust +fn update_peer_infos( + &self, my_peer_id, my_peer_route_id, dst_peer_id, + peer_infos, raw_peer_infos, +) -> Result<(), Error> { + let dst_is_credential_peer = self.is_credential_peer(dst_peer_id); + + for (idx, route_info) in peer_infos.iter().enumerate() { + // 临时节点只允许传播自己的路由信息 + if dst_is_credential_peer && route_info.peer_id != dst_peer_id { + tracing::debug!( + ?dst_peer_id, peer_id=?route_info.peer_id, + "ignoring route info from credential peer for other peer" + ); + continue; + } + + // 过滤临时节点的 proxy_cidrs,只保留凭据允许的范围 + if dst_is_credential_peer { + let allowed = self.get_credential_allowed_proxy_cidrs(dst_peer_id); + if let Some(allowed_cidrs) = allowed { + route_info.proxy_cidrs.retain(|cidr| { + allowed_cidrs.iter().any(|a| cidr_is_subset(cidr, a)) + }); + } + } + // ... existing logic ... + } +} +``` + +修改 `do_sync_route_info()` (line 2614): + +```rust +// 在 do_sync_route_info 中 +let from_is_credential = self.is_credential_peer(from_peer_id); +let credential_allows_relay = from_is_credential + && self.is_credential_relay_allowed(from_peer_id); + +if let Some(peer_infos) = &peer_infos { + // update_peer_infos 内部会过滤临时节点的非自身信息 + service_impl.synced_route_info.update_peer_infos(...); +} + +// 临时节点的 conn_info: 仅当 allow_relay=true 时接收 +if let Some(conn_info) = &conn_info { + if !from_is_credential || credential_allows_relay { + service_impl.synced_route_info.update_conn_info(conn_info); + } +} + +// 临时节点的 foreign_network_infos 始终不接收 +if let Some(foreign_network) = &foreign_network { + if !from_is_credential { + service_impl.synced_route_info.update_foreign_network(foreign_network); + } +} +``` + +**conn_info 处理**: +- 临时节点的 `conn_info`: 根据凭据的 `allow_relay` 标志决定是否接收 + - `allow_relay = true`: 管理节点接收并传播该临时节点的 conn_info,使其参与路由图,可作为 relay 转发数据 + - `allow_relay = false`(默认): 忽略 conn_info,该临时节点不参与中继(仅作为叶子节点存在于路由图中) +- 临时节点的 `foreign_network_infos` 始终忽略 + +**`is_credential_relay_allowed()` 实现**: +```rust +fn is_credential_relay_allowed(&self, peer_id: PeerId) -> bool { + // 从全网汇总的 trusted_credential_pubkeys 中查找该 peer 的凭据 + // 检查对应 TrustedCredentialPubkey.allow_relay 标志 + let peer_info = self.peer_infos.read(); + if let Some(info) = peer_info.get(&peer_id) { + for tc in &self.all_trusted_credentials { + if tc.pubkey == info.noise_static_pubkey { + return tc.allow_relay; + } + } + } + false +} +``` + +**注意**: 即使 `allow_relay=true`,临时节点仍然不能转发握手包(Step 10b 限制不变),因此不会有新节点通过 relay 临时节点接入网络。relay 能力仅用于已建立连接的 peer 之间的数据转发。 + +#### 9c. 临时节点的 `RoutePeerInfo` 中的 `trusted_credential_pubkeys` 被忽略 + +已在 Step 5 中说明:只信任 `secret_digest` 匹配的管理节点发布的 trusted 列表。 + +#### 判断 peer 是否为临时节点的方法 + +在 `SyncedRouteInfo` / `PeerRouteServiceImpl` 中新增: +```rust +fn is_credential_peer(&self, peer_id: PeerId) -> bool { + // 方法: 检查该 peer 的 RoutePeerInfo + // 1. 如果 peer 的 noise_static_pubkey 在 trusted_credential_pubkeys 中 → 是临时节点 + // 2. 如果 peer 通过了 network_secret 确认 (secret_digest 匹配) → 是管理节点 + // 3. 在 peer_conn 握手后,可以记录 secure_auth_level 到连接信息中 + let peer_info = self.synced_route_info.peer_infos.read(); + if let Some(info) = peer_info.get(&peer_id) { + if !info.noise_static_pubkey.is_empty() + && self.trusted_credential_pubkeys.contains(&info.noise_static_pubkey) { + return true; + } + } + false +} +``` + +对于直连 peer,也可以在握手阶段直接记录 `secure_auth_level`,用于快速判断。 + +### Step 10: 禁止通过临时节点接入网络 + +**约束**: 不得有新节点(无论是否持有 network_secret)通过临时节点的 listener 接入网络。但允许通过管理节点中继后建立 P2P 连接。 + +#### 10a. 临时节点天然无法接受新节点接入(无需额外代码) + +临时节点作为 listener 时,新节点的连接会**自然失败**,因为: +1. 临时节点没有 `network_secret`,无法验证对端的 `secret_proof` → 无法确认对端是管理节点 +2. 临时节点不发布 `trusted_credential_pubkeys` → 对端公钥不在可信列表中 +3. 对端也无法验证临时节点的 `secret_proof`(临时节点没有 network_secret) + +因此 **不需要在 `add_tunnel_as_server()` 中添加显式拦截逻辑**。已有的 Noise 握手 + 凭据校验机制已足够阻止新节点通过临时节点接入。 + +**例外**: 已知的管理节点可以连接到临时节点(如 P2P hole punch 场景),因为管理节点的公钥已通过 OSPF 同步被临时节点知晓,握手可以成功。 + +#### 10b. 临时节点不转发来自未知 peer 的连接请求 + +**文件: `easytier/src/peers/peer_manager.rs`** + +在 packet forwarding 路径 (line 718-766) 中: +- 临时节点不应转发 `HandShake` / `NoiseHandshakeMsg*` 类型的包 +- 这防止新节点通过临时节点的中继接入网络 + +```rust +// 在 peer_recv 循环的 forward 分支中 +if to_peer_id != my_peer_id { + // 临时节点不转发握手包(阻止新节点通过临时节点接入) + if is_credential_node && ( + hdr.packet_type == PacketType::HandShake as u8 + || hdr.packet_type == PacketType::NoiseHandshakeMsg1 as u8 + || hdr.packet_type == PacketType::NoiseHandshakeMsg2 as u8 + || hdr.packet_type == PacketType::NoiseHandshakeMsg3 as u8 + ) { + tracing::debug!("credential node dropping forwarded handshake packet"); + continue; + } + // ... existing forward logic ... +} +``` + +#### 10c. P2P 连接通过管理节点中继仍然允许 + +P2P hole punch 的流程: +1. 两个节点通过管理节点交换打洞信息(RPC) +2. 建立直接 P2P tunnel +3. 在 P2P tunnel 上握手 + +这个流程不受影响,因为: +- 打洞信息交换通过管理节点中继(RPC),不经过临时节点 +- P2P tunnel 建立后的握手是直连,不通过临时节点的 listener +- `is_directly_connected=false` 的连接(hole punch 结果)可以被临时节点接受 + +**设计思路**: 将凭据映射为 ACL Group,复用现有的 group-based ACL 规则系统。 + +现有 ACL 系统已支持基于 group 的规则匹配: +- `Rule.source_groups` / `Rule.destination_groups` (acl.proto:72-73) +- `PeerGroupInfo` 通过 HMAC proof 验证 peer 所属 group (peer_rpc.rs:8-38) +- `verify_and_update_group_trusts()` 在 OSPF 同步时更新 group trust map (peer_ospf_route.rs:743) +- `get_peer_groups()` 返回 peer 所属的 group 列表,用于 ACL 匹配 (peer_ospf_route.rs:2287) + +**方案**: 生成凭据时,为每个凭据创建一个隐式 ACL Group。 + +1. **凭据生成时**: 管理节点为凭据创建一个关联的 group: + - group_name = `"credential:"` 或用户自定义名称 + - group_secret = 由 credential_secret 派生的密钥 + - 可选:指定凭据所属的 group_name(批量管理,如 `"guest"`, `"contractor"`) + +2. **临时节点加入时**: 临时节点使用凭据私钥连接。其 group 归属由管理节点在 `TrustedCredentialPubkey.groups` 中声明(无需临时节点自己提供 group proof)。验证节点在 `verify_credential_peers()` 中匹配公钥后,直接将声明的 groups 加入 `group_trust_map`。 + +3. **ACL 规则配置**: 管理员可配置基于 group 的 ACL 规则: + ```toml + # 示例配置: 限制 "guest" group 只能访问特定子网 + [[acl.acl_v1.chains]] + name = "inbound" + chain_type = "Inbound" + default_action = "Allow" + + [[acl.acl_v1.chains.rules]] + name = "restrict_guest" + source_groups = ["guest"] + destination_ips = ["10.0.0.0/24"] + action = "Drop" + ``` + +4. **管理节点发布 group 信息**: + - 在 `RoutePeerInfo.trusted_credential_pubkeys` 中传播可信公钥时,同时包含关联的 group 信息 + - 扩展 proto: + (使用 Step 1 中定义的 `TrustedCredentialPubkey`,group 归属由管理节点声明,无需 proof 验证) + - 替换 `repeated bytes trusted_credential_pubkeys` 为 `repeated TrustedCredentialPubkey trusted_credential_pubkeys` + +5. **校验节点处理**: 在 `verify_credential_peers()` 中: + - 验证凭据公钥在可信列表中后 + - 直接将 `TrustedCredentialPubkey.groups` 中声明的 group 加入 `group_trust_map` / `group_trust_map_cache`(无需验证 group proof,因为管理节点的声明已是可信的) + - ACL filter 在处理数据包时自动基于 group 匹配规则 + +**API 扩展**: + +生成凭据时可指定 group: +```protobuf +message GenerateCredentialRequest { + repeated string groups = 1; // 可选: 为该凭据关联的 group 名称 + bool allow_relay = 2; // 可选: 是否允许 relay + repeated string allowed_proxy_cidrs = 3; // 可选: 限制可声明的 proxy_cidrs + int64 ttl_seconds = 4; // 必选: 凭据有效期(秒) +} +``` + +CLI: +```bash +# 生成带 group 的凭据,有效期 24 小时 +easytier-cli credential generate --groups guest,restricted --ttl 86400 + +# 生成允许 relay 的凭据,有效期 7 天 +easytier-cli credential generate --groups relay-node --allow-relay --ttl 604800 + +# 最简用法(默认 group 名为 "credential") +easytier-cli credential generate --ttl 3600 +``` + +## 安全审查 + +### 已覆盖的安全性 + +- **端到端加密**: 数据包在源端加密、目的端解密,relay 节点(含 `allow_relay` 的临时节点)无法看到明文 +- **临时节点自我授权防护**: 只信任 `secret_digest` 匹配的管理节点发布的 `trusted_credential_pubkeys` +- **临时节点路由篡改防护**: 只接收临时节点自身的 RoutePeerInfo,忽略其转发的其他路由 +- **临时节点网络接入防护**: 临时节点天然无法接受新节点接入(无 network_secret、不发布 trusted 列表) + +### 需要关注的安全问题 + +**1. Step 8 握手后快速拒绝应为必选(非可选)** + +当前 Step 8 标记为"可选优化",但实际上是**必须的安全措施**。如果不做快速拒绝: +- 任何随机节点(无 credential、无 network_secret)都能完成 Noise 握手(因为 Step 3 放宽了 secret_proof 失败) +- 在等待 OSPF 同步验证期间,该节点持有一个有效的加密连接,浪费资源 +- **修改**: Step 8 改为必选。握手完成后立即检查:对端 secret_proof 失败 + 公钥不在本节点已知的 trusted 列表中 → 立即断开 + +**2. Group proof 验证机制需要明确** + +当前方案:临时节点在 `RoutePeerInfo.groups` 中携带 `PeerGroupInfo`(HMAC proof),管理节点在 `TrustedCredentialPubkey` 中传播 `group_secret_hash`。 + +问题:HMAC 验证需要**原始 secret**,不是 hash。验证节点如何知道 credential 的 group secret? + +**解决方案**: `TrustedCredentialPubkey.group_secret_hash` 改为 `group_secret_digest`,使用与现有 `NetworkIdentity.network_secret_digest` 相同的 digest 算法。验证时: +- 管理节点在 `TrustedCredentialPubkey` 中包含 `group_secret_digest` +- 临时节点发送的 `PeerGroupInfo` 中包含 `group_proof`(HMAC) +- 验证节点无法直接验证 HMAC(没有原始 secret),但可以信任管理节点的声明:如果管理节点在 `TrustedCredentialPubkey.groups` 中列出了某个 group,且临时节点的公钥匹配,就直接信任该 group 归属 +- 即:**group 归属由管理节点在 `TrustedCredentialPubkey` 中声明,无需临时节点提供 proof** +- 这简化了实现,且安全性不降低(管理节点已是可信源) + +**3. 凭据持久化** + +`CredentialManager` 当前设计为内存存储。管理节点重启后所有凭据丢失,导致使用这些凭据的临时节点被踢出。 + +**解决方案**: +- 管理节点可配置凭据存储的 JSON 文件路径(如 `--credential-file /path/to/credentials.json`) +- `CredentialManager` 启动时从该文件加载已有凭据 +- 生成/撤销凭据时自动写入该文件 +- 未配置文件路径时,凭据仅存内存(重启丢失) + +**4. 同一凭据多节点复用** + +同一个 credential 私钥可以被多个节点同时使用。它们有不同的 `peer_id` 但相同的 `noise_static_pubkey`。这会导致: +- 路由表中多个 RoutePeerInfo 有相同的 `noise_static_pubkey` +- 撤销时所有使用该凭据的节点同时被踢出(符合预期) +- **这是预期行为**,但应在文档中说明 + +**5. 临时节点 proxy_cidrs 限制** + +临时节点可能声明虚假的 `proxy_cidrs`(子网代理),导致流量黑洞。 + +**解决方案**(已纳入设计): +- 生成凭据时通过 `allowed_proxy_cidrs` 字段限制该凭据可声明的网段范围 +- 管理节点在 Step 9b 的 `update_peer_infos()` 中过滤:只保留临时节点声明的 proxy_cidrs 中属于 `allowed_proxy_cidrs` 子集的网段 +- 未配置 `allowed_proxy_cidrs` 时(空列表),临时节点不允许声明任何 proxy_cidrs + +**6. 凭据过期时间(TTL)** + +凭据必须设置过期时间。过期后自动失效,等同于被撤销。 +- 生成凭据时必须指定 `--ttl` 或 `--expiry` +- `verify_credential_peers()` 中检查 `expiry_unix`,过期的凭据从可信列表中移除 +- 过期检查在每次路由同步时执行,无需额外定时器 + +## 优势 + +- **最小改动**: Noise 握手消息格式不变,完全复用现有流程 +- **安全性**: X25519 密钥对提供强身份认证,不弱于 network_secret;端到端加密保护 relay 场景 +- **自然传播**: 利用 OSPF 已有基础设施,无需新 RPC +- **去中心化撤销**: 任何管理节点都可撤销,全网通过路由同步感知 +- **ACL 复用**: 凭据映射为 ACL Group,完全复用现有 group-based ACL 规则系统,无需新的 ACL 机制 diff --git a/easytier/locales/app.yml b/easytier/locales/app.yml index dd3397ef..ff4484d2 100644 --- a/easytier/locales/app.yml +++ b/easytier/locales/app.yml @@ -244,6 +244,12 @@ core_clap: local_public_key: en: "local public key for secure mode. if not provided, a random key will be generated, or use local private key to derive public key" zh-CN: "安全模式下的本地公钥。如果未提供,则会随机生成一个密钥,或者使用本地私钥派生公钥" + credential: + en: "credential secret (base64-encoded private key) for joining network as a temporary node without network_secret" + zh-CN: "凭据密钥(base64编码的私钥),用于作为临时节点加入网络,无需 network_secret" + credential_file: + en: "path to credential storage file for persisting generated credentials across restarts (admin nodes)" + zh-CN: "凭据存储文件路径,用于在管理节点重启后保留已生成的凭据" check_config: en: Check config validity without starting the network zh-CN: 检查配置文件的有效性并退出 diff --git a/easytier/src/common/config.rs b/easytier/src/common/config.rs index 8689490a..f2491fb4 100644 --- a/easytier/src/common/config.rs +++ b/easytier/src/common/config.rs @@ -216,6 +216,11 @@ pub trait ConfigLoader: Send + Sync { fn get_secure_mode(&self) -> Option; fn set_secure_mode(&self, secure_mode: Option); + fn get_credential_file(&self) -> Option { + None + } + fn set_credential_file(&self, _path: Option) {} + fn dump(&self) -> String; } @@ -296,6 +301,16 @@ impl NetworkIdentity { network_secret_digest: Some(network_secret_digest), } } + + /// Create a NetworkIdentity for a credential node (no network_secret). + /// The node identifies by network_name only and authenticates via credential keypair. + pub fn new_credential(network_name: String) -> Self { + NetworkIdentity { + network_name, + network_secret: None, + network_secret_digest: None, + } + } } impl Default for NetworkIdentity { @@ -428,6 +443,8 @@ struct Config { udp_whitelist: Option>, stun_servers: Option>, stun_servers_v6: Option>, + + credential_file: Option, } #[derive(Debug, Clone)] @@ -821,6 +838,14 @@ impl ConfigLoader for TomlConfigLoader { self.config.lock().unwrap().secure_mode = secure_mode; } + fn get_credential_file(&self) -> Option { + self.config.lock().unwrap().credential_file.clone() + } + + fn set_credential_file(&self, path: Option) { + self.config.lock().unwrap().credential_file = path; + } + fn dump(&self) -> String { let default_flags_json = serde_json::to_string(&gen_default_flags()).unwrap(); let default_flags_hashmap = diff --git a/easytier/src/common/global_ctx.rs b/easytier/src/common/global_ctx.rs index d8df3d83..dd60505d 100644 --- a/easytier/src/common/global_ctx.rs +++ b/easytier/src/common/global_ctx.rs @@ -1,14 +1,19 @@ use std::collections::hash_map::DefaultHasher; +use std::collections::HashMap; use std::net::{IpAddr, SocketAddr}; use std::{ hash::Hasher, sync::{Arc, Mutex}, + time::{SystemTime, UNIX_EPOCH}, }; +use arc_swap::ArcSwap; + use crate::common::config::ProxyNetworkConfig; use crate::common::stats_manager::StatsManager; use crate::common::token_bucket::TokenBucketManager; use crate::peers::acl_filter::AclFilter; +use crate::peers::credential_manager::CredentialManager; use crate::proto::acl::GroupIdentity; use crate::proto::api::config::InstanceConfigPatch; use crate::proto::api::instance::PeerConnInfo; @@ -59,11 +64,43 @@ pub enum GlobalCtxEvent { ConfigPatched(InstanceConfigPatch), ProxyCidrsUpdated(Vec, Vec), // (added, removed) + + CredentialChanged, } pub type EventBus = tokio::sync::broadcast::Sender; pub type EventBusSubscriber = tokio::sync::broadcast::Receiver; +/// Source of a trusted public key from OSPF route propagation +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TrustedKeySource { + /// Peer node's noise static pubkey + OspfNode, + /// Admin-declared trusted credential pubkey + OspfCredential, +} + +/// Metadata for a trusted public key +#[derive(Debug, Clone)] +pub struct TrustedKeyMetadata { + pub source: TrustedKeySource, + /// Expiry time in Unix seconds. None means never expires. + pub expiry_unix: Option, +} + +impl TrustedKeyMetadata { + pub fn is_expired(&self) -> bool { + if let Some(expiry) = self.expiry_unix { + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs() as i64; + return now >= expiry; + } + false + } +} + pub struct GlobalCtx { pub inst_name: String, pub id: uuid::Uuid, @@ -97,6 +134,12 @@ pub struct GlobalCtx { stats_manager: Arc, acl_filter: Arc, + + credential_manager: Arc, + + /// OSPF propagated trusted keys (peer pubkeys and admin credentials) + /// Stored in ArcSwap for lock-free reads and atomic batch updates + trusted_keys: ArcSwap, TrustedKeyMetadata>>, } impl std::fmt::Debug for GlobalCtx { @@ -152,6 +195,9 @@ impl GlobalCtx { ..Default::default() }; + let credential_storage_path = config_fs.get_credential_file(); + let credential_manager = Arc::new(CredentialManager::new(credential_storage_path)); + GlobalCtx { inst_name: config_fs.get_inst_name(), id, @@ -187,6 +233,10 @@ impl GlobalCtx { stats_manager: Arc::new(StatsManager::new()), acl_filter: Arc::new(AclFilter::new()), + + credential_manager, + + trusted_keys: ArcSwap::new(Arc::new(HashMap::new())), } } @@ -404,6 +454,31 @@ impl GlobalCtx { &self.acl_filter } + pub fn get_credential_manager(&self) -> &Arc { + &self.credential_manager + } + + /// Check if a public key is trusted using two-level lookup: + /// 1. OSPF propagated trusted_keys (lock-free) + /// 2. Local credential_manager + pub fn is_pubkey_trusted(&self, pubkey: &[u8]) -> bool { + // First level: check OSPF propagated keys (lock-free) + let keys = self.trusted_keys.load(); + if let Some(metadata) = keys.get(pubkey) { + return !metadata.is_expired(); + } + drop(keys); + + // Second level: check local credential_manager + self.credential_manager.is_pubkey_trusted(pubkey) + } + + /// Atomically replace all OSPF trusted keys with a new set + /// Called by OSPF route layer after each route update + pub fn update_trusted_keys(&self, keys: HashMap, TrustedKeyMetadata>) { + self.trusted_keys.store(Arc::new(keys)); + } + pub fn get_acl_groups(&self, peer_id: PeerId) -> Vec { use std::collections::HashSet; self.config diff --git a/easytier/src/common/log.rs b/easytier/src/common/log.rs index a9e4d205..0c9b1f08 100644 --- a/easytier/src/common/log.rs +++ b/easytier/src/common/log.rs @@ -1,3 +1,5 @@ +use std::io::IsTerminal as _; + use crate::common::config::LoggingConfigLoader; use crate::common::get_logger_timer_rfc3339; use crate::common::tracing_rolling_appender::{FileAppenderWrapper, RollingFileAppenderBase}; @@ -175,7 +177,8 @@ pub fn init( let layer = || { layer() - .pretty() + .compact() + .with_ansi(std::io::stderr().is_terminal()) .with_timer(get_logger_timer_rfc3339()) .with_writer(std::io::stderr) }; diff --git a/easytier/src/core.rs b/easytier/src/core.rs index 488348bc..0a8c6f97 100644 --- a/easytier/src/core.rs +++ b/easytier/src/core.rs @@ -636,6 +636,20 @@ struct NetworkOptions { help = t!("core_clap.local_public_key").to_string() )] local_public_key: Option, + + #[arg( + long, + env = "ET_CREDENTIAL", + help = t!("core_clap.credential").to_string() + )] + credential: Option, + + #[arg( + long, + env = "ET_CREDENTIAL_FILE", + help = t!("core_clap.credential_file").to_string() + )] + credential_file: Option, } #[derive(Parser, Debug)] @@ -802,11 +816,17 @@ impl NetworkOptions { let old_ns = cfg.get_network_identity(); let network_name = self.network_name.clone().unwrap_or(old_ns.network_name); - let network_secret = self - .network_secret - .clone() - .unwrap_or(old_ns.network_secret.unwrap_or_default()); - cfg.set_network_identity(NetworkIdentity::new(network_name, network_secret)); + + if self.credential.is_some() { + // Credential mode: no network_secret, authenticate via credential keypair + cfg.set_network_identity(NetworkIdentity::new_credential(network_name)); + } else { + let network_secret = self + .network_secret + .clone() + .unwrap_or(old_ns.network_secret.unwrap_or_default()); + cfg.set_network_identity(NetworkIdentity::new(network_name, network_secret)); + } if let Some(dhcp) = self.dhcp { cfg.set_dhcp(dhcp); @@ -975,7 +995,19 @@ impl NetworkOptions { cfg.set_port_forwards(old); } - if let Some(secure_mode) = self.secure_mode { + if let Some(ref credential_file) = self.credential_file { + cfg.set_credential_file(Some(credential_file.clone())); + } + + if let Some(ref credential_secret) = self.credential { + // --credential implies --secure-mode and sets the credential private key + let c = SecureModeConfig { + enabled: true, + local_private_key: Some(credential_secret.clone()), + local_public_key: None, + }; + cfg.set_secure_mode(Some(Self::process_secure_mode_cfg(c)?)); + } else if let Some(secure_mode) = self.secure_mode { if secure_mode { let c = SecureModeConfig { enabled: secure_mode, diff --git a/easytier/src/easytier-cli.rs b/easytier/src/easytier-cli.rs index 923b9a87..7ecd7e1e 100644 --- a/easytier/src/easytier-cli.rs +++ b/easytier/src/easytier-cli.rs @@ -37,17 +37,18 @@ use easytier::{ instance::{ instance_identifier::{InstanceSelector, Selector}, list_peer_route_pair, AclManageRpc, AclManageRpcClientFactory, ConnectorManageRpc, - ConnectorManageRpcClientFactory, DumpRouteRequest, GetAclStatsRequest, - GetPrometheusStatsRequest, GetStatsRequest, GetVpnPortalInfoRequest, - GetWhitelistRequest, InstanceIdentifier, ListConnectorRequest, - ListForeignNetworkRequest, ListGlobalForeignNetworkRequest, - ListMappedListenerRequest, ListPeerRequest, ListPeerResponse, - ListPortForwardRequest, ListRouteRequest, ListRouteResponse, + ConnectorManageRpcClientFactory, CredentialManageRpc, + CredentialManageRpcClientFactory, DumpRouteRequest, GenerateCredentialRequest, + GetAclStatsRequest, GetPrometheusStatsRequest, GetStatsRequest, + GetVpnPortalInfoRequest, GetWhitelistRequest, InstanceIdentifier, + ListConnectorRequest, ListCredentialsRequest, ListForeignNetworkRequest, + ListGlobalForeignNetworkRequest, ListMappedListenerRequest, ListPeerRequest, + ListPeerResponse, ListPortForwardRequest, ListRouteRequest, ListRouteResponse, MappedListenerManageRpc, MappedListenerManageRpcClientFactory, NodeInfo, PeerManageRpc, PeerManageRpcClientFactory, PortForwardManageRpc, - PortForwardManageRpcClientFactory, ShowNodeInfoRequest, StatsRpc, - StatsRpcClientFactory, TcpProxyEntryState, TcpProxyEntryTransportType, TcpProxyRpc, - TcpProxyRpcClientFactory, VpnPortalRpc, VpnPortalRpcClientFactory, + PortForwardManageRpcClientFactory, RevokeCredentialRequest, ShowNodeInfoRequest, + StatsRpc, StatsRpcClientFactory, TcpProxyEntryState, TcpProxyEntryTransportType, + TcpProxyRpc, TcpProxyRpcClientFactory, VpnPortalRpc, VpnPortalRpcClientFactory, }, logger::{ GetLoggerConfigRequest, LogLevel, LoggerRpc, LoggerRpcClientFactory, @@ -134,6 +135,8 @@ enum SubCommand { Stats(StatsArgs), #[command(about = "manage logger configuration")] Logger(LoggerArgs), + #[command(about = "manage temporary credentials")] + Credential(CredentialArgs), #[command(about = t!("core_clap.generate_completions").to_string())] GenAutocomplete { shell: ShellType }, } @@ -340,6 +343,42 @@ enum LoggerSubCommand { }, } +#[derive(Args, Debug)] +struct CredentialArgs { + #[command(subcommand)] + sub_command: CredentialSubCommand, +} + +#[derive(Subcommand, Debug)] +enum CredentialSubCommand { + /// Generate a new temporary credential + Generate { + #[arg(long, help = "TTL in seconds (required)")] + ttl: i64, + #[arg(long, value_delimiter = ',', help = "ACL groups (comma-separated)")] + groups: Option>, + #[arg( + long, + default_value = "false", + help = "allow relay through this credential node" + )] + allow_relay: bool, + #[arg( + long, + value_delimiter = ',', + help = "allowed proxy CIDRs (comma-separated)" + )] + allowed_proxy_cidrs: Option>, + }, + /// Revoke a credential by its ID + Revoke { + #[arg(help = "credential ID (public key base64)")] + credential_id: String, + }, + /// List all active credentials + List, +} + #[derive(Args, Debug)] struct ServiceArgs { #[arg(short, long, default_value = env!("CARGO_PKG_NAME"), help = "service name")] @@ -537,6 +576,18 @@ impl CommandHandler<'_> { .with_context(|| "failed to get config client")?) } + async fn get_credential_client( + &self, + ) -> Result>, Error> { + Ok(self + .client + .lock() + .await + .scoped_client::>("".to_string()) + .await + .with_context(|| "failed to get credential client")?) + } + async fn list_peers(&self) -> Result { let client = self.get_peer_manager_client().await?; let request = ListPeerRequest { @@ -1363,6 +1414,121 @@ impl CommandHandler<'_> { Ok(()) } + async fn handle_credential_generate( + &self, + ttl: i64, + groups: Vec, + allow_relay: bool, + allowed_proxy_cidrs: Vec, + ) -> Result<(), Error> { + let client = self.get_credential_client().await?; + let request = GenerateCredentialRequest { + groups, + allow_relay, + allowed_proxy_cidrs, + ttl_seconds: ttl, + }; + let response = client + .generate_credential(BaseController::default(), request) + .await?; + + match self.output_format { + OutputFormat::Table => { + println!("Credential generated successfully:"); + println!(" credential_id: {}", response.credential_id); + println!(" credential_secret: {}", response.credential_secret); + println!(); + println!("To use this credential on a new node:"); + println!( + " easytier-core --network-name --secure-mode --credential {}", + response.credential_secret + ); + } + OutputFormat::Json => { + let json = serde_json::to_string_pretty(&response)?; + println!("{}", json); + } + } + + Ok(()) + } + + async fn handle_credential_revoke(&self, credential_id: &str) -> Result<(), Error> { + let client = self.get_credential_client().await?; + let request = RevokeCredentialRequest { + credential_id: credential_id.to_string(), + }; + let response = client + .revoke_credential(BaseController::default(), request) + .await?; + + match self.output_format { + OutputFormat::Table => { + if response.success { + println!("Credential revoked successfully"); + } else { + println!("Credential not found"); + } + } + OutputFormat::Json => { + let json = serde_json::to_string_pretty(&response)?; + println!("{}", json); + } + } + + Ok(()) + } + + async fn handle_credential_list(&self) -> Result<(), Error> { + let client = self.get_credential_client().await?; + let request = ListCredentialsRequest {}; + let response = client + .list_credentials(BaseController::default(), request) + .await?; + + match self.output_format { + OutputFormat::Table => { + if response.credentials.is_empty() { + println!("No active credentials"); + } else { + use tabled::{builder::Builder, settings::Style}; + let mut builder = Builder::default(); + builder.push_record(["ID", "Groups", "Relay", "Expiry", "Allowed CIDRs"]); + for cred in &response.credentials { + let expiry = { + let secs = cred.expiry_unix; + let remaining = secs + - std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs() as i64; + if remaining > 0 { + format!("{}s remaining", remaining) + } else { + "expired".to_string() + } + }; + builder.push_record([ + &cred.credential_id[..], + &cred.groups.join(","), + if cred.allow_relay { "yes" } else { "no" }, + &expiry, + &cred.allowed_proxy_cidrs.join(","), + ]); + } + let table = builder.build().with(Style::rounded()).to_string(); + println!("{}", table); + } + } + OutputFormat::Json => { + let json = serde_json::to_string_pretty(&response)?; + println!("{}", json); + } + } + + Ok(()) + } + fn parse_port_list(ports_str: &str) -> Result, Error> { let mut ports = Vec::new(); for port_spec in ports_str.split(',') { @@ -2193,6 +2359,29 @@ async fn main() -> Result<(), Error> { handler.handle_logger_set(level).await?; } }, + SubCommand::Credential(credential_args) => match &credential_args.sub_command { + CredentialSubCommand::Generate { + ttl, + groups, + allow_relay, + allowed_proxy_cidrs, + } => { + handler + .handle_credential_generate( + *ttl, + groups.clone().unwrap_or_default(), + *allow_relay, + allowed_proxy_cidrs.clone().unwrap_or_default(), + ) + .await?; + } + CredentialSubCommand::Revoke { credential_id } => { + handler.handle_credential_revoke(credential_id).await?; + } + CredentialSubCommand::List => { + handler.handle_credential_list().await?; + } + }, SubCommand::GenAutocomplete { shell } => { let mut cmd = Cli::command(); if let Some(shell) = shell.to_shell() { diff --git a/easytier/src/instance/instance.rs b/easytier/src/instance/instance.rs index b9ccb44f..da8c0997 100644 --- a/easytier/src/instance/instance.rs +++ b/easytier/src/instance/instance.rs @@ -1316,6 +1316,7 @@ impl Instance { stats_rpc_service: G, config_rpc_service: H, peer_center_rpc_service: Arc, + credential_manage_rpc_service: PeerManagerRpcService, } #[async_trait::async_trait] @@ -1383,6 +1384,12 @@ impl Instance { ) -> Arc + Send + Sync> { self.peer_center_rpc_service.clone() } + + fn get_credential_manage_service( + &self, + ) -> &dyn CredentialManageRpc { + &self.credential_manage_rpc_service + } } ApiRpcServiceImpl { @@ -1444,6 +1451,7 @@ impl Instance { stats_rpc_service: self.get_stats_rpc_service(), config_rpc_service: self.get_config_service(), peer_center_rpc_service: Arc::new(self.peer_center.get_rpc_service()), + credential_manage_rpc_service: PeerManagerRpcService::new(self.peer_manager.clone()), } } diff --git a/easytier/src/instance_manager.rs b/easytier/src/instance_manager.rs index 4e6c8eea..9e01ec2e 100644 --- a/easytier/src/instance_manager.rs +++ b/easytier/src/instance_manager.rs @@ -423,6 +423,10 @@ fn handle_event( instance_id ); } + + GlobalCtxEvent::CredentialChanged => { + event!(info, "[{}] credential changed", instance_id); + } } } else { events = events.resubscribe(); diff --git a/easytier/src/peers/credential_manager.rs b/easytier/src/peers/credential_manager.rs new file mode 100644 index 00000000..e251d9d9 --- /dev/null +++ b/easytier/src/peers/credential_manager.rs @@ -0,0 +1,354 @@ +use std::{ + collections::HashMap, + path::PathBuf, + sync::Mutex, + time::{Duration, SystemTime, UNIX_EPOCH}, +}; + +use base64::engine::general_purpose::STANDARD as BASE64_STANDARD; +use base64::Engine; +use serde::{Deserialize, Serialize}; +use x25519_dalek::{PublicKey, StaticSecret}; + +use crate::proto::peer_rpc::TrustedCredentialPubkey; + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct CredentialEntry { + pubkey_bytes: Vec, + groups: Vec, + allow_relay: bool, + allowed_proxy_cidrs: Vec, + expiry_unix: i64, + created_at_unix: i64, +} + +pub struct CredentialManager { + credentials: Mutex>, + storage_path: Option, +} + +impl CredentialManager { + pub fn new(storage_path: Option) -> Self { + let mgr = CredentialManager { + credentials: Mutex::new(HashMap::new()), + storage_path, + }; + mgr.load_from_disk(); + mgr + } + + pub fn generate_credential( + &self, + groups: Vec, + allow_relay: bool, + allowed_proxy_cidrs: Vec, + ttl: Duration, + ) -> (String, String) { + let private = StaticSecret::random_from_rng(rand::rngs::OsRng); + let public = PublicKey::from(&private); + let id = BASE64_STANDARD.encode(public.as_bytes()); + let secret = BASE64_STANDARD.encode(private.as_bytes()); + + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs() as i64; + let expiry_unix = now + ttl.as_secs() as i64; + + let entry = CredentialEntry { + pubkey_bytes: public.as_bytes().to_vec(), + groups, + allow_relay, + allowed_proxy_cidrs, + expiry_unix, + created_at_unix: now, + }; + + self.credentials.lock().unwrap().insert(id.clone(), entry); + self.save_to_disk(); + (id, secret) + } + + pub fn revoke_credential(&self, credential_id: &str) -> bool { + let removed = self + .credentials + .lock() + .unwrap() + .remove(credential_id) + .is_some(); + if removed { + self.save_to_disk(); + } + removed + } + + pub fn get_trusted_pubkeys(&self) -> Vec { + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs() as i64; + + self.credentials + .lock() + .unwrap() + .values() + .filter(|e| e.expiry_unix > now) + .map(|e| TrustedCredentialPubkey { + pubkey: e.pubkey_bytes.clone(), + groups: e.groups.clone(), + allow_relay: e.allow_relay, + expiry_unix: e.expiry_unix, + allowed_proxy_cidrs: e.allowed_proxy_cidrs.clone(), + }) + .collect() + } + + pub fn is_pubkey_trusted(&self, pubkey: &[u8]) -> bool { + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs() as i64; + + self.credentials + .lock() + .unwrap() + .values() + .any(|e| e.pubkey_bytes == pubkey && e.expiry_unix > now) + } + + pub fn list_credentials(&self) -> Vec { + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs() as i64; + + self.credentials + .lock() + .unwrap() + .iter() + .filter(|(_, e)| e.expiry_unix > now) + .map(|(id, e)| crate::proto::api::instance::CredentialInfo { + credential_id: id.clone(), + groups: e.groups.clone(), + allow_relay: e.allow_relay, + expiry_unix: e.expiry_unix, + allowed_proxy_cidrs: e.allowed_proxy_cidrs.clone(), + }) + .collect() + } + + fn save_to_disk(&self) { + let Some(path) = &self.storage_path else { + return; + }; + let creds = self.credentials.lock().unwrap(); + if let Ok(json) = serde_json::to_string_pretty(&*creds) { + if let Err(e) = std::fs::write(path, json) { + tracing::warn!(?e, "failed to save credentials to disk"); + } + } + } + + fn load_from_disk(&self) { + let Some(path) = &self.storage_path else { + return; + }; + let Ok(data) = std::fs::read_to_string(path) else { + return; + }; + match serde_json::from_str::>(&data) { + Ok(loaded) => { + *self.credentials.lock().unwrap() = loaded; + tracing::info!("loaded credentials from {}", path.display()); + } + Err(e) => { + tracing::warn!(?e, "failed to parse credentials file"); + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_generate_and_revoke() { + let mgr = CredentialManager::new(None); + let (id, secret) = mgr.generate_credential( + vec!["guest".to_string()], + false, + vec![], + Duration::from_secs(3600), + ); + + assert!(!id.is_empty()); + assert!(!secret.is_empty()); + + let pubkey_bytes = BASE64_STANDARD.decode(&id).unwrap(); + assert!(mgr.is_pubkey_trusted(&pubkey_bytes)); + + let trusted = mgr.get_trusted_pubkeys(); + assert_eq!(trusted.len(), 1); + assert_eq!(trusted[0].groups, vec!["guest".to_string()]); + + assert!(mgr.revoke_credential(&id)); + assert!(!mgr.is_pubkey_trusted(&pubkey_bytes)); + assert!(mgr.get_trusted_pubkeys().is_empty()); + } + + #[test] + fn test_expired_credential() { + let mgr = CredentialManager::new(None); + // TTL of 0 seconds - immediately expired + let (id, _) = mgr.generate_credential(vec![], false, vec![], Duration::from_secs(0)); + + let pubkey_bytes = BASE64_STANDARD.decode(&id).unwrap(); + assert!(!mgr.is_pubkey_trusted(&pubkey_bytes)); + assert!(mgr.get_trusted_pubkeys().is_empty()); + } + + #[test] + fn test_list_credentials() { + let mgr = CredentialManager::new(None); + mgr.generate_credential( + vec!["a".to_string()], + true, + vec!["10.0.0.0/24".to_string()], + Duration::from_secs(3600), + ); + mgr.generate_credential(vec![], false, vec![], Duration::from_secs(3600)); + + let list = mgr.list_credentials(); + assert_eq!(list.len(), 2); + } + + #[test] + fn test_keypair_validity() { + // Verify the generated private key can derive the same public key + let mgr = CredentialManager::new(None); + let (id, secret) = + mgr.generate_credential(vec![], false, vec![], Duration::from_secs(3600)); + + let privkey_bytes: [u8; 32] = BASE64_STANDARD.decode(&secret).unwrap().try_into().unwrap(); + let private = StaticSecret::from(privkey_bytes); + let derived_public = PublicKey::from(&private); + let derived_id = BASE64_STANDARD.encode(derived_public.as_bytes()); + + assert_eq!(id, derived_id); + } + + #[test] + fn test_revoke_nonexistent() { + let mgr = CredentialManager::new(None); + assert!(!mgr.revoke_credential("nonexistent_id")); + } + + #[test] + fn test_multiple_credentials_independent() { + let mgr = CredentialManager::new(None); + let (id1, _) = mgr.generate_credential( + vec!["group1".to_string()], + false, + vec![], + Duration::from_secs(3600), + ); + let (id2, _) = mgr.generate_credential( + vec!["group2".to_string()], + true, + vec!["10.0.0.0/8".to_string()], + Duration::from_secs(3600), + ); + + let pk1 = BASE64_STANDARD.decode(&id1).unwrap(); + let pk2 = BASE64_STANDARD.decode(&id2).unwrap(); + + assert!(mgr.is_pubkey_trusted(&pk1)); + assert!(mgr.is_pubkey_trusted(&pk2)); + + // Revoke first, second should still be trusted + mgr.revoke_credential(&id1); + assert!(!mgr.is_pubkey_trusted(&pk1)); + assert!(mgr.is_pubkey_trusted(&pk2)); + + let trusted = mgr.get_trusted_pubkeys(); + assert_eq!(trusted.len(), 1); + assert_eq!(trusted[0].groups, vec!["group2".to_string()]); + assert!(trusted[0].allow_relay); + assert_eq!( + trusted[0].allowed_proxy_cidrs, + vec!["10.0.0.0/8".to_string()] + ); + } + + #[test] + fn test_trusted_pubkeys_include_metadata() { + let mgr = CredentialManager::new(None); + let (id, _) = mgr.generate_credential( + vec!["admin".to_string(), "ops".to_string()], + true, + vec!["192.168.0.0/16".to_string(), "10.0.0.0/8".to_string()], + Duration::from_secs(7200), + ); + + let trusted = mgr.get_trusted_pubkeys(); + assert_eq!(trusted.len(), 1); + let tc = &trusted[0]; + assert_eq!(tc.groups, vec!["admin".to_string(), "ops".to_string()]); + assert!(tc.allow_relay); + assert_eq!( + tc.allowed_proxy_cidrs, + vec!["192.168.0.0/16".to_string(), "10.0.0.0/8".to_string()] + ); + assert!(tc.expiry_unix > 0); + + let pk = BASE64_STANDARD.decode(&id).unwrap(); + assert_eq!(tc.pubkey, pk); + } + + #[test] + fn test_unknown_pubkey_not_trusted() { + let mgr = CredentialManager::new(None); + mgr.generate_credential(vec![], false, vec![], Duration::from_secs(3600)); + + let random_key = [42u8; 32]; + assert!(!mgr.is_pubkey_trusted(&random_key)); + } + + #[test] + fn test_persistence_roundtrip() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("creds.json"); + + // Create and save + { + let mgr = CredentialManager::new(Some(path.clone())); + mgr.generate_credential( + vec!["persist_group".to_string()], + true, + vec!["10.0.0.0/24".to_string()], + Duration::from_secs(3600), + ); + assert_eq!(mgr.list_credentials().len(), 1); + } + + // Load from disk + { + let mgr = CredentialManager::new(Some(path)); + let list = mgr.list_credentials(); + assert_eq!(list.len(), 1); + assert_eq!(list[0].groups, vec!["persist_group".to_string()]); + assert!(list[0].allow_relay); + } + } + + #[test] + fn test_list_credentials_filters_expired() { + let mgr = CredentialManager::new(None); + mgr.generate_credential(vec![], false, vec![], Duration::from_secs(3600)); + mgr.generate_credential(vec![], false, vec![], Duration::from_secs(0)); // expired + + let list = mgr.list_credentials(); + assert_eq!(list.len(), 1); + } +} diff --git a/easytier/src/peers/foreign_network_client.rs b/easytier/src/peers/foreign_network_client.rs index 13da76d7..99e2794f 100644 --- a/easytier/src/peers/foreign_network_client.rs +++ b/easytier/src/peers/foreign_network_client.rs @@ -38,7 +38,7 @@ impl ForeignNetworkClient { } } - pub async fn add_new_peer_conn(&self, peer_conn: PeerConn) { + pub async fn add_new_peer_conn(&self, peer_conn: PeerConn) -> Result<(), Error> { tracing::warn!(peer_conn = ?peer_conn.get_conn_info(), network = ?peer_conn.get_network_identity(), "add new peer conn in foreign network client"); self.peer_map.add_new_peer_conn(peer_conn).await } diff --git a/easytier/src/peers/foreign_network_manager.rs b/easytier/src/peers/foreign_network_manager.rs index 0ace0f55..1004b267 100644 --- a/easytier/src/peers/foreign_network_manager.rs +++ b/easytier/src/peers/foreign_network_manager.rs @@ -686,7 +686,7 @@ impl ForeignNetworkManager { } } - entry.peer_map.add_new_peer_conn(peer_conn).await; + entry.peer_map.add_new_peer_conn(peer_conn).await?; Ok(()) } diff --git a/easytier/src/peers/mod.rs b/easytier/src/peers/mod.rs index 5e30a04a..6c7a3473 100644 --- a/easytier/src/peers/mod.rs +++ b/easytier/src/peers/mod.rs @@ -1,6 +1,7 @@ mod graph_algo; pub mod acl_filter; +pub mod credential_manager; pub mod peer; pub mod peer_conn; pub mod peer_conn_ping; diff --git a/easytier/src/peers/peer.rs b/easytier/src/peers/peer.rs index 3e982d7e..376f793b 100644 --- a/easytier/src/peers/peer.rs +++ b/easytier/src/peers/peer.rs @@ -17,6 +17,7 @@ use crate::{ global_ctx::{ArcGlobalCtx, GlobalCtxEvent}, PeerId, }, + proto::peer_rpc::PeerIdentityType, tunnel::packet_def::ZCPacket, }; use crate::{ @@ -40,6 +41,7 @@ pub struct Peer { shutdown_notifier: Arc, default_conn_id: Arc>, + peer_identity_type: Arc>>, default_conn_id_clear_task: ScopedTask<()>, } @@ -52,6 +54,8 @@ impl Peer { let conns: ConnMap = Arc::new(DashMap::new()); let (close_event_sender, mut close_event_receiver) = mpsc::channel(10); let shutdown_notifier = Arc::new(tokio::sync::Notify::new()); + let peer_identity_type = Arc::new(AtomicCell::new(None)); + let peer_identity_type_copy = peer_identity_type.clone(); let conns_copy = conns.clone(); let shutdown_notifier_copy = shutdown_notifier.clone(); @@ -76,6 +80,9 @@ impl Peer { conn.get_conn_info(), )); shrink_dashmap(&conns_copy, Some(4)); + if conns_copy.is_empty() { + peer_identity_type_copy.store(None); + } } } @@ -118,11 +125,25 @@ impl Peer { shutdown_notifier, default_conn_id, + peer_identity_type, default_conn_id_clear_task, } } - pub async fn add_peer_conn(&self, mut conn: PeerConn) { + pub async fn add_peer_conn(&self, mut conn: PeerConn) -> Result<(), Error> { + let conn_identity_type = conn.get_peer_identity_type(); + let peer_identity_type = self.peer_identity_type.load(); + if let Some(peer_identity_type) = peer_identity_type { + if peer_identity_type != conn_identity_type { + return Err(Error::SecretKeyError(format!( + "peer identity type mismatch. peer: {:?}, conn: {:?}", + peer_identity_type, conn_identity_type + ))); + } + } else { + self.peer_identity_type.store(Some(conn_identity_type)); + } + let close_notifier = conn.get_close_notifier(); let conn_info = conn.get_conn_info(); @@ -143,6 +164,7 @@ impl Peer { self.global_ctx .issue_event(GlobalCtxEvent::PeerConnAdded(conn_info)); + Ok(()) } async fn select_conn(&self) -> Option { @@ -221,6 +243,10 @@ impl Peer { pub fn get_default_conn_id(&self) -> PeerConnId { self.default_conn_id.load() } + + pub fn get_peer_identity_type(&self) -> Option { + self.peer_identity_type.load() + } } // pritn on drop @@ -238,17 +264,38 @@ impl Drop for Peer { #[cfg(test)] mod tests { + use base64::prelude::{Engine as _, BASE64_STANDARD}; + use rand::rngs::OsRng; use std::sync::Arc; use tokio::time::timeout; use crate::{ - common::{global_ctx::tests::get_mock_global_ctx, new_peer_id}, + common::{ + config::{NetworkIdentity, PeerConfig}, + global_ctx::{tests::get_mock_global_ctx, GlobalCtx}, + new_peer_id, + }, peers::{create_packet_recv_chan, peer_conn::PeerConn, peer_session::PeerSessionStore}, + proto::common::SecureModeConfig, tunnel::ring::create_ring_tunnel_pair, }; use super::Peer; + fn set_secure_mode_cfg(global_ctx: &GlobalCtx, enabled: bool) { + if !enabled { + global_ctx.config.set_secure_mode(None); + } else { + let private = x25519_dalek::StaticSecret::random_from_rng(OsRng); + let public = x25519_dalek::PublicKey::from(&private); + global_ctx.config.set_secure_mode(Some(SecureModeConfig { + enabled: true, + local_private_key: Some(BASE64_STANDARD.encode(private.as_bytes())), + local_public_key: Some(BASE64_STANDARD.encode(public.as_bytes())), + })); + } + } + #[tokio::test] async fn close_peer() { let (local_packet_send, _local_packet_recv) = create_packet_recv_chan(); @@ -284,8 +331,8 @@ mod tests { let local_conn_id = local_peer_conn.get_conn_id(); - local_peer.add_peer_conn(local_peer_conn).await; - remote_peer.add_peer_conn(remote_peer_conn).await; + local_peer.add_peer_conn(local_peer_conn).await.unwrap(); + remote_peer.add_peer_conn(remote_peer_conn).await.unwrap(); assert_eq!(local_peer.list_peer_conns().await.len(), 1); assert_eq!(remote_peer.list_peer_conns().await.len(), 1); @@ -305,4 +352,110 @@ mod tests { println!("wait for close handler"); close_handler.await.unwrap().unwrap(); } + + #[tokio::test] + async fn reject_peer_conn_with_mismatched_identity_type() { + let (packet_send, _packet_recv) = create_packet_recv_chan(); + let global_ctx = get_mock_global_ctx(); + let local_peer_id = new_peer_id(); + let remote_peer_id = new_peer_id(); + let peer = Peer::new(remote_peer_id, packet_send, global_ctx); + + let ps = Arc::new(PeerSessionStore::new()); + + let (shared_client_tunnel, shared_server_tunnel) = create_ring_tunnel_pair(); + let shared_client_ctx = get_mock_global_ctx(); + let shared_server_ctx = get_mock_global_ctx(); + shared_client_ctx + .config + .set_network_identity(NetworkIdentity::new("net1".to_string(), "sec2".to_string())); + shared_server_ctx + .config + .set_network_identity(NetworkIdentity { + network_name: "net2".to_string(), + network_secret: None, + network_secret_digest: None, + }); + set_secure_mode_cfg(&shared_client_ctx, true); + set_secure_mode_cfg(&shared_server_ctx, true); + let remote_url: url::Url = shared_client_tunnel + .info() + .unwrap() + .remote_addr + .unwrap() + .url + .parse() + .unwrap(); + shared_client_ctx.config.set_peers(vec![PeerConfig { + uri: remote_url, + peer_public_key: Some( + shared_server_ctx + .config + .get_secure_mode() + .unwrap() + .local_public_key + .unwrap(), + ), + }]); + let mut shared_client_conn = PeerConn::new( + local_peer_id, + shared_client_ctx, + Box::new(shared_client_tunnel), + ps.clone(), + ); + let mut shared_server_conn = PeerConn::new( + remote_peer_id, + shared_server_ctx, + Box::new(shared_server_tunnel), + ps.clone(), + ); + let (c1, s1) = tokio::join!( + shared_client_conn.do_handshake_as_client(), + shared_server_conn.do_handshake_as_server() + ); + c1.unwrap(); + s1.unwrap(); + assert_eq!( + shared_client_conn.get_peer_identity_type(), + crate::proto::peer_rpc::PeerIdentityType::SharedNode + ); + + let (admin_client_tunnel, admin_server_tunnel) = create_ring_tunnel_pair(); + let admin_client_ctx = get_mock_global_ctx(); + let admin_server_ctx = get_mock_global_ctx(); + admin_client_ctx + .config + .set_network_identity(NetworkIdentity::new("net1".to_string(), "sec2".to_string())); + admin_server_ctx + .config + .set_network_identity(NetworkIdentity::new("net1".to_string(), "sec2".to_string())); + set_secure_mode_cfg(&admin_client_ctx, true); + set_secure_mode_cfg(&admin_server_ctx, true); + let mut admin_client_conn = PeerConn::new( + local_peer_id, + admin_client_ctx, + Box::new(admin_client_tunnel), + Arc::new(PeerSessionStore::new()), + ); + let mut admin_server_conn = PeerConn::new( + remote_peer_id, + admin_server_ctx, + Box::new(admin_server_tunnel), + Arc::new(PeerSessionStore::new()), + ); + let (c2, s2) = tokio::join!( + admin_client_conn.do_handshake_as_client(), + admin_server_conn.do_handshake_as_server() + ); + c2.unwrap(); + s2.unwrap(); + assert_eq!( + admin_client_conn.get_peer_identity_type(), + crate::proto::peer_rpc::PeerIdentityType::Admin + ); + + peer.add_peer_conn(shared_client_conn).await.unwrap(); + let ret = peer.add_peer_conn(admin_client_conn).await; + assert!(ret.is_err()); + } } diff --git a/easytier/src/peers/peer_conn.rs b/easytier/src/peers/peer_conn.rs index 4b91fbc9..fc653e45 100644 --- a/easytier/src/peers/peer_conn.rs +++ b/easytier/src/peers/peer_conn.rs @@ -43,7 +43,7 @@ use crate::{ common::{LimiterConfig, SecureModeConfig, TunnelInfo}, peer_rpc::{ HandshakeRequest, PeerConnNoiseMsg1Pb, PeerConnNoiseMsg2Pb, PeerConnNoiseMsg3Pb, - PeerConnSessionActionPb, SecureAuthLevel, + PeerConnSessionActionPb, PeerIdentityType, SecureAuthLevel, }, }, tunnel::{ @@ -83,6 +83,7 @@ struct NoiseHandshakeResult { remote_static_pubkey: Vec, handshake_hash: Vec, secure_auth_level: SecureAuthLevel, + peer_identity_type: PeerIdentityType, remote_network_name: String, secret_digest: Vec, @@ -677,6 +678,99 @@ impl PeerConn { Ok(self.sink.send(pkt).await?) } + /// Unified remote peer authentication verification. + /// + /// Auth outcome matrix (current behavior): + /// + /// | Client role | Server role | Typical credential condition | Client auth level | Server auth level | Client sees server type | Server sees client type | + /// | --- | --- | --- | --- | --- | --- | --- | + /// | Admin | Admin | same network_secret, proof verified | NetworkSecretConfirmed | NetworkSecretConfirmed | Admin | Admin | + /// | Credential | Admin | client pubkey is trusted by admin | EncryptedUnauthenticated | PeerVerified | Admin | Credential | + /// | Credential | Admin | client pubkey is unknown | handshake may fail | handshake reject | unknown | unknown | + /// | Admin | SharedNode | pinned key match | PeerVerified | EncryptedUnauthenticated | SharedNode | SharedNode | + /// | Admin | SharedNode | local has no pinned key requirement | EncryptedUnauthenticated | EncryptedUnauthenticated | SharedNode | SharedNode | + /// | Credential | SharedNode | no pin and not trusted | EncryptedUnauthenticated | EncryptedUnauthenticated | SharedNode | SharedNode | + /// | Credential | Credential | both keys trusted by admin distribution | PeerVerified | PeerVerified | Credential | Credential | + /// + /// Logic (in priority order): + /// 1. **NetworkSecretConfirmed**: proof verification succeeds + /// 2. **PeerVerified**: pinned_pubkey matches and is in trusted list + /// (if no network_secret, pinned_pubkey must be in trusted list) + /// 3. **PeerVerified**: pubkey is in trusted list + /// 4. **EncryptedUnauthenticated**: initiator without network_secret + /// 5. **Reject**: none of the above + fn verify_remote_auth( + &self, + proof: Option<&[u8]>, + handshake_hash: &[u8], + remote_pubkey: &[u8], + pinned_pubkey: Option<&[u8]>, + has_network_secret: bool, + is_initiator: bool, + ) -> Result { + // 1. Verify proof + if let Some(proof) = proof { + if let Some(mac) = self.global_ctx.get_secret_proof(handshake_hash) { + if mac.verify_slice(proof).is_ok() { + return Ok(SecureAuthLevel::NetworkSecretConfirmed); + } + } + } + + // 2. Check pinned pubkey + if let Some(pinned) = pinned_pubkey { + if pinned != remote_pubkey { + return Err(Error::WaitRespError( + "pinned remote static pubkey mismatch".to_owned(), + )); + } + // If no network_secret, pinned key must be in trusted list + if !has_network_secret && !self.global_ctx.is_pubkey_trusted(remote_pubkey) { + return Err(Error::WaitRespError( + "pinned pubkey not in trusted list".to_owned(), + )); + } + return Ok(SecureAuthLevel::PeerVerified); + } + + // 3. Check if pubkey is in trusted list + if self.global_ctx.is_pubkey_trusted(remote_pubkey) { + return Ok(SecureAuthLevel::PeerVerified); + } + + // 4. If we are the initiator without network_secret, keep encrypted channel only. + if is_initiator && !has_network_secret { + return Ok(SecureAuthLevel::EncryptedUnauthenticated); + } + + // 5. Reject + Err(Error::WaitRespError( + "authentication failed: invalid proof and unknown credential".to_owned(), + )) + } + + fn classify_remote_identity( + &self, + remote_network_name: &str, + secure_auth_level: SecureAuthLevel, + remote_role_hint_is_same_network: bool, + remote_sent_secret_proof: bool, + ) -> PeerIdentityType { + if !remote_role_hint_is_same_network + || remote_network_name != self.global_ctx.get_network_name() + { + return PeerIdentityType::SharedNode; + } + + if matches!(secure_auth_level, SecureAuthLevel::NetworkSecretConfirmed) + || remote_sent_secret_proof + { + return PeerIdentityType::Admin; + } + + PeerIdentityType::Credential + } + async fn do_noise_handshake_as_client(&self) -> Result { let prologue = b"easytier-peerconn-noise".to_vec(); @@ -715,8 +809,6 @@ impl PeerConn { .local_private_key(&local_private_key)? .build_initiator()?; - let mut secure_auth_level = SecureAuthLevel::EncryptedUnauthenticated; - self.send_noise_msg( msg1_pb, PacketType::NoiseHandshakeMsg1, @@ -751,29 +843,12 @@ impl PeerConn { let action = PeerConnSessionActionPb::try_from(msg2_pb.action) .map_err(|_| Error::WaitRespError("invalid session action".to_owned()))?; let remote_network_name = msg2_pb.b_network_name.clone(); + let remote_sent_secret_proof = msg2_pb.secret_proof_32.is_some(); - if remote_network_name == network.network_name { - if msg2_pb.role_hint != 1 { - return Err(Error::WaitRespError( - "role_hint must be 1 when network_name is same".to_owned(), - )); - } - let Some(secret_proof_32) = msg2_pb.secret_proof_32 else { - return Err(Error::WaitRespError( - "secret_proof_32 must be present when role_hint is 1".to_owned(), - )); - }; - let verify_result = self - .global_ctx - .get_secret_proof(&server_handshake_hash) - .map(|mac| mac.verify_slice(&secret_proof_32).is_ok()); - if verify_result != Some(true) { - return Err(Error::WaitRespError(format!( - "secret_proof_32 verify failed: {verify_result:?}" - ))); - } - - secure_auth_level = secure_auth_level.max(SecureAuthLevel::NetworkSecretConfirmed); + if remote_network_name == network.network_name && msg2_pb.role_hint != 1 { + return Err(Error::WaitRespError( + "role_hint must be 1 when network_name is same".to_owned(), + )); } let handshake_hash_for_proof = hs.get_handshake_hash().to_vec(); @@ -817,16 +892,25 @@ impl PeerConn { None }; - if let Some(pinned) = pinned_remote_pubkey.as_ref() { - if pinned.as_slice() == remote_static.as_slice() { - secure_auth_level = - secure_auth_level.max(SecureAuthLevel::SharedNodePubkeyVerified); - } else { - return Err(Error::WaitRespError( - "pinned remote static pubkey mismatch".to_owned(), - )); - } - } + // Verify server authentication using unified logic + let secure_auth_level = if msg2_pb.role_hint != 1 && pinned_remote_pubkey.is_none() { + SecureAuthLevel::EncryptedUnauthenticated + } else { + self.verify_remote_auth( + msg2_pb.secret_proof_32.as_deref(), + &server_handshake_hash, + &remote_static, + pinned_remote_pubkey.as_deref(), + network.network_secret.is_some(), + true, // is_initiator + )? + }; + let peer_identity_type = self.classify_remote_identity( + &remote_network_name, + secure_auth_level, + msg2_pb.role_hint == 1, + remote_sent_secret_proof, + ); let handshake_hash = hs.get_handshake_hash().to_vec(); @@ -863,6 +947,7 @@ impl PeerConn { remote_static_pubkey: remote_static, handshake_hash, secure_auth_level, + peer_identity_type, remote_network_name, // we have authorized the peer with noise handshake, so just set secret digest same as us even remote is a shared node. secret_digest, @@ -1043,24 +1128,6 @@ impl PeerConn { )); } - let mut secure_auth_level = SecureAuthLevel::EncryptedUnauthenticated; - let Some(proof) = msg3_pb.secret_proof_32.as_ref() else { - return Err(Error::WaitRespError( - "noise msg3 secret_proof_32 is required".to_owned(), - )); - }; - - if role_hint == 1 { - if let Some(mac) = self.global_ctx.get_secret_proof(&handshake_hash_for_proof) { - if mac.verify_slice(proof).is_ok() { - secure_auth_level = - secure_auth_level.max(SecureAuthLevel::NetworkSecretConfirmed); - } else { - return Err(Error::WaitRespError("invalid secret_proof".to_owned())); - } - } - } - let remote_static = hs .get_remote_static() .map(|x: &[u8]| x.to_vec()) @@ -1074,6 +1141,30 @@ impl PeerConn { }; session.check_or_set_peer_static_pubkey(remote_static_key)?; + // Verify client authentication using unified logic + // Note: Server doesn't use pinned_pubkey since it's the responder + let secure_auth_level = if role_hint == 1 { + self.verify_remote_auth( + msg3_pb.secret_proof_32.as_deref(), + &handshake_hash_for_proof, + &remote_static, + None, // Server doesn't have pinned_remote_pubkey + self.global_ctx + .get_network_identity() + .network_secret + .is_some(), + false, // is_initiator + )? + } else { + SecureAuthLevel::EncryptedUnauthenticated + }; + let peer_identity_type = self.classify_remote_identity( + &remote_network_name, + secure_auth_level, + role_hint == 1, + msg3_pb.secret_proof_32.is_some(), + ); + let handshake_hash = hs.get_handshake_hash().to_vec(); Ok(NoiseHandshakeResult { @@ -1083,11 +1174,12 @@ impl PeerConn { remote_static_pubkey: remote_static, handshake_hash, secure_auth_level, + peer_identity_type, remote_network_name, secret_digest: msg3_pb.secret_digest, - client_secret_proof: Some(SecretProof { + client_secret_proof: msg3_pb.secret_proof_32.as_ref().map(|p| SecretProof { challenge: handshake_hash_for_proof, - proof: proof.clone(), + proof: p.clone(), }), my_encrypt_algo: self.my_encrypt_algo.clone(), @@ -1392,9 +1484,21 @@ impl PeerConn { .as_ref() .map(|x| x.secure_auth_level as i32) .unwrap_or_default(), + peer_identity_type: self + .noise_handshake_result + .as_ref() + .map(|x| x.peer_identity_type as i32) + .unwrap_or(PeerIdentityType::Admin as i32), } } + pub fn get_peer_identity_type(&self) -> PeerIdentityType { + self.noise_handshake_result + .as_ref() + .map(|x| x.peer_identity_type) + .unwrap_or(PeerIdentityType::Admin) + } + pub fn set_peer_id(&mut self, peer_id: PeerId) { if self.info.is_some() { panic!("set_peer_id should only be called before handshake"); @@ -1758,6 +1862,14 @@ pub mod tests { s_peer.get_conn_info().secure_auth_level, SecureAuthLevel::NetworkSecretConfirmed as i32, ); + assert_eq!( + c_peer.get_conn_info().peer_identity_type, + PeerIdentityType::Admin as i32, + ); + assert_eq!( + s_peer.get_conn_info().peer_identity_type, + PeerIdentityType::Admin as i32, + ); } #[tokio::test] @@ -1809,7 +1921,66 @@ pub mod tests { assert_eq!( c_peer.get_conn_info().secure_auth_level, - SecureAuthLevel::SharedNodePubkeyVerified as i32, + SecureAuthLevel::PeerVerified as i32, + ); + assert_eq!( + c_peer.get_conn_info().peer_identity_type, + PeerIdentityType::SharedNode as i32, + ); + assert_eq!( + s_peer.get_conn_info().peer_identity_type, + PeerIdentityType::SharedNode as i32, + ); + } + + #[tokio::test] + async fn peer_conn_secure_mode_shared_node_without_pin_is_unauthenticated() { + let (c, s) = create_ring_tunnel_pair(); + + let c_peer_id = new_peer_id(); + let s_peer_id = new_peer_id(); + + let c_ctx = get_mock_global_ctx(); + let s_ctx = get_mock_global_ctx(); + + c_ctx + .config + .set_network_identity(NetworkIdentity::new("net1".to_string(), "sec2".to_string())); + s_ctx.config.set_network_identity(NetworkIdentity { + network_name: "net2".to_string(), + network_secret: None, + network_secret_digest: None, + }); + + set_secure_mode_cfg(&c_ctx, true); + set_secure_mode_cfg(&s_ctx, true); + + let ps = Arc::new(PeerSessionStore::new()); + let mut c_peer = PeerConn::new(c_peer_id, c_ctx, Box::new(c), ps.clone()); + let mut s_peer = PeerConn::new(s_peer_id, s_ctx, Box::new(s), ps.clone()); + + let (c_ret, s_ret) = tokio::join!( + c_peer.do_handshake_as_client(), + s_peer.do_handshake_as_server() + ); + c_ret.unwrap(); + s_ret.unwrap(); + + assert_eq!( + c_peer.get_conn_info().secure_auth_level, + SecureAuthLevel::EncryptedUnauthenticated as i32, + ); + assert_eq!( + s_peer.get_conn_info().secure_auth_level, + SecureAuthLevel::EncryptedUnauthenticated as i32, + ); + assert_eq!( + c_peer.get_conn_info().peer_identity_type, + PeerIdentityType::SharedNode as i32, + ); + assert_eq!( + s_peer.get_conn_info().peer_identity_type, + PeerIdentityType::SharedNode as i32, ); } @@ -1903,4 +2074,227 @@ pub mod tests { .unwrap_err(); let _ = tokio::join!(j); } + + /// Helper: set up a credential node's GlobalCtx with a specific private key + /// (no network_secret, secure mode enabled with the given keypair) + fn set_credential_mode_cfg( + global_ctx: &GlobalCtx, + network_name: &str, + private_key: &x25519_dalek::StaticSecret, + ) { + use crate::common::config::NetworkIdentity; + let public = x25519_dalek::PublicKey::from(private_key); + global_ctx + .config + .set_network_identity(NetworkIdentity::new_credential(network_name.to_string())); + global_ctx.config.set_secure_mode(Some(SecureModeConfig { + enabled: true, + local_private_key: Some(BASE64_STANDARD.encode(private_key.as_bytes())), + local_public_key: Some(BASE64_STANDARD.encode(public.as_bytes())), + })); + } + + /// Test: credential node connects to admin node, admin has credential in trusted list. + /// Handshake should succeed with PeerVerified auth level on server side. + #[tokio::test] + async fn peer_conn_credential_node_connects_to_admin() { + let (c, s) = create_ring_tunnel_pair(); + + let c_peer_id = new_peer_id(); + let s_peer_id = new_peer_id(); + + // Admin node (server) has network_secret + let s_ctx = get_mock_global_ctx(); + s_ctx.config.set_network_identity(NetworkIdentity::new( + "net1".to_string(), + "secret".to_string(), + )); + set_secure_mode_cfg(&s_ctx, true); + + // Generate a credential on admin and get the private key for the client + let (cred_id, cred_secret) = s_ctx.get_credential_manager().generate_credential( + vec!["guest".to_string()], + false, + vec![], + std::time::Duration::from_secs(3600), + ); + + // Credential node (client) uses credential private key + let c_ctx = get_mock_global_ctx(); + let privkey_bytes: [u8; 32] = BASE64_STANDARD + .decode(&cred_secret) + .unwrap() + .try_into() + .unwrap(); + let private = x25519_dalek::StaticSecret::from(privkey_bytes); + set_credential_mode_cfg(&c_ctx, "net1", &private); + + let ps = Arc::new(PeerSessionStore::new()); + let mut c_peer = PeerConn::new(c_peer_id, c_ctx, Box::new(c), ps.clone()); + let mut s_peer = PeerConn::new(s_peer_id, s_ctx, Box::new(s), ps.clone()); + + let (c_ret, s_ret) = tokio::join!( + c_peer.do_handshake_as_client(), + s_peer.do_handshake_as_server() + ); + + c_ret.unwrap(); + s_ret.unwrap(); + + // Server should see credential node as PeerVerified + assert_eq!( + s_peer.get_conn_info().secure_auth_level, + SecureAuthLevel::PeerVerified as i32, + ); + assert_eq!( + s_peer.get_conn_info().peer_identity_type, + PeerIdentityType::Credential as i32, + ); + + // Client (credential node) keeps encrypted unauthenticated level + assert_eq!( + c_peer.get_conn_info().secure_auth_level, + SecureAuthLevel::EncryptedUnauthenticated as i32, + ); + assert_eq!( + c_peer.get_conn_info().peer_identity_type, + PeerIdentityType::Admin as i32, + ); + + // Verify credential ID matches + let _ = cred_id; // just to use it + } + + /// Test: unknown credential node (not in trusted list) is rejected by admin. + #[tokio::test] + async fn peer_conn_unknown_credential_rejected() { + let (c, s) = create_ring_tunnel_pair(); + + let c_peer_id = new_peer_id(); + let s_peer_id = new_peer_id(); + + // Admin node (server) with no credentials generated + let s_ctx = get_mock_global_ctx(); + s_ctx.config.set_network_identity(NetworkIdentity::new( + "net1".to_string(), + "secret".to_string(), + )); + set_secure_mode_cfg(&s_ctx, true); + + // Unknown credential node (client) with random key, not in admin's trusted list + let c_ctx = get_mock_global_ctx(); + let random_private = x25519_dalek::StaticSecret::random_from_rng(OsRng); + set_credential_mode_cfg(&c_ctx, "net1", &random_private); + + let ps = Arc::new(PeerSessionStore::new()); + let mut c_peer = PeerConn::new(c_peer_id, c_ctx, Box::new(c), ps.clone()); + let mut s_peer = PeerConn::new(s_peer_id, s_ctx, Box::new(s), ps.clone()); + + let (c_ret, s_ret) = tokio::join!( + c_peer.do_handshake_as_client(), + s_peer.do_handshake_as_server() + ); + + // Server should reject the unknown credential + assert!(s_ret.is_err(), "server should reject unknown credential"); + // Client may also fail due to connection being closed + let _ = c_ret; + } + + /// Test: two admin nodes with same network_secret still get NetworkSecretConfirmed. + /// (Regression test: credential system should not break normal admin-to-admin auth) + #[tokio::test] + async fn peer_conn_admin_to_admin_still_works() { + let (c, s) = create_ring_tunnel_pair(); + + let c_peer_id = new_peer_id(); + let s_peer_id = new_peer_id(); + + let c_ctx = get_mock_global_ctx(); + let s_ctx = get_mock_global_ctx(); + + c_ctx.config.set_network_identity(NetworkIdentity::new( + "net1".to_string(), + "secret".to_string(), + )); + s_ctx.config.set_network_identity(NetworkIdentity::new( + "net1".to_string(), + "secret".to_string(), + )); + + set_secure_mode_cfg(&c_ctx, true); + set_secure_mode_cfg(&s_ctx, true); + + let ps = Arc::new(PeerSessionStore::new()); + let mut c_peer = PeerConn::new(c_peer_id, c_ctx, Box::new(c), ps.clone()); + let mut s_peer = PeerConn::new(s_peer_id, s_ctx, Box::new(s), ps.clone()); + + let (c_ret, s_ret) = tokio::join!( + c_peer.do_handshake_as_client(), + s_peer.do_handshake_as_server() + ); + + c_ret.unwrap(); + s_ret.unwrap(); + + assert_eq!( + c_peer.get_conn_info().secure_auth_level, + SecureAuthLevel::NetworkSecretConfirmed as i32, + ); + assert_eq!( + s_peer.get_conn_info().secure_auth_level, + SecureAuthLevel::NetworkSecretConfirmed as i32, + ); + } + + /// Test: revoked credential is rejected on new connection attempt. + #[tokio::test] + async fn peer_conn_revoked_credential_rejected() { + // Admin generates credential, then revokes it + let admin_ctx = get_mock_global_ctx(); + admin_ctx.config.set_network_identity(NetworkIdentity::new( + "net1".to_string(), + "secret".to_string(), + )); + set_secure_mode_cfg(&admin_ctx, true); + + let (cred_id, cred_secret) = admin_ctx.get_credential_manager().generate_credential( + vec![], + false, + vec![], + std::time::Duration::from_secs(3600), + ); + + // Revoke the credential + assert!(admin_ctx + .get_credential_manager() + .revoke_credential(&cred_id)); + + // Now try to connect with the revoked credential + let (c, s) = create_ring_tunnel_pair(); + let c_peer_id = new_peer_id(); + let s_peer_id = new_peer_id(); + + let c_ctx = get_mock_global_ctx(); + let privkey_bytes: [u8; 32] = BASE64_STANDARD + .decode(&cred_secret) + .unwrap() + .try_into() + .unwrap(); + let private = x25519_dalek::StaticSecret::from(privkey_bytes); + set_credential_mode_cfg(&c_ctx, "net1", &private); + + let ps = Arc::new(PeerSessionStore::new()); + let mut c_peer = PeerConn::new(c_peer_id, c_ctx, Box::new(c), ps.clone()); + let mut s_peer = PeerConn::new(s_peer_id, admin_ctx, Box::new(s), ps.clone()); + + let (c_ret, s_ret) = tokio::join!( + c_peer.do_handshake_as_client(), + s_peer.do_handshake_as_server() + ); + + // Server should reject the revoked credential + assert!(s_ret.is_err(), "server should reject revoked credential"); + let _ = c_ret; + } } diff --git a/easytier/src/peers/peer_manager.rs b/easytier/src/peers/peer_manager.rs index 5e960d84..db242b92 100644 --- a/easytier/src/peers/peer_manager.rs +++ b/easytier/src/peers/peer_manager.rs @@ -43,7 +43,8 @@ use crate::{ ListGlobalForeignNetworkResponse, }, peer_rpc::{ - ForeignNetworkRouteInfoEntry, ForeignNetworkRouteInfoKey, RouteForeignNetworkSummary, + ForeignNetworkRouteInfoEntry, ForeignNetworkRouteInfoKey, PeerIdentityType, + RouteForeignNetworkSummary, }, }, tunnel::{ @@ -374,12 +375,34 @@ impl PeerManager { } async fn add_new_peer_conn(&self, peer_conn: PeerConn) -> Result<(), Error> { - if self.global_ctx.get_network_identity() != peer_conn.get_network_identity() { + let my_identity = self.global_ctx.get_network_identity(); + let peer_identity = peer_conn.get_network_identity(); + + // For credential nodes, network_secret_digest is either None or all-zeros + // (all-zeros when received over the wire via handshake). + // In this case, only compare network_name. + let my_digest_empty = my_identity + .network_secret_digest + .as_ref() + .is_none_or(|d| d.iter().all(|b| *b == 0)); + let peer_digest_empty = peer_identity + .network_secret_digest + .as_ref() + .is_none_or(|d| d.iter().all(|b| *b == 0)); + + let identity_ok = if my_digest_empty || peer_digest_empty { + // Credential node: only check network_name + my_identity.network_name == peer_identity.network_name + } else { + my_identity == peer_identity + }; + + if !identity_ok { return Err(Error::SecretKeyError( "network identity not match".to_string(), )); } - self.peers.add_new_peer_conn(peer_conn).await; + self.peers.add_new_peer_conn(peer_conn).await?; Ok(()) } @@ -414,7 +437,7 @@ impl PeerManager { { self.add_new_peer_conn(peer).await?; } else { - self.foreign_network_client.add_new_peer_conn(peer).await; + self.foreign_network_client.add_new_peer_conn(peer).await?; } Ok((peer_id, conn_id)) } @@ -674,6 +697,12 @@ impl PeerManager { let secure_mode_enabled = self.is_secure_mode_enabled; let stats_mgr = self.global_ctx.stats_manager().clone(); let route = self.get_route(); + let is_credential_node = self + .global_ctx + .get_network_identity() + .network_secret + .is_none() + && secure_mode_enabled; let label_set = LabelSet::new().with_label_type(LabelType::NetworkName(global_ctx.get_network_name())); @@ -721,6 +750,17 @@ impl PeerManager { continue; } + // Step 10b: credential nodes don't forward handshake packets + if is_credential_node + && (hdr.packet_type == PacketType::HandShake as u8 + || hdr.packet_type == PacketType::NoiseHandshakeMsg1 as u8 + || hdr.packet_type == PacketType::NoiseHandshakeMsg2 as u8 + || hdr.packet_type == PacketType::NoiseHandshakeMsg3 as u8) + { + tracing::debug!("credential node dropping forwarded handshake packet"); + continue; + } + if hdr.forward_counter > 2 && hdr.is_latency_first() { tracing::trace!(?hdr, "set_latency_first false because too many hop"); hdr.set_latency_first(false); @@ -934,6 +974,11 @@ impl PeerManager { self.my_peer_id } + async fn get_peer_identity_type(&self, peer_id: PeerId) -> Option { + let peer_map = self.peers.upgrade()?; + peer_map.get_peer_identity_type(peer_id) + } + async fn list_foreign_networks(&self) -> ForeignNetworkRouteInfoMap { let ret = DashMap::new(); let Some(foreign_mgr) = self.foreign_network_manager.upgrade() else { @@ -1965,7 +2010,7 @@ mod tests { return false; }; conns.iter().any(|c| { - c.secure_auth_level == SecureAuthLevel::SharedNodePubkeyVerified as i32 + c.secure_auth_level == SecureAuthLevel::PeerVerified as i32 && c.noise_local_static_pubkey.len() == 32 && c.noise_remote_static_pubkey.len() == 32 }) diff --git a/easytier/src/peers/peer_map.rs b/easytier/src/peers/peer_map.rs index ffe0bce6..5b055a81 100644 --- a/easytier/src/peers/peer_map.rs +++ b/easytier/src/peers/peer_map.rs @@ -16,7 +16,7 @@ use crate::{ }, proto::{ api::instance::{self, PeerConnInfo}, - peer_rpc::RoutePeerInfo, + peer_rpc::{PeerIdentityType, RoutePeerInfo}, }, tunnel::{packet_def::ZCPacket, TunnelError}, }; @@ -56,18 +56,19 @@ impl PeerMap { .issue_event(GlobalCtxEvent::PeerAdded(peer_id)); } - pub async fn add_new_peer_conn(&self, peer_conn: PeerConn) { + pub async fn add_new_peer_conn(&self, peer_conn: PeerConn) -> Result<(), Error> { let _ = self.maintain_alive_client_urls(&peer_conn); let peer_id = peer_conn.get_peer_id(); let no_entry = self.peer_map.get(&peer_id).is_none(); if no_entry { let new_peer = Peer::new(peer_id, self.packet_send.clone(), self.global_ctx.clone()); - new_peer.add_peer_conn(peer_conn).await; + new_peer.add_peer_conn(peer_conn).await?; self.add_new_peer(new_peer).await; } else { let peer = self.peer_map.get(&peer_id).unwrap().clone(); - peer.add_peer_conn(peer_conn).await; + peer.add_peer_conn(peer_conn).await?; } + Ok(()) } fn maintain_alive_client_urls(&self, peer_conn: &PeerConn) -> Option<()> { @@ -302,6 +303,11 @@ impl PeerMap { .map(|p| p.get_default_conn_id()) } + pub fn get_peer_identity_type(&self, peer_id: PeerId) -> Option { + self.get_peer_by_id(peer_id) + .and_then(|p| p.get_peer_identity_type()) + } + pub async fn close_peer_conn( &self, peer_id: PeerId, diff --git a/easytier/src/peers/peer_ospf_route.rs b/easytier/src/peers/peer_ospf_route.rs index 03c5ce7b..35d7584e 100644 --- a/easytier/src/peers/peer_ospf_route.rs +++ b/easytier/src/peers/peer_ospf_route.rs @@ -1,5 +1,5 @@ use std::{ - collections::{BTreeMap, BTreeSet, HashMap}, + collections::{BTreeMap, BTreeSet, HashMap, HashSet}, fmt::Debug, net::{IpAddr, Ipv4Addr, Ipv6Addr}, sync::{ @@ -43,9 +43,10 @@ use crate::{ route_foreign_network_infos, route_foreign_network_summary, sync_route_info_request::ConnInfo, ForeignNetworkRouteInfoEntry, ForeignNetworkRouteInfoKey, OspfRouteRpc, OspfRouteRpcClientFactory, - OspfRouteRpcServer, PeerGroupInfo, PeerIdVersion, RouteForeignNetworkInfos, - RouteForeignNetworkSummary, RoutePeerInfo, RoutePeerInfos, SyncRouteInfoError, - SyncRouteInfoRequest, SyncRouteInfoResponse, + OspfRouteRpcServer, PeerGroupInfo, PeerIdVersion, PeerIdentityType, + RouteForeignNetworkInfos, RouteForeignNetworkSummary, RoutePeerInfo, RoutePeerInfos, + SyncRouteInfoError, SyncRouteInfoRequest, SyncRouteInfoResponse, + TrustedCredentialPubkey, }, rpc_types::{ self, @@ -80,6 +81,26 @@ static REMOVE_UNREACHABLE_PEER_INFO_AFTER: Duration = Duration::from_secs(90); type Version = u32; +/// Check if `child` CIDR is a subset of `parent` CIDR (both as string representations). +/// Returns true if child is contained within parent, or if they are equal. +fn cidr_is_subset_str(child: &str, parent: &str) -> bool { + let Ok(child_cidr) = child.parse::() else { + return false; + }; + let Ok(parent_cidr) = parent.parse::() else { + return false; + }; + match (child_cidr, parent_cidr) { + (IpCidr::V4(c), IpCidr::V4(p)) => { + p.first_address() <= c.first_address() && c.last_address() <= p.last_address() + } + (IpCidr::V6(c), IpCidr::V6(p)) => { + p.first_address() <= c.first_address() && c.last_address() <= p.last_address() + } + _ => false, // mixed v4/v6 + } +} + #[derive(Debug, Clone)] struct AtomicVersion(Arc); @@ -147,6 +168,7 @@ impl RoutePeerInfo { quic_port: None, noise_static_pubkey: Vec::new(), + trusted_credential_pubkeys: Vec::new(), } } @@ -206,6 +228,17 @@ impl RoutePeerInfo { noise_static_pubkey, + // Only admin nodes (holding network_secret) publish trusted credential pubkeys + trusted_credential_pubkeys: if global_ctx + .get_network_identity() + .network_secret + .is_some() + { + global_ctx.get_credential_manager().get_trusted_pubkeys() + } else { + Vec::new() + }, + ..Default::default() } } @@ -336,6 +369,10 @@ struct SyncedRouteInfo { group_trust_map: DashMap>>, group_trust_map_cache: DashMap>>, // cache for group trust map, should sync with group_trust_map + // Aggregated trusted credential pubkeys from all admin nodes + // Maps pubkey bytes -> TrustedCredentialPubkey + trusted_credential_pubkeys: DashMap, TrustedCredentialPubkey>, + version: AtomicVersion, } @@ -352,6 +389,19 @@ impl Debug for SyncedRouteInfo { } impl SyncedRouteInfo { + fn mark_credential_peer(info: &mut RoutePeerInfo, is_credential_peer: bool) { + let mut feature_flag = info.feature_flag.unwrap_or_default(); + feature_flag.is_credential_peer = is_credential_peer; + info.feature_flag = Some(feature_flag); + } + + fn is_credential_peer_info(info: &RoutePeerInfo) -> bool { + info.feature_flag + .as_ref() + .map(|x| x.is_credential_peer) + .unwrap_or(false) + } + fn get_connected_peers>(&self, peer_id: PeerId) -> Option { self.conn_map .read() @@ -830,6 +880,160 @@ impl SyncedRouteInfo { self.group_trust_map_cache .insert(my_peer_id, Arc::new(my_group_names)); } + + /// Collect trusted credential pubkeys from admin nodes (network_secret holders) + /// and verify credential peers. Returns set of peer_ids that should be removed. + /// Also returns a HashMap of trusted keys for synchronization to GlobalCtx. + fn verify_and_update_credential_trusts( + &self, + ) -> ( + Vec, + HashMap, crate::common::global_ctx::TrustedKeyMetadata>, + ) { + use crate::common::global_ctx::{TrustedKeyMetadata, TrustedKeySource}; + + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs() as i64; + + // Step 1: Collect trusted credential pubkeys from admin nodes (take union) + // Only trust nodes whose secret_digest matches ours (i.e. they hold network_secret) + let mut all_trusted: HashMap, TrustedCredentialPubkey> = HashMap::new(); + // Also collect all peer pubkeys for GlobalCtx synchronization + let mut global_trusted_keys: HashMap, TrustedKeyMetadata> = HashMap::new(); + + let peer_infos = self.peer_infos.read(); + + for (_, info) in peer_infos.iter() { + if !self.is_admin_peer(info) { + continue; + } + // Collect all peer noise_static_pubkeys as trusted keys + if !info.noise_static_pubkey.is_empty() { + global_trusted_keys.insert( + info.noise_static_pubkey.clone(), + TrustedKeyMetadata { + source: TrustedKeySource::OspfNode, + expiry_unix: None, // Peer pubkeys never expire + }, + ); + } + for tc in &info.trusted_credential_pubkeys { + if tc.expiry_unix > now { + all_trusted + .entry(tc.pubkey.clone()) + .or_insert_with(|| tc.clone()); + // Also add to global trusted keys + global_trusted_keys.insert( + tc.pubkey.clone(), + TrustedKeyMetadata { + source: TrustedKeySource::OspfCredential, + expiry_unix: Some(tc.expiry_unix), + }, + ); + } + } + } + + // Save the previous trusted set to detect revoked credentials + let prev_trusted: HashSet> = self + .trusted_credential_pubkeys + .iter() + .map(|r| r.key().clone()) + .collect(); + + // Update the trusted_credential_pubkeys map + self.trusted_credential_pubkeys.clear(); + for (k, v) in &all_trusted { + self.trusted_credential_pubkeys.insert(k.clone(), v.clone()); + } + + // Step 2: Update group trust map for credential peers + // Credential peers get their groups from the TrustedCredentialPubkey declaration + for (_, info) in peer_infos.iter() { + if info.noise_static_pubkey.is_empty() { + continue; + } + if let Some(tc) = all_trusted.get(&info.noise_static_pubkey) { + // This peer is a credential peer, assign groups from credential declaration + if !tc.groups.is_empty() { + let mut group_map = HashMap::new(); + let mut group_names = Vec::new(); + for g in &tc.groups { + group_map.insert(g.clone(), Vec::new()); // no proof needed, admin-declared + group_names.push(g.clone()); + } + self.group_trust_map.insert(info.peer_id, group_map); + self.group_trust_map_cache + .insert(info.peer_id, Arc::new(group_names)); + } + } + } + + // Step 3: Find and remove peers with revoked/expired credentials. + // A peer is untrusted if: + // - Its noise_static_pubkey was in the PREVIOUS trusted set (it was a credential peer) + // - Its noise_static_pubkey is NOT in the CURRENT trusted set (credential revoked/expired) + let mut untrusted_peers = Vec::new(); + for (peer_id, info) in peer_infos.iter() { + if info.noise_static_pubkey.is_empty() || info.version == 0 { + continue; + } + // Only remove peers whose pubkey was previously trusted but no longer is + if prev_trusted.contains(&info.noise_static_pubkey) + && !all_trusted.contains_key(&info.noise_static_pubkey) + { + untrusted_peers.push(*peer_id); + } + } + + // Remove untrusted peers from peer_infos so they won't appear in route graph + if !untrusted_peers.is_empty() { + drop(peer_infos); // release read lock before writing + let mut peer_infos_write = self.peer_infos.write(); + for peer_id in &untrusted_peers { + tracing::warn!(?peer_id, "removing untrusted peer from route info"); + peer_infos_write.remove(peer_id); + self.raw_peer_infos.remove(peer_id); + } + drop(peer_infos_write); + // Also remove from conn_map + let mut conn_map = self.conn_map.write(); + for peer_id in &untrusted_peers { + conn_map.remove(peer_id); + } + self.version.inc(); + } + + (untrusted_peers, global_trusted_keys) + } + + fn is_admin_peer(&self, info: &RoutePeerInfo) -> bool { + if info.version == 0 { + return false; + } + !Self::is_credential_peer_info(info) + } + + fn is_credential_peer(&self, peer_id: PeerId) -> bool { + let peer_infos = self.peer_infos.read(); + peer_infos + .get(&peer_id) + .map(Self::is_credential_peer_info) + .unwrap_or(false) + } + + fn get_credential_info(&self, peer_id: PeerId) -> Option { + let peer_infos = self.peer_infos.read(); + let info = peer_infos.get(&peer_id)?; + if info.noise_static_pubkey.is_empty() { + return None; + } + self.trusted_credential_pubkeys + .get(&info.noise_static_pubkey) + .map(|r| r.value().clone()) + } } type PeerGraph = Graph; @@ -977,6 +1181,14 @@ impl RouteTable { start_node: &NodeIndex, version: Version, ) { + if graph.node_weight(*start_node).is_none() { + tracing::warn!( + ?start_node, + version, + "invalid start node for least-hop route rebuild" + ); + return; + } let normalize_edge_cost = |e: petgraph::graph::EdgeReference| { if *e.weight() >= AVOID_RELAY_COST { AVOID_RELAY_COST + 1 @@ -1020,6 +1232,14 @@ impl RouteTable { start_node: &NodeIndex, version: Version, ) { + if graph.node_weight(*start_node).is_none() { + tracing::warn!( + ?start_node, + version, + "invalid start node for least-cost route rebuild" + ); + return; + } let (costs, next_hops) = dijkstra_with_first_hop(&graph, *start_node, |e| *e.weight()); for (dst, (next_hop, path_len)) in next_hops.iter() { @@ -1058,6 +1278,18 @@ impl RouteTable { if graph.node_count() == 0 { tracing::warn!("no peer in graph, cannot build next hop map"); + self.next_hop_map_version.set_if_larger(version); + self.clean_expired_route_info(); + return; + } + if start_node == NodeIndex::end() { + tracing::warn!( + ?my_peer_id, + version, + "my peer id is missing in graph, skip next-hop rebuild this round" + ); + self.next_hop_map_version.set_if_larger(version); + self.clean_expired_route_info(); return; } @@ -1596,6 +1828,7 @@ impl PeerRouteServiceImpl { foreign_network: DashMap::new(), group_trust_map: DashMap::new(), group_trust_map_cache: DashMap::new(), + trusted_credential_pubkeys: DashMap::new(), version: AtomicVersion::new(), }, cached_local_conn_map: std::sync::Mutex::new(RouteConnBitmap::default()), @@ -1607,6 +1840,24 @@ impl PeerRouteServiceImpl { } } + fn get_my_secret_digest(&self) -> Option> { + let ni = self.global_ctx.get_network_identity(); + ni.network_secret_digest.map(|d| d.to_vec()) + } + + fn is_credential_node(&self) -> bool { + self.global_ctx + .get_network_identity() + .network_secret + .is_none() + && self + .global_ctx + .config + .get_secure_mode() + .map(|c| c.enabled) + .unwrap_or(false) + } + fn get_or_create_session(&self, dst_peer_id: PeerId) -> Arc { self.sessions .entry(dst_peer_id) @@ -1640,29 +1891,31 @@ impl PeerRouteServiceImpl { .collect() } + async fn get_peer_identity_type_from_interface( + &self, + peer_id: PeerId, + ) -> Option { + self.interface + .lock() + .await + .as_ref() + .unwrap() + .get_peer_identity_type(peer_id) + .await + } + fn update_my_peer_info(&self) -> bool { - if self.synced_route_info.update_my_peer_info( + self.synced_route_info.update_my_peer_info( self.my_peer_id, self.my_peer_route_id, &self.global_ctx, - ) { - self.update_route_table_and_cached_local_conn_bitmap(); - return true; - } - false + ) } async fn update_my_conn_info(&self) -> bool { let connected_peers: BTreeSet = self.list_peers_from_interface().await; - let updated = self - .synced_route_info - .update_my_conn_info(self.my_peer_id, connected_peers); - - if updated { - self.update_route_table_and_cached_local_conn_bitmap(); - } - - updated + self.synced_route_info + .update_my_conn_info(self.my_peer_id, connected_peers) } async fn update_my_foreign_network(&self) -> bool { @@ -1921,15 +2174,6 @@ impl PeerRouteServiceImpl { // stop iter if last_update of conn info is older than session.last_sync_succ_timestamp let last_update = TryInto::::try_into(conn_info.last_update).unwrap(); if last_sync_succ_timestamp.is_some_and(|t| last_update < t) { - tracing::debug!( - "ignore conn info {:?} because last_update: {:?} is older than last_sync_succ_timestamp: {:?}, conn_map count: {}, my_peer_id: {:?}, session: {:?}", - conn_info, - last_update, - last_sync_succ_timestamp, - conn_map.len(), - self.my_peer_id, - session - ); break; } @@ -2012,7 +2256,16 @@ impl PeerRouteServiceImpl { let my_peer_info_updated = self.update_my_peer_info(); let my_conn_info_updated = self.update_my_conn_info().await; let my_foreign_network_updated = self.update_my_foreign_network().await; - if my_conn_info_updated || my_peer_info_updated { + let mut untrusted_changed = false; + if my_peer_info_updated { + let (untrusted, global_trusted_keys) = + self.synced_route_info.verify_and_update_credential_trusts(); + self.global_ctx.update_trusted_keys(global_trusted_keys); + untrusted_changed = !untrusted.is_empty(); + } + + if my_peer_info_updated || my_conn_info_updated || untrusted_changed { + self.update_route_table_and_cached_local_conn_bitmap(); self.update_foreign_network_owner_map(); } if my_peer_info_updated { @@ -2168,7 +2421,7 @@ impl PeerRouteServiceImpl { return true; } - tracing::debug!(?foreign_network, "sync_route request need send to peer. my_id {:?}, pper_id: {:?}, peer_infos: {:?}, conn_info: {:?}, synced_route_info: {:?} session: {:?}", + tracing::debug!(?foreign_network, "sync_route request need send to peer. my_id {:?}, dst_peer_id: {:?}, peer_infos: {:?}, conn_info: {:?}, synced_route_info: {:?} session: {:?}", my_peer_id, dst_peer_id, peer_infos, conn_info, self.synced_route_info, session); session @@ -2504,16 +2757,28 @@ impl RouteSessionManager { } // find peer_ids that are not initiators. - let initiator_candidates = peers - .iter() - .filter(|x| { - let Some(session) = service_impl.get_session(**x) else { - return true; - }; - !session.dst_is_initiator.load(Ordering::Relaxed) - }) - .copied() - .collect::>(); + let mut initiator_candidates = Vec::new(); + for peer_id in peers.iter().copied() { + // Step 9a: Filter OSPF session candidates based on direct auth level. + // - Credential nodes only initiate sessions to admin nodes (not other credential nodes) + // - Admin nodes don't initiate sessions to credential nodes + let identity_type = service_impl + .get_peer_identity_type_from_interface(peer_id) + .await + .unwrap_or(PeerIdentityType::Admin); + if matches!(identity_type, PeerIdentityType::Credential) { + continue; + } + + let Some(session) = service_impl.get_session(peer_id) else { + initiator_candidates.push(peer_id); + continue; + }; + + if !session.dst_is_initiator.load(Ordering::Relaxed) { + initiator_candidates.push(peer_id); + } + } if initiator_candidates.is_empty() { next_sleep_ms = 1000; @@ -2626,6 +2891,12 @@ impl RouteSessionManager { let my_peer_id = service_impl.my_peer_id; let session = self.get_or_start_session(from_peer_id)?; + let from_identity_type = service_impl + .get_peer_identity_type_from_interface(from_peer_id) + .await + .unwrap_or(PeerIdentityType::Admin); + let from_is_credential = matches!(from_identity_type, PeerIdentityType::Credential); + let _session_lock = session.lock.lock(); session.rpc_rx_count.fetch_add(1, Ordering::Relaxed); @@ -2635,38 +2906,119 @@ impl RouteSessionManager { let mut need_update_route_table = false; if let Some(peer_infos) = &peer_infos { + // Step 9b: credential peers can only propagate their own route info + let normalize_raw = |info: &RoutePeerInfo| { + let mut raw = DynamicMessage::new(RoutePeerInfo::default().descriptor()); + raw.transcode_from(info).unwrap(); + raw + }; + let normalized_peer_infos: Vec; + let normalized_raw_peer_infos: Vec; + let (pi, rpi) = if from_is_credential { + let allowed_cidrs = service_impl + .synced_route_info + .get_credential_info(from_peer_id) + .map(|tc| tc.allowed_proxy_cidrs.clone()) + .unwrap_or_default(); + normalized_peer_infos = peer_infos + .iter() + .filter(|info| info.peer_id == from_peer_id) + .cloned() + .map(|mut info| { + // Filter proxy_cidrs to only those allowed by credential + if !allowed_cidrs.is_empty() { + info.proxy_cidrs.retain(|cidr| { + allowed_cidrs + .iter() + .any(|allowed| cidr_is_subset_str(cidr, allowed)) + }); + } else { + // No allowed_proxy_cidrs → no proxy_cidrs allowed + info.proxy_cidrs.clear(); + } + SyncedRouteInfo::mark_credential_peer(&mut info, true); + info + }) + .collect(); + normalized_raw_peer_infos = + normalized_peer_infos.iter().map(normalize_raw).collect(); + (&normalized_peer_infos, &normalized_raw_peer_infos) + } else { + let mut peer_infos_mut = peer_infos.clone(); + let mut raw_peer_infos_mut = raw_peer_infos + .as_ref() + .cloned() + .unwrap_or_else(|| peer_infos_mut.iter().map(normalize_raw).collect()); + if let Some((idx, info)) = peer_infos_mut + .iter() + .enumerate() + .find(|(_, info)| info.peer_id == from_peer_id) + { + let mut info = info.clone(); + SyncedRouteInfo::mark_credential_peer(&mut info, false); + peer_infos_mut[idx] = info.clone(); + raw_peer_infos_mut[idx] = normalize_raw(&info); + } + normalized_peer_infos = peer_infos_mut; + normalized_raw_peer_infos = raw_peer_infos_mut; + (&normalized_peer_infos, &normalized_raw_peer_infos) + }; + service_impl.synced_route_info.update_peer_infos( my_peer_id, service_impl.my_peer_route_id, from_peer_id, - peer_infos, - raw_peer_infos.as_ref().unwrap(), + pi, + rpi, )?; service_impl .synced_route_info .verify_and_update_group_trusts( - peer_infos, + pi, &service_impl.global_ctx.get_acl_group_declarations(), ); - session.update_dst_saved_peer_info_version(peer_infos, from_peer_id); + session.update_dst_saved_peer_info_version(pi, from_peer_id); need_update_route_table = true; } + // Step 9b: credential peers' conn_info depends on allow_relay flag if let Some(conn_info) = &conn_info { - service_impl.synced_route_info.update_conn_info(conn_info); - session.update_dst_saved_conn_info_version(conn_info, from_peer_id); - need_update_route_table = true; + let accept_conn_info = if from_is_credential { + service_impl + .synced_route_info + .get_credential_info(from_peer_id) + .map(|tc| tc.allow_relay) + .unwrap_or(false) + } else { + true + }; + if accept_conn_info { + service_impl.synced_route_info.update_conn_info(conn_info); + session.update_dst_saved_conn_info_version(conn_info, from_peer_id); + need_update_route_table = true; + } } if need_update_route_table { + // Run credential verification and update route table + let (_untrusted, global_trusted_keys) = service_impl + .synced_route_info + .verify_and_update_credential_trusts(); + // Sync trusted keys to GlobalCtx for handshake verification + service_impl + .global_ctx + .update_trusted_keys(global_trusted_keys); service_impl.update_route_table_and_cached_local_conn_bitmap(); } if let Some(foreign_network) = &foreign_network { - service_impl - .synced_route_info - .update_foreign_network(foreign_network); - session.update_dst_saved_foreign_network_version(foreign_network, from_peer_id); + // Step 9b: credential peers' foreign_network_infos are always ignored + if !from_is_credential { + service_impl + .synced_route_info + .update_foreign_network(foreign_network); + session.update_dst_saved_foreign_network_version(foreign_network, from_peer_id); + } } if need_update_route_table || foreign_network.is_some() { @@ -3041,12 +3393,15 @@ mod tests { create_packet_recv_chan, peer_manager::{PeerManager, RouteAlgoType}, peer_ospf_route::{PeerIdVersion, PeerRouteServiceImpl, FORCE_USE_CONN_LIST}, - route_trait::{NextHopPolicy, Route, RouteCostCalculatorInterface}, + route_trait::{NextHopPolicy, Route, RouteCostCalculatorInterface, RouteInterface}, tests::{connect_peer_manager, create_mock_peer_manager, wait_route_appear}, }, proto::{ - common::NatType, - peer_rpc::{RoutePeerInfo, RoutePeerInfos, SyncRouteInfoRequest}, + common::{NatType, PeerFeatureFlag}, + peer_rpc::{ + PeerIdentityType, RoutePeerInfo, RoutePeerInfos, SyncRouteInfoRequest, + TrustedCredentialPubkey, + }, }, tunnel::common::tests::wait_for_condition, }; @@ -3054,6 +3409,26 @@ mod tests { use super::PeerRoute; + struct AuthOnlyInterface { + my_peer_id: PeerId, + identity_type: DashMap, + } + + #[async_trait::async_trait] + impl RouteInterface for AuthOnlyInterface { + async fn list_peers(&self) -> Vec { + Vec::new() + } + + fn my_peer_id(&self) -> PeerId { + self.my_peer_id + } + + async fn get_peer_identity_type(&self, peer_id: PeerId) -> Option { + self.identity_type.get(&peer_id).map(|x| *x.value()) + } + } + async fn create_mock_route(peer_mgr: Arc) -> Arc { let peer_route = PeerRoute::new( peer_mgr.my_peer_id(), @@ -3098,6 +3473,213 @@ mod tests { assert!(rx1 <= max_rx); } + #[tokio::test] + async fn credential_flag_controls_role_classification() { + let service_impl = PeerRouteServiceImpl::new(1, get_mock_global_ctx()); + + let mut admin_info = RoutePeerInfo::new(); + admin_info.peer_id = 10; + admin_info.version = 1; + admin_info.feature_flag = Some(PeerFeatureFlag { + is_credential_peer: false, + ..Default::default() + }); + + let mut credential_info = RoutePeerInfo::new(); + credential_info.peer_id = 11; + credential_info.version = 1; + credential_info.feature_flag = Some(PeerFeatureFlag { + is_credential_peer: true, + ..Default::default() + }); + + { + let mut guard = service_impl.synced_route_info.peer_infos.write(); + guard.insert(admin_info.peer_id, admin_info.clone()); + guard.insert(credential_info.peer_id, credential_info.clone()); + } + + assert!(service_impl.synced_route_info.is_admin_peer(&admin_info)); + assert!(!service_impl + .synced_route_info + .is_admin_peer(&credential_info)); + assert!(service_impl + .synced_route_info + .is_credential_peer(credential_info.peer_id)); + assert!(!service_impl + .synced_route_info + .is_credential_peer(admin_info.peer_id)); + } + + #[tokio::test] + async fn trusted_credentials_only_from_admin_publishers() { + let service_impl = PeerRouteServiceImpl::new(1, get_mock_global_ctx()); + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs() as i64; + + let admin_key = vec![1; 32]; + let credential_key = vec![2; 32]; + + let mut admin_info = RoutePeerInfo::new(); + admin_info.peer_id = 20; + admin_info.version = 1; + admin_info.feature_flag = Some(PeerFeatureFlag { + is_credential_peer: false, + ..Default::default() + }); + admin_info.trusted_credential_pubkeys = vec![TrustedCredentialPubkey { + pubkey: admin_key.clone(), + expiry_unix: now + 600, + ..Default::default() + }]; + + let mut credential_info = RoutePeerInfo::new(); + credential_info.peer_id = 21; + credential_info.version = 1; + credential_info.feature_flag = Some(PeerFeatureFlag { + is_credential_peer: true, + ..Default::default() + }); + credential_info.trusted_credential_pubkeys = vec![TrustedCredentialPubkey { + pubkey: credential_key.clone(), + expiry_unix: now + 600, + ..Default::default() + }]; + + { + let mut guard = service_impl.synced_route_info.peer_infos.write(); + guard.insert(admin_info.peer_id, admin_info); + guard.insert(credential_info.peer_id, credential_info); + } + + service_impl + .synced_route_info + .verify_and_update_credential_trusts(); + + assert!(service_impl + .synced_route_info + .trusted_credential_pubkeys + .contains_key(&admin_key)); + assert!(!service_impl + .synced_route_info + .trusted_credential_pubkeys + .contains_key(&credential_key)); + } + + #[tokio::test] + async fn sync_route_info_marks_credential_sender_and_filters_entries() { + let peer_mgr = create_mock_pmgr().await; + let route = create_mock_route(peer_mgr.clone()).await; + let from_peer_id: PeerId = 10001; + let forwarded_peer_id: PeerId = 10002; + + let identity_type = DashMap::new(); + identity_type.insert(from_peer_id, PeerIdentityType::Credential); + *route.service_impl.interface.lock().await = Some(Box::new(AuthOnlyInterface { + my_peer_id: peer_mgr.my_peer_id(), + identity_type, + })); + + let mut sender_info = RoutePeerInfo::new(); + sender_info.peer_id = from_peer_id; + sender_info.version = 1; + sender_info.proxy_cidrs = vec!["10.10.0.0/24".to_string()]; + + let mut forwarded_info = RoutePeerInfo::new(); + forwarded_info.peer_id = forwarded_peer_id; + forwarded_info.version = 1; + + let make_raw = |info: &RoutePeerInfo| { + let mut raw = DynamicMessage::new(RoutePeerInfo::default().descriptor()); + raw.transcode_from(info).unwrap(); + raw + }; + let raw_infos = vec![make_raw(&sender_info), make_raw(&forwarded_info)]; + + route + .session_mgr + .do_sync_route_info( + from_peer_id, + 1, + true, + Some(vec![sender_info, forwarded_info]), + Some(raw_infos), + None, + None, + ) + .await + .unwrap(); + + let guard = route.service_impl.synced_route_info.peer_infos.read(); + let stored = guard.get(&from_peer_id).unwrap(); + assert!(stored + .feature_flag + .as_ref() + .map(|x| x.is_credential_peer) + .unwrap_or(false)); + assert!(stored.proxy_cidrs.is_empty()); + assert!(guard.get(&forwarded_peer_id).is_none()); + } + + #[tokio::test] + async fn sync_route_info_forces_non_credential_for_legacy_admin_sender() { + let peer_mgr = create_mock_pmgr().await; + let route = create_mock_route(peer_mgr.clone()).await; + let from_peer_id: PeerId = 10011; + let other_peer_id: PeerId = 10012; + + let identity_type = DashMap::new(); + identity_type.insert(from_peer_id, PeerIdentityType::Admin); + *route.service_impl.interface.lock().await = Some(Box::new(AuthOnlyInterface { + my_peer_id: peer_mgr.my_peer_id(), + identity_type, + })); + + let mut sender_info = RoutePeerInfo::new(); + sender_info.peer_id = from_peer_id; + sender_info.version = 1; + sender_info.feature_flag = Some(PeerFeatureFlag { + is_credential_peer: true, + ..Default::default() + }); + + let mut other_info = RoutePeerInfo::new(); + other_info.peer_id = other_peer_id; + other_info.version = 1; + + let make_raw = |info: &RoutePeerInfo| { + let mut raw = DynamicMessage::new(RoutePeerInfo::default().descriptor()); + raw.transcode_from(info).unwrap(); + raw + }; + let raw_infos = vec![make_raw(&sender_info), make_raw(&other_info)]; + + route + .session_mgr + .do_sync_route_info( + from_peer_id, + 1, + true, + Some(vec![sender_info, other_info]), + Some(raw_infos), + None, + None, + ) + .await + .unwrap(); + + let guard = route.service_impl.synced_route_info.peer_infos.read(); + let sender = guard.get(&from_peer_id).unwrap(); + assert!(!sender + .feature_flag + .as_ref() + .map(|x| x.is_credential_peer) + .unwrap_or(false)); + assert!(guard.get(&other_peer_id).is_some()); + } + #[rstest::rstest] #[tokio::test] async fn ospf_route_2node(#[values(true, false)] enable_conn_list_sync: bool) { diff --git a/easytier/src/peers/peer_session.rs b/easytier/src/peers/peer_session.rs index f22ffcb3..3e1a7c95 100644 --- a/easytier/src/peers/peer_session.rs +++ b/easytier/src/peers/peer_session.rs @@ -787,7 +787,15 @@ impl PeerSession { let encryptor = self .get_encryptor(epoch, dir, true) .ok_or_else(|| anyhow!("no key for epoch"))?; - let _ = encryptor.encrypt_with_nonce(pkt, Some(nonce_bytes.as_slice())); + if let Err(e) = encryptor.encrypt_with_nonce(pkt, Some(nonce_bytes.as_slice())) { + tracing::warn!( + peer_id = ?self.peer_id, + ?e, + "session encrypt failed, invalidating" + ); + self.invalidate(); + return Err(e.into()); + } Ok(()) } diff --git a/easytier/src/peers/route_trait.rs b/easytier/src/peers/route_trait.rs index ef9b99c4..e223b3b2 100644 --- a/easytier/src/peers/route_trait.rs +++ b/easytier/src/peers/route_trait.rs @@ -8,8 +8,8 @@ use dashmap::DashMap; use crate::{ common::{global_ctx::NetworkIdentity, PeerId}, proto::peer_rpc::{ - ForeignNetworkRouteInfoEntry, ForeignNetworkRouteInfoKey, RouteForeignNetworkInfos, - RouteForeignNetworkSummary, RoutePeerInfo, + ForeignNetworkRouteInfoEntry, ForeignNetworkRouteInfoKey, PeerIdentityType, + RouteForeignNetworkInfos, RouteForeignNetworkSummary, RoutePeerInfo, }, }; @@ -27,6 +27,9 @@ pub type ForeignNetworkRouteInfoMap = pub trait RouteInterface { async fn list_peers(&self) -> Vec; fn my_peer_id(&self) -> PeerId; + async fn get_peer_identity_type(&self, _peer_id: PeerId) -> Option { + None + } async fn list_foreign_networks(&self) -> ForeignNetworkRouteInfoMap { DashMap::new() } diff --git a/easytier/src/peers/rpc_service.rs b/easytier/src/peers/rpc_service.rs index f5ffef67..4263f79b 100644 --- a/easytier/src/peers/rpc_service.rs +++ b/easytier/src/peers/rpc_service.rs @@ -1,17 +1,21 @@ use std::{ ops::Deref, sync::{Arc, Weak}, + time::Duration, }; use crate::{ proto::{ api::instance::{ - AclManageRpc, DumpRouteRequest, DumpRouteResponse, GetAclStatsRequest, + AclManageRpc, CredentialManageRpc, DumpRouteRequest, DumpRouteResponse, + GenerateCredentialRequest, GenerateCredentialResponse, GetAclStatsRequest, GetAclStatsResponse, GetForeignNetworkSummaryRequest, GetForeignNetworkSummaryResponse, - GetWhitelistRequest, GetWhitelistResponse, ListForeignNetworkRequest, - ListForeignNetworkResponse, ListGlobalForeignNetworkRequest, - ListGlobalForeignNetworkResponse, ListPeerRequest, ListPeerResponse, ListRouteRequest, - ListRouteResponse, PeerInfo, PeerManageRpc, ShowNodeInfoRequest, ShowNodeInfoResponse, + GetWhitelistRequest, GetWhitelistResponse, ListCredentialsRequest, + ListCredentialsResponse, ListForeignNetworkRequest, ListForeignNetworkResponse, + ListGlobalForeignNetworkRequest, ListGlobalForeignNetworkResponse, ListPeerRequest, + ListPeerResponse, ListRouteRequest, ListRouteResponse, PeerInfo, PeerManageRpc, + RevokeCredentialRequest, RevokeCredentialResponse, ShowNodeInfoRequest, + ShowNodeInfoResponse, }, rpc_types::{self, controller::BaseController}, }, @@ -201,3 +205,77 @@ impl AclManageRpc for PeerManagerRpcService { }) } } + +#[async_trait::async_trait] +impl CredentialManageRpc for PeerManagerRpcService { + type Controller = BaseController; + + async fn generate_credential( + &self, + _: BaseController, + request: GenerateCredentialRequest, + ) -> Result { + let pm = weak_upgrade(&self.peer_manager)?; + let global_ctx = pm.get_global_ctx(); + + if global_ctx.get_network_identity().network_secret.is_none() { + return Err(rpc_types::error::Error::ExecutionError(anyhow::anyhow!( + "only admin nodes (with network_secret) can generate credentials" + ))); + } + + let ttl = if request.ttl_seconds > 0 { + Duration::from_secs(request.ttl_seconds as u64) + } else { + return Err(rpc_types::error::Error::ExecutionError(anyhow::anyhow!( + "ttl_seconds must be positive" + ))); + }; + + let (id, secret) = global_ctx.get_credential_manager().generate_credential( + request.groups, + request.allow_relay, + request.allowed_proxy_cidrs, + ttl, + ); + + global_ctx.issue_event(crate::common::global_ctx::GlobalCtxEvent::CredentialChanged); + + Ok(GenerateCredentialResponse { + credential_id: id, + credential_secret: secret, + }) + } + + async fn revoke_credential( + &self, + _: BaseController, + request: RevokeCredentialRequest, + ) -> Result { + let pm = weak_upgrade(&self.peer_manager)?; + let global_ctx = pm.get_global_ctx(); + + let success = global_ctx + .get_credential_manager() + .revoke_credential(&request.credential_id); + + if success { + global_ctx.issue_event(crate::common::global_ctx::GlobalCtxEvent::CredentialChanged); + } + + Ok(RevokeCredentialResponse { success }) + } + + async fn list_credentials( + &self, + _: BaseController, + _request: ListCredentialsRequest, + ) -> Result { + let pm = weak_upgrade(&self.peer_manager)?; + let global_ctx = pm.get_global_ctx(); + + Ok(ListCredentialsResponse { + credentials: global_ctx.get_credential_manager().list_credentials(), + }) + } +} diff --git a/easytier/src/peers/tests.rs b/easytier/src/peers/tests.rs index 3bf690ce..3507c0d2 100644 --- a/easytier/src/peers/tests.rs +++ b/easytier/src/peers/tests.rs @@ -1,6 +1,8 @@ use std::sync::Arc; use std::time::Duration; +use base64::Engine as _; + use crate::{ common::{ error::Error, @@ -707,3 +709,467 @@ async fn relay_peer_map_bidirectional_handshake_race() { "peer_c should have session with peer_a" ); } + +/// Helper: create a secure peer manager for a credential node. +/// Uses the given X25519 private key as the Noise static key, with no network_secret. +pub async fn create_mock_peer_manager_credential( + network_name: String, + private_key: &x25519_dalek::StaticSecret, +) -> Arc { + use crate::common::config::NetworkIdentity; + use crate::proto::common::SecureModeConfig; + use base64::engine::general_purpose::STANDARD as BASE64_STANDARD; + use base64::Engine; + + let (s, _r) = create_packet_recv_chan(); + let g = get_mock_global_ctx_with_network(Some(NetworkIdentity::new_credential(network_name))); + + let public = x25519_dalek::PublicKey::from(private_key); + g.config.set_secure_mode(Some(SecureModeConfig { + enabled: true, + local_private_key: Some(BASE64_STANDARD.encode(private_key.as_bytes())), + local_public_key: Some(BASE64_STANDARD.encode(public.as_bytes())), + })); + + let peer_mgr = Arc::new(PeerManager::new(RouteAlgoType::Ospf, g, s)); + peer_mgr.run().await.unwrap(); + peer_mgr +} + +/// Test: credential node joins a 2-admin network and routes appear. +/// Topology: Admin_A -- Credential_C, Admin_A -- Admin_B +/// Credential node connects to the admin that generated the credential. +#[tokio::test] +async fn credential_node_joins_network() { + let admin_a = create_mock_peer_manager_secure("net1".to_string(), "secret".to_string()).await; + let admin_b = create_mock_peer_manager_secure("net1".to_string(), "secret".to_string()).await; + + // Generate credential on admin_a + let (_cred_id, cred_secret) = admin_a + .get_global_ctx() + .get_credential_manager() + .generate_credential( + vec!["guest".to_string()], + false, + vec![], + std::time::Duration::from_secs(3600), + ); + + // Create credential node using the generated key + let privkey_bytes: [u8; 32] = base64::engine::general_purpose::STANDARD + .decode(&cred_secret) + .unwrap() + .try_into() + .unwrap(); + let private = x25519_dalek::StaticSecret::from(privkey_bytes); + let cred_c = create_mock_peer_manager_credential("net1".to_string(), &private).await; + + // Connect admins first + connect_peer_manager(admin_a.clone(), admin_b.clone()).await; + + // Admin A and B should discover each other + wait_route_appear(admin_a.clone(), admin_b.clone()) + .await + .unwrap(); + + // Now connect credential node to admin A (credential as client) + connect_peer_manager(cred_c.clone(), admin_a.clone()).await; + + // Credential node C should be reachable from admin B (via A) + let cred_c_id = cred_c.my_peer_id(); + wait_for_condition( + || { + let admin_b = admin_b.clone(); + async move { + admin_b + .list_routes() + .await + .iter() + .any(|r| r.peer_id == cred_c_id) + } + }, + Duration::from_secs(10), + ) + .await; + + // Credential node C should see admin B + wait_for_condition( + || { + let cred_c = cred_c.clone(); + let admin_b_id = admin_b.my_peer_id(); + async move { + cred_c + .list_routes() + .await + .iter() + .any(|r| r.peer_id == admin_b_id) + } + }, + Duration::from_secs(10), + ) + .await; +} + +/// Test: credential node is rejected when its pubkey is not in any admin's trusted list. +/// Topology: Admin_A -- Unknown_B (random key, not in trusted list) +#[tokio::test] +async fn unknown_credential_node_rejected() { + let admin_a = create_mock_peer_manager_secure("net1".to_string(), "secret".to_string()).await; + + // Create a credential node with a random key (NOT generated by admin) + let random_private = x25519_dalek::StaticSecret::random_from_rng(rand::rngs::OsRng); + let unknown_c = create_mock_peer_manager_credential("net1".to_string(), &random_private).await; + + // Try to connect: C -> A (unknown credential as client, admin as server) + connect_peer_manager(unknown_c.clone(), admin_a.clone()).await; + + // The handshake should fail so the connection won't establish. + // Wait a bit and verify no route appears. + tokio::time::sleep(Duration::from_secs(3)).await; + + let routes = admin_a.list_routes().await; + assert!( + !routes.iter().any(|r| r.peer_id == unknown_c.my_peer_id()), + "unknown credential node should NOT appear in admin's routes" + ); +} + +/// Test: after revocation, the credential node disappears from routes. +/// Topology: Admin_A -- Credential_C, Admin_A -- Admin_B +/// After revocation on A, C should be removed from B's route table. +#[tokio::test] +async fn credential_revocation_removes_from_routes() { + let admin_a = create_mock_peer_manager_secure("net1".to_string(), "secret".to_string()).await; + let admin_b = create_mock_peer_manager_secure("net1".to_string(), "secret".to_string()).await; + + let (cred_id, cred_secret) = admin_a + .get_global_ctx() + .get_credential_manager() + .generate_credential(vec![], false, vec![], std::time::Duration::from_secs(3600)); + + let privkey_bytes: [u8; 32] = base64::engine::general_purpose::STANDARD + .decode(&cred_secret) + .unwrap() + .try_into() + .unwrap(); + let private = x25519_dalek::StaticSecret::from(privkey_bytes); + let cred_c = create_mock_peer_manager_credential("net1".to_string(), &private).await; + + // Connect: A -- B, C -> A (credential node as client, admin as server) + connect_peer_manager(admin_a.clone(), admin_b.clone()).await; + connect_peer_manager(cred_c.clone(), admin_a.clone()).await; + + // Wait for credential node to appear in admin_b's routes + let cred_c_id = cred_c.my_peer_id(); + wait_for_condition( + || { + let admin_b = admin_b.clone(); + async move { + admin_b + .list_routes() + .await + .iter() + .any(|r| r.peer_id == cred_c_id) + } + }, + Duration::from_secs(10), + ) + .await; + + // Now revoke the credential + assert!(admin_a + .get_global_ctx() + .get_credential_manager() + .revoke_credential(&cred_id)); + // Issue event to trigger OSPF sync + admin_a + .get_global_ctx() + .issue_event(crate::common::global_ctx::GlobalCtxEvent::CredentialChanged); + + // Wait for credential node to disappear from admin_b's routes + wait_for_condition( + || { + let admin_b = admin_b.clone(); + async move { + !admin_b + .list_routes() + .await + .iter() + .any(|r| r.peer_id == cred_c_id) + } + }, + Duration::from_secs(15), + ) + .await; +} + +/// Test: admin node with credential — credential node gets group assignment. +/// Verify that the credential node's groups appear in the OSPF sync data. +#[tokio::test] +async fn credential_node_group_assignment() { + let admin_a = create_mock_peer_manager_secure("net1".to_string(), "secret".to_string()).await; + let admin_b = create_mock_peer_manager_secure("net1".to_string(), "secret".to_string()).await; + + let (_cred_id, cred_secret) = admin_a + .get_global_ctx() + .get_credential_manager() + .generate_credential( + vec!["guest".to_string(), "limited".to_string()], + false, + vec![], + std::time::Duration::from_secs(3600), + ); + + let privkey_bytes: [u8; 32] = base64::engine::general_purpose::STANDARD + .decode(&cred_secret) + .unwrap() + .try_into() + .unwrap(); + let private = x25519_dalek::StaticSecret::from(privkey_bytes); + let cred_c = create_mock_peer_manager_credential("net1".to_string(), &private).await; + + connect_peer_manager(admin_a.clone(), admin_b.clone()).await; + connect_peer_manager(cred_c.clone(), admin_a.clone()).await; + + // Wait for credential node route to appear on admin_b (via OSPF through admin_a) + let cred_c_id = cred_c.my_peer_id(); + wait_for_condition( + || { + let admin_b = admin_b.clone(); + async move { + admin_b + .list_routes() + .await + .iter() + .any(|r| r.peer_id == cred_c_id) + } + }, + Duration::from_secs(10), + ) + .await; + + // Verify the credential node's groups are assigned via OSPF on admin_b + // (admin_b gets the groups from admin_a's TrustedCredentialPubkey via OSPF sync) + wait_for_condition( + || { + let admin_b = admin_b.clone(); + async move { + let g = admin_b.get_route().get_peer_groups(cred_c_id); + g.contains(&"guest".to_string()) && g.contains(&"limited".to_string()) + } + }, + Duration::from_secs(10), + ) + .await; +} + +/// Minimal test: two secure peers connect and discover each other's route. +#[tokio::test] +async fn two_secure_peers_route_appear() { + let peer_a = create_mock_peer_manager_secure("net1".to_string(), "sec1".to_string()).await; + let peer_b = create_mock_peer_manager_secure("net1".to_string(), "sec1".to_string()).await; + + connect_peer_manager(peer_a.clone(), peer_b.clone()).await; + + wait_route_appear(peer_a.clone(), peer_b.clone()) + .await + .unwrap(); +} + +#[tokio::test] +async fn multi_admin_multi_credential_route_and_revocation_isolation() { + let admin_a = create_mock_peer_manager_secure("net1".to_string(), "secret".to_string()).await; + let admin_b = create_mock_peer_manager_secure("net1".to_string(), "secret".to_string()).await; + let admin_d = create_mock_peer_manager_secure("net1".to_string(), "secret".to_string()).await; + + connect_peer_manager(admin_a.clone(), admin_b.clone()).await; + connect_peer_manager(admin_b.clone(), admin_d.clone()).await; + connect_peer_manager(admin_a.clone(), admin_d.clone()).await; + + wait_route_appear(admin_a.clone(), admin_b.clone()) + .await + .unwrap(); + wait_route_appear(admin_b.clone(), admin_d.clone()) + .await + .unwrap(); + wait_route_appear(admin_a.clone(), admin_d.clone()) + .await + .unwrap(); + + let (cred1_id, cred1_secret) = admin_a + .get_global_ctx() + .get_credential_manager() + .generate_credential( + vec!["guest-a".to_string()], + false, + vec![], + std::time::Duration::from_secs(3600), + ); + let (_cred2_id, cred2_secret) = admin_b + .get_global_ctx() + .get_credential_manager() + .generate_credential( + vec!["guest-b".to_string()], + false, + vec![], + std::time::Duration::from_secs(3600), + ); + + let cred1_private: [u8; 32] = base64::engine::general_purpose::STANDARD + .decode(&cred1_secret) + .unwrap() + .try_into() + .unwrap(); + let cred2_private: [u8; 32] = base64::engine::general_purpose::STANDARD + .decode(&cred2_secret) + .unwrap() + .try_into() + .unwrap(); + let cred_1 = create_mock_peer_manager_credential( + "net1".to_string(), + &x25519_dalek::StaticSecret::from(cred1_private), + ) + .await; + let cred_2 = create_mock_peer_manager_credential( + "net1".to_string(), + &x25519_dalek::StaticSecret::from(cred2_private), + ) + .await; + + connect_peer_manager(cred_1.clone(), admin_a.clone()).await; + connect_peer_manager(cred_2.clone(), admin_b.clone()).await; + + let cred_1_id = cred_1.my_peer_id(); + let cred_2_id = cred_2.my_peer_id(); + + wait_for_condition( + || { + let admin_d = admin_d.clone(); + async move { + let routes = admin_d.list_routes().await; + routes.iter().any(|r| r.peer_id == cred_1_id) + && routes.iter().any(|r| r.peer_id == cred_2_id) + } + }, + Duration::from_secs(15), + ) + .await; + + wait_for_condition( + || { + let admin_d = admin_d.clone(); + async move { + let g1 = admin_d.get_route().get_peer_groups(cred_1_id); + let g2 = admin_d.get_route().get_peer_groups(cred_2_id); + g1.contains(&"guest-a".to_string()) && g2.contains(&"guest-b".to_string()) + } + }, + Duration::from_secs(15), + ) + .await; + + assert!(admin_a + .get_global_ctx() + .get_credential_manager() + .revoke_credential(&cred1_id)); + admin_a + .get_global_ctx() + .issue_event(crate::common::global_ctx::GlobalCtxEvent::CredentialChanged); + + wait_for_condition( + || { + let admin_d = admin_d.clone(); + async move { + let routes = admin_d.list_routes().await; + !routes.iter().any(|r| r.peer_id == cred_1_id) + && routes.iter().any(|r| r.peer_id == cred_2_id) + } + }, + Duration::from_secs(20), + ) + .await; +} + +#[tokio::test] +async fn unknown_credential_rejected_while_valid_credential_survives() { + let admin_a = create_mock_peer_manager_secure("net1".to_string(), "secret".to_string()).await; + let admin_b = create_mock_peer_manager_secure("net1".to_string(), "secret".to_string()).await; + + connect_peer_manager(admin_a.clone(), admin_b.clone()).await; + wait_route_appear(admin_a.clone(), admin_b.clone()) + .await + .unwrap(); + + let (_cred_id, cred_secret) = admin_a + .get_global_ctx() + .get_credential_manager() + .generate_credential( + vec!["stable".to_string()], + false, + vec![], + std::time::Duration::from_secs(3600), + ); + + let valid_private: [u8; 32] = base64::engine::general_purpose::STANDARD + .decode(&cred_secret) + .unwrap() + .try_into() + .unwrap(); + let valid_cred = create_mock_peer_manager_credential( + "net1".to_string(), + &x25519_dalek::StaticSecret::from(valid_private), + ) + .await; + let unknown_private = x25519_dalek::StaticSecret::random_from_rng(rand::rngs::OsRng); + let unknown_cred = + create_mock_peer_manager_credential("net1".to_string(), &unknown_private).await; + + connect_peer_manager(valid_cred.clone(), admin_a.clone()).await; + let (unknown_ring_client, unknown_ring_server) = create_ring_tunnel_pair(); + let unknown_connect_client = tokio::spawn({ + let unknown_cred = unknown_cred.clone(); + async move { + unknown_cred + .add_client_tunnel(unknown_ring_client, false) + .await + } + }); + let unknown_connect_server = tokio::spawn({ + let admin_a = admin_a.clone(); + async move { + admin_a + .add_tunnel_as_server(unknown_ring_server, true) + .await + } + }); + let (unknown_client_ret, unknown_server_ret) = + tokio::join!(unknown_connect_client, unknown_connect_server); + assert!( + unknown_client_ret.unwrap().is_err() || unknown_server_ret.unwrap().is_err(), + "unknown credential connection should fail on at least one side" + ); + + let valid_id = valid_cred.my_peer_id(); + let unknown_id = unknown_cred.my_peer_id(); + + wait_for_condition( + || { + let admin_b = admin_b.clone(); + async move { + admin_b + .list_routes() + .await + .iter() + .any(|r| r.peer_id == valid_id) + } + }, + Duration::from_secs(15), + ) + .await; + + tokio::time::sleep(Duration::from_secs(5)).await; + + let routes = admin_b.list_routes().await; + assert!(routes.iter().any(|r| r.peer_id == valid_id)); + assert!(!routes.iter().any(|r| r.peer_id == unknown_id)); +} diff --git a/easytier/src/proto/api_instance.proto b/easytier/src/proto/api_instance.proto index e6947cc8..f79e429a 100644 --- a/easytier/src/proto/api_instance.proto +++ b/easytier/src/proto/api_instance.proto @@ -44,6 +44,7 @@ message PeerConnInfo { bytes noise_local_static_pubkey = 11; bytes noise_remote_static_pubkey = 12; peer_rpc.SecureAuthLevel secure_auth_level = 13; + peer_rpc.PeerIdentityType peer_identity_type = 14; } message PeerInfo { @@ -291,3 +292,45 @@ service StatsRpc { rpc GetPrometheusStats(GetPrometheusStatsRequest) returns (GetPrometheusStatsResponse); } + +// Credential management messages + +message GenerateCredentialRequest { + repeated string groups = 1; // optional: ACL groups for this credential + bool allow_relay = 2; // optional: allow relay through credential node + repeated string allowed_proxy_cidrs = 3; // optional: restrict proxy_cidrs + int64 ttl_seconds = 4; // must be > 0: credential TTL in seconds (0 / omitted is invalid) +} + +message GenerateCredentialResponse { + string credential_id = 1; // public key base64 + string credential_secret = 2; // private key base64 +} + +message RevokeCredentialRequest { + string credential_id = 1; +} + +message RevokeCredentialResponse { + bool success = 1; +} + +message ListCredentialsRequest {} + +message CredentialInfo { + string credential_id = 1; // public key base64 + repeated string groups = 2; + bool allow_relay = 3; + int64 expiry_unix = 4; + repeated string allowed_proxy_cidrs = 5; +} + +message ListCredentialsResponse { + repeated CredentialInfo credentials = 1; +} + +service CredentialManageRpc { + rpc GenerateCredential(GenerateCredentialRequest) returns (GenerateCredentialResponse); + rpc RevokeCredential(RevokeCredentialRequest) returns (RevokeCredentialResponse); + rpc ListCredentials(ListCredentialsRequest) returns (ListCredentialsResponse); +} diff --git a/easytier/src/proto/common.proto b/easytier/src/proto/common.proto index bb9f82b9..1ef107b2 100644 --- a/easytier/src/proto/common.proto +++ b/easytier/src/proto/common.proto @@ -216,6 +216,7 @@ message PeerFeatureFlag { bool support_conn_list_sync = 5; bool quic_input = 6; bool no_relay_quic = 7; + bool is_credential_peer = 8; } enum SocketType { diff --git a/easytier/src/proto/peer_rpc.proto b/easytier/src/proto/peer_rpc.proto index 9792dbb4..84538b71 100644 --- a/easytier/src/proto/peer_rpc.proto +++ b/easytier/src/proto/peer_rpc.proto @@ -5,6 +5,14 @@ import "common.proto"; package peer_rpc; +message TrustedCredentialPubkey { + bytes pubkey = 1; // X25519 public key (32 bytes) + repeated string groups = 2; // ACL groups this credential belongs to + bool allow_relay = 3; // whether this credential node can relay data + int64 expiry_unix = 4; // expiry time (Unix timestamp) + repeated string allowed_proxy_cidrs = 5; // allowed proxy_cidrs ranges +} + message RoutePeerInfo { // means next hop in route table. uint32 peer_id = 1; @@ -30,6 +38,9 @@ message RoutePeerInfo { common.NatType tcp_nat_type = 17; bytes noise_static_pubkey = 18; + + // Trusted credential public keys published by admin nodes (holding network_secret) + repeated TrustedCredentialPubkey trusted_credential_pubkeys = 19; } message PeerIdVersion { @@ -263,10 +274,16 @@ message KcpConnData { enum SecureAuthLevel { None = 0; EncryptedUnauthenticated = 1; - SharedNodePubkeyVerified = 2; + PeerVerified = 2; NetworkSecretConfirmed = 3; } +enum PeerIdentityType { + Admin = 0; + Credential = 1; + SharedNode = 2; +} + enum PeerConnSessionActionPb { Join = 0; Sync = 1; diff --git a/easytier/src/rpc_service/api.rs b/easytier/src/rpc_service/api.rs index 37051e4d..23d2149e 100644 --- a/easytier/src/rpc_service/api.rs +++ b/easytier/src/rpc_service/api.rs @@ -10,9 +10,9 @@ use crate::{ api::{ config::ConfigRpcServer, instance::{ - AclManageRpcServer, ConnectorManageRpcServer, MappedListenerManageRpcServer, - PeerManageRpcServer, PortForwardManageRpcServer, StatsRpcServer, TcpProxyRpcServer, - VpnPortalRpcServer, + AclManageRpcServer, ConnectorManageRpcServer, CredentialManageRpcServer, + MappedListenerManageRpcServer, PeerManageRpcServer, PortForwardManageRpcServer, + StatsRpcServer, TcpProxyRpcServer, VpnPortalRpcServer, }, logger::LoggerRpcServer, manage::WebClientServiceServer, @@ -23,8 +23,9 @@ use crate::{ }, rpc_service::{ acl_manage::AclManageRpcService, config::ConfigRpcService, - connector_manage::ConnectorManageRpcService, instance_manage::InstanceManageRpcService, - logger::LoggerRpcService, mapped_listener_manage::MappedListenerManageRpcService, + connector_manage::ConnectorManageRpcService, credential_manage::CredentialManageRpcService, + instance_manage::InstanceManageRpcService, logger::LoggerRpcService, + mapped_listener_manage::MappedListenerManageRpcService, peer_center::PeerCenterManageRpcService, peer_manage::PeerManageRpcService, port_forward_manage::PortForwardManageRpcService, proxy::TcpProxyRpcService, stats::StatsRpcService, vpn_portal::VpnPortalRpcService, @@ -156,6 +157,11 @@ fn register_api_rpc_service( PeerCenterRpcServer::new(PeerCenterManageRpcService::new(instance_manager.clone())), "", ); + + registry.register( + CredentialManageRpcServer::new(CredentialManageRpcService::new(instance_manager.clone())), + "", + ); } fn parse_rpc_portal(rpc_portal: Option) -> anyhow::Result { diff --git a/easytier/src/rpc_service/credential_manage.rs b/easytier/src/rpc_service/credential_manage.rs new file mode 100644 index 00000000..5b13d0bc --- /dev/null +++ b/easytier/src/rpc_service/credential_manage.rs @@ -0,0 +1,62 @@ +use std::sync::Arc; + +use crate::{ + instance_manager::NetworkInstanceManager, + proto::{ + api::instance::{ + CredentialManageRpc, GenerateCredentialRequest, GenerateCredentialResponse, + ListCredentialsRequest, ListCredentialsResponse, RevokeCredentialRequest, + RevokeCredentialResponse, + }, + rpc_types::controller::BaseController, + }, +}; + +#[derive(Clone)] +pub struct CredentialManageRpcService { + instance_manager: Arc, +} + +impl CredentialManageRpcService { + pub fn new(instance_manager: Arc) -> Self { + Self { instance_manager } + } +} + +#[async_trait::async_trait] +impl CredentialManageRpc for CredentialManageRpcService { + type Controller = BaseController; + + async fn generate_credential( + &self, + ctrl: Self::Controller, + req: GenerateCredentialRequest, + ) -> crate::proto::rpc_types::error::Result { + super::get_instance_service(&self.instance_manager, &None)? + .get_credential_manage_service() + .generate_credential(ctrl, req) + .await + } + + async fn revoke_credential( + &self, + ctrl: Self::Controller, + req: RevokeCredentialRequest, + ) -> crate::proto::rpc_types::error::Result { + super::get_instance_service(&self.instance_manager, &None)? + .get_credential_manage_service() + .revoke_credential(ctrl, req) + .await + } + + async fn list_credentials( + &self, + ctrl: Self::Controller, + req: ListCredentialsRequest, + ) -> crate::proto::rpc_types::error::Result { + super::get_instance_service(&self.instance_manager, &None)? + .get_credential_manage_service() + .list_credentials(ctrl, req) + .await + } +} diff --git a/easytier/src/rpc_service/mod.rs b/easytier/src/rpc_service/mod.rs index cddec6af..e06d05ab 100644 --- a/easytier/src/rpc_service/mod.rs +++ b/easytier/src/rpc_service/mod.rs @@ -2,6 +2,7 @@ mod acl_manage; mod api; mod config; mod connector_manage; +mod credential_manage; mod mapped_listener_manage; mod peer_center; mod peer_manage; @@ -76,6 +77,11 @@ pub trait InstanceRpcService: Sync + Send { > + Send + Sync, >; + fn get_credential_manage_service( + &self, + ) -> &dyn crate::proto::api::instance::CredentialManageRpc< + Controller = crate::proto::rpc_types::controller::BaseController, + >; } fn get_instance_service( diff --git a/easytier/src/tests/credential_tests.rs b/easytier/src/tests/credential_tests.rs new file mode 100644 index 00000000..ee87c99e --- /dev/null +++ b/easytier/src/tests/credential_tests.rs @@ -0,0 +1,777 @@ +//! Credential system integration tests +//! +//! These tests verify the credential-based authentication system where: +//! - Admin nodes hold network_secret and can generate credentials +//! - Credential nodes use X25519 keypairs to authenticate without network_secret +//! - Credentials can be revoked and propagate across the network + +use std::time::Duration; + +use crate::{ + common::{ + config::{ConfigLoader, NetworkIdentity, TomlConfigLoader}, + global_ctx::GlobalCtxEvent, + }, + instance::instance::Instance, + tests::three_node::{generate_secure_mode_config, generate_secure_mode_config_with_key}, + tunnel::{common::tests::wait_for_condition, tcp::TcpTunnelConnector}, +}; + +use super::{add_ns_to_bridge, create_netns, del_netns, drop_insts, ping_test}; + +use rstest::rstest; + +/// Prepare network namespaces for credential tests +/// Topology: +/// br_a (10.1.1.0/24): ns_adm (10.1.1.1), ns_c1 (10.1.1.2), ns_c2 (10.1.1.3), ns_c3 (10.1.1.4) +/// br_b (10.1.2.0/24): ns_adm2 (10.1.2.1) - for multi-admin tests +/// Note: Using short names (max 15 chars for veth interfaces) +pub fn prepare_credential_network() { + // Clean up any existing namespaces + for ns in ["ns_adm", "ns_c1", "ns_c2", "ns_c3", "ns_adm2"] { + del_netns(ns); + } + + // Create bridge br_a for admin and credentials + let _ = std::process::Command::new("ip") + .args(["link", "del", "br_a"]) + .output(); + let _ = std::process::Command::new("brctl") + .args(["delbr", "br_a"]) + .output(); + let _ = std::process::Command::new("brctl") + .args(["addbr", "br_a"]) + .output() + .expect("Failed to create br_a"); + let _ = std::process::Command::new("ip") + .args(["link", "set", "br_a", "up"]) + .output(); + + // Create namespaces and add to bridge + create_netns("ns_adm", "10.1.1.1/24", "fd11::1/64"); + add_ns_to_bridge("br_a", "ns_adm"); + + create_netns("ns_c1", "10.1.1.2/24", "fd11::2/64"); + add_ns_to_bridge("br_a", "ns_c1"); + + create_netns("ns_c2", "10.1.1.3/24", "fd11::3/64"); + add_ns_to_bridge("br_a", "ns_c2"); + + // Create ns_c3 for relay tests (needs 4 nodes) + create_netns("ns_c3", "10.1.1.4/24", "fd11::4/64"); + add_ns_to_bridge("br_a", "ns_c3"); + + // Create bridge br_b for second admin (multi-admin tests) + let _ = std::process::Command::new("ip") + .args(["link", "del", "br_b"]) + .output(); + let _ = std::process::Command::new("brctl") + .args(["delbr", "br_b"]) + .output(); + let _ = std::process::Command::new("brctl") + .args(["addbr", "br_b"]) + .output() + .expect("Failed to create br_b"); + let _ = std::process::Command::new("ip") + .args(["link", "set", "br_b", "up"]) + .output(); + + create_netns("ns_adm2", "10.1.2.1/24", "fd12::1/64"); + add_ns_to_bridge("br_b", "ns_adm2"); +} + +/// Helper: Create credential node config with generated credential +async fn create_credential_config( + admin_inst: &Instance, + inst_name: &str, + ns: Option<&str>, + ipv4: &str, + ipv6: &str, +) -> TomlConfigLoader { + use base64::Engine as _; + + // Generate credential on admin + let (_cred_id, cred_secret) = admin_inst + .get_global_ctx() + .get_credential_manager() + .generate_credential(vec![], false, vec![], Duration::from_secs(3600)); + + // Decode private key + let privkey_bytes: [u8; 32] = base64::prelude::BASE64_STANDARD + .decode(&cred_secret) + .unwrap() + .try_into() + .unwrap(); + let private = x25519_dalek::StaticSecret::from(privkey_bytes); + + // Create config + let config = TomlConfigLoader::default(); + config.set_inst_name(inst_name.to_owned()); + config.set_netns(ns.map(|s| s.to_owned())); + config.set_ipv4(Some(ipv4.parse().unwrap())); + config.set_ipv6(Some(ipv6.parse().unwrap())); + config.set_listeners(vec![]); + config.set_network_identity(NetworkIdentity::new_credential( + admin_inst + .get_global_ctx() + .get_network_identity() + .network_name + .clone(), + )); + config.set_secure_mode(Some(generate_secure_mode_config_with_key(&private))); + + config +} + +/// Helper: Create admin node config +fn create_admin_config( + inst_name: &str, + ns: Option<&str>, + ipv4: &str, + ipv6: &str, +) -> TomlConfigLoader { + let config = TomlConfigLoader::default(); + config.set_inst_name(inst_name.to_owned()); + config.set_netns(ns.map(|s| s.to_owned())); + config.set_ipv4(Some(ipv4.parse().unwrap())); + config.set_ipv6(Some(ipv6.parse().unwrap())); + config.set_listeners(vec![ + "tcp://0.0.0.0:11010".parse().unwrap(), + "udp://0.0.0.0:11010".parse().unwrap(), + ]); + config.set_network_identity(NetworkIdentity::new( + "test_network".to_string(), + "test_secret".to_string(), + )); + config.set_secure_mode(Some(generate_secure_mode_config())); + + config +} + +/// Test 1: Basic credential node connectivity +/// Topology: Admin ← Credential +/// Verifies that a credential node can connect to an admin node and appears in routes +#[tokio::test] +#[serial_test::serial] +async fn credential_basic_connectivity() { + prepare_credential_network(); + + // Create admin node + let admin_config = create_admin_config("admin", Some("ns_adm"), "10.144.144.1", "fd00::1/64"); + let mut admin_inst = Instance::new(admin_config); + admin_inst.run().await.unwrap(); + + // Create credential node + let cred_config = create_credential_config( + &admin_inst, + "cred", + Some("ns_c1"), + "10.144.144.2", + "fd00::2/64", + ) + .await; + let mut cred_inst = Instance::new(cred_config); + cred_inst.run().await.unwrap(); + + // Credential connects to admin + cred_inst + .get_conn_manager() + .add_connector(TcpTunnelConnector::new( + "tcp://10.1.1.1:11010".parse().unwrap(), + )); + + let cred_peer_id = cred_inst.peer_id(); + let admin_peer_id = admin_inst.peer_id(); + println!( + "Admin peer_id: {}, Credential peer_id: {}", + admin_peer_id, cred_peer_id + ); + + // Wait a bit for connection attempt + tokio::time::sleep(Duration::from_secs(2)).await; + + // Check peers and connections + let admin_peers = admin_inst.get_peer_manager().get_peer_map().list_peers(); + let cred_peers = cred_inst.get_peer_manager().get_peer_map().list_peers(); + println!("Admin peers: {:?}", admin_peers); + println!("Credential peers: {:?}", cred_peers); + + // Wait for credential to appear in admin's route table + wait_for_condition( + || async { + let routes = admin_inst.get_peer_manager().list_routes().await; + let cred_routes = cred_inst.get_peer_manager().list_routes().await; + let admin_peers = admin_inst.get_peer_manager().get_peer_map().list_peers(); + let cred_peers = cred_inst.get_peer_manager().get_peer_map().list_peers(); + println!( + "Admin peers: {:?}, routes: {:?}", + admin_peers, + routes + .iter() + .map(|r| (r.peer_id, r.ipv4_addr)) + .collect::>() + ); + println!( + "Credential peers: {:?}, routes: {:?}", + cred_peers, + cred_routes + .iter() + .map(|r| (r.peer_id, r.ipv4_addr)) + .collect::>() + ); + routes.iter().any(|r| r.peer_id == cred_peer_id) + }, + Duration::from_secs(10), + ) + .await; + + // Verify connectivity + wait_for_condition( + || async { ping_test("ns_adm", "10.144.144.2", None).await }, + Duration::from_secs(10), + ) + .await; + + wait_for_condition( + || async { ping_test("ns_c1", "10.144.144.1", None).await }, + Duration::from_secs(10), + ) + .await; + + drop_insts(vec![admin_inst, cred_inst]).await; +} + +/// Test 5-6: Credential relay capability with allow_relay parameter +/// Topology: Admin ← Credential_A, Admin ← Credential_B, Admin ← Credential_C(listener, allow_relay) +/// Verifies routing behavior based on allow_relay flag: +/// - allow_relay=true: A→B route goes through C (cost 2 via C) +/// - allow_relay=false: A→B route goes through Admin (cost 2 via Admin) +#[rstest] +#[case(true)] +#[case(false)] +#[tokio::test] +#[serial_test::serial] +async fn credential_relay_capability(#[case] allow_relay: bool) { + use crate::peers::route_trait::NextHopPolicy; + + prepare_credential_network(); + + // Create admin node + let admin_config = create_admin_config("admin", Some("ns_adm"), "10.144.144.1", "fd00::1/64"); + let mut admin_inst = Instance::new(admin_config); + let mut ff = admin_inst.get_global_ctx().get_feature_flags(); + // if cred c allow relay, we set admin inst avoid relay (if other same-cost path available, admin will not relay data) + ff.avoid_relay_data = allow_relay; + admin_inst.get_global_ctx().set_feature_flags(ff); + admin_inst.run().await.unwrap(); + + let admin_peer_id = admin_inst.peer_id(); + + // Generate credentials for A, B, C + // C has configurable allow_relay + let (_cred_a_id, cred_a_secret) = admin_inst + .get_global_ctx() + .get_credential_manager() + .generate_credential(vec![], false, vec![], Duration::from_secs(3600)); + + let (_cred_b_id, cred_b_secret) = admin_inst + .get_global_ctx() + .get_credential_manager() + .generate_credential(vec![], false, vec![], Duration::from_secs(3600)); + + let (_cred_c_id, cred_c_secret) = admin_inst + .get_global_ctx() + .get_credential_manager() + .generate_credential(vec![], allow_relay, vec![], Duration::from_secs(3600)); + + // Create credential A on ns_c1 + let cred_a_config = { + use base64::Engine as _; + let privkey_bytes: [u8; 32] = base64::prelude::BASE64_STANDARD + .decode(&cred_a_secret) + .unwrap() + .try_into() + .unwrap(); + let private = x25519_dalek::StaticSecret::from(privkey_bytes); + let config = TomlConfigLoader::default(); + config.set_inst_name("cred_a".to_string()); + config.set_netns(Some("ns_c1".to_string())); + config.set_ipv4(Some("10.144.144.2".parse().unwrap())); + config.set_ipv6(Some("fd00::2/64".parse().unwrap())); + config.set_listeners(vec!["tcp://0.0.0.0:11021".parse().unwrap()]); + config.set_network_identity(NetworkIdentity::new_credential( + admin_inst + .get_global_ctx() + .get_network_identity() + .network_name + .clone(), + )); + config.set_secure_mode(Some(generate_secure_mode_config_with_key(&private))); + config + }; + let mut cred_a_inst = Instance::new(cred_a_config); + cred_a_inst.run().await.unwrap(); + + // Create credential B on ns_c2 + let cred_b_config = { + use base64::Engine as _; + let privkey_bytes: [u8; 32] = base64::prelude::BASE64_STANDARD + .decode(&cred_b_secret) + .unwrap() + .try_into() + .unwrap(); + let private = x25519_dalek::StaticSecret::from(privkey_bytes); + let config = TomlConfigLoader::default(); + config.set_inst_name("cred_b".to_string()); + config.set_netns(Some("ns_c2".to_string())); + config.set_ipv4(Some("10.144.144.3".parse().unwrap())); + config.set_ipv6(Some("fd00::3/64".parse().unwrap())); + config.set_listeners(vec!["tcp://0.0.0.0:11022".parse().unwrap()]); + config.set_network_identity(NetworkIdentity::new_credential( + admin_inst + .get_global_ctx() + .get_network_identity() + .network_name + .clone(), + )); + config.set_secure_mode(Some(generate_secure_mode_config_with_key(&private))); + config + }; + let mut cred_b_inst = Instance::new(cred_b_config); + cred_b_inst.run().await.unwrap(); + + // Create credential C on ns_c3 WITH listener (so A and B can connect to it) + let cred_c_config = { + use base64::Engine as _; + let privkey_bytes: [u8; 32] = base64::prelude::BASE64_STANDARD + .decode(&cred_c_secret) + .unwrap() + .try_into() + .unwrap(); + let private = x25519_dalek::StaticSecret::from(privkey_bytes); + let config = TomlConfigLoader::default(); + config.set_inst_name("cred_c".to_string()); + config.set_netns(Some("ns_c3".to_string())); + config.set_ipv4(Some("10.144.144.4".parse().unwrap())); + config.set_ipv6(Some("fd00::4/64".parse().unwrap())); + // C has listener so A and B can connect to it + config.set_listeners(vec!["tcp://0.0.0.0:11020".parse().unwrap()]); + config.set_network_identity(NetworkIdentity::new_credential( + admin_inst + .get_global_ctx() + .get_network_identity() + .network_name + .clone(), + )); + config.set_secure_mode(Some(generate_secure_mode_config_with_key(&private))); + config + }; + let mut cred_c_inst = Instance::new(cred_c_config); + cred_c_inst.run().await.unwrap(); + + let cred_a_peer_id = cred_a_inst.peer_id(); + let cred_b_peer_id = cred_b_inst.peer_id(); + let cred_c_peer_id = cred_c_inst.peer_id(); + + // All credentials connect to admin + cred_a_inst + .get_conn_manager() + .add_connector(TcpTunnelConnector::new( + "tcp://10.1.1.1:11010".parse().unwrap(), + )); + cred_b_inst + .get_conn_manager() + .add_connector(TcpTunnelConnector::new( + "tcp://10.1.1.1:11010".parse().unwrap(), + )); + cred_c_inst + .get_conn_manager() + .add_connector(TcpTunnelConnector::new( + "tcp://10.1.1.1:11010".parse().unwrap(), + )); + + // A and B also connect to C (simulating P2P discovery and connection) + // C is on ns_c3 with IP 10.1.1.4, listener on port 11020 + cred_a_inst + .get_conn_manager() + .add_connector(TcpTunnelConnector::new( + "tcp://10.1.1.4:11020".parse().unwrap(), + )); + cred_b_inst + .get_conn_manager() + .add_connector(TcpTunnelConnector::new( + "tcp://10.1.1.4:11020".parse().unwrap(), + )); + // print all peer ids + println!("Admin peer id: {:?}", admin_peer_id); + println!("Cred A peer id: {:?}", cred_a_peer_id); + println!("Cred B peer id: {:?}", cred_b_peer_id); + println!("Cred C peer id: {:?}", cred_c_peer_id); + + // Wait for all nodes to appear in admin's route table + wait_for_condition( + || async { + let routes = admin_inst.get_peer_manager().list_routes().await; + let has_a = routes.iter().any(|r| r.peer_id == cred_a_peer_id); + let has_b = routes.iter().any(|r| r.peer_id == cred_b_peer_id); + let has_c = routes.iter().any(|r| r.peer_id == cred_c_peer_id); + println!("Admin routes: a={}, b={}, c={}", has_a, has_b, has_c); + has_a && has_b && has_c + }, + Duration::from_secs(30), + ) + .await; + + // Wait for P2P connections to establish + wait_for_condition( + || async { + let peers_a = cred_a_inst.get_peer_manager().get_peer_map().list_peers(); + let peers_b = cred_b_inst.get_peer_manager().get_peer_map().list_peers(); + let peers_c = cred_c_inst.get_peer_manager().get_peer_map().list_peers(); + + let a_connected_c = peers_a.contains(&cred_c_peer_id); + let b_connected_c = peers_b.contains(&cred_c_peer_id); + let c_connected_a = peers_c.contains(&cred_a_peer_id); + let c_connected_b = peers_c.contains(&cred_b_peer_id); + + println!( + "P2P: A->C={}, B->C={}, C->A={}, C->B={}, allow_relay={}", + a_connected_c, b_connected_c, c_connected_a, c_connected_b, allow_relay + ); + + if allow_relay { + a_connected_c && b_connected_c && c_connected_a && c_connected_b + } else { + a_connected_c && b_connected_c + } + }, + Duration::from_secs(30), + ) + .await; + + // Wait for routes to propagate + wait_for_condition( + || async { + let routes_a = cred_a_inst.get_peer_manager().list_routes().await; + let a_sees_b = routes_a.iter().any(|r| r.peer_id == cred_b_peer_id); + let cost_a_to_b = routes_a + .iter() + .find(|r| r.peer_id == cred_b_peer_id) + .map(|r| r.cost); + + println!("Routes: a_sees_b={} (cost={:?})", a_sees_b, cost_a_to_b); + a_sees_b + }, + Duration::from_secs(15), + ) + .await; + + wait_for_condition( + || async { + let next_hop_a_to_b = cred_a_inst + .get_peer_manager() + .get_route() + .get_next_hop_with_policy(cred_b_peer_id, NextHopPolicy::LeastCost) + .await; + println!( + "Next hop convergence A->B={:?} (admin={}, c={}), allow_relay={}", + next_hop_a_to_b, admin_peer_id, cred_c_peer_id, allow_relay + ); + if allow_relay { + next_hop_a_to_b == Some(cred_c_peer_id) + } else { + next_hop_a_to_b == Some(admin_peer_id) + } + }, + Duration::from_secs(20), + ) + .await; + + // wait 5s, make sure the routes are stable + tokio::time::sleep(Duration::from_secs(5)).await; + + // Verify next hop from A to B based on allow_relay flag + let next_hop_a_to_b = cred_a_inst + .get_peer_manager() + .get_route() + .get_next_hop_with_policy(cred_b_peer_id, NextHopPolicy::LeastCost) + .await; + + println!( + "Next hop A->B={:?} (admin={}, c={}), allow_relay={}", + next_hop_a_to_b, admin_peer_id, cred_c_peer_id, allow_relay + ); + + // When C has allow_relay=false, route should go through Admin + // When C has allow_relay=true, route may go through C or Admin depending on routing algorithm + if !allow_relay { + assert_eq!( + next_hop_a_to_b, + Some(admin_peer_id), + "Route from A to B should go through admin when allow_relay=false" + ); + } else { + assert_eq!( + next_hop_a_to_b, + Some(cred_c_peer_id), + "Route from A to B should go through C when allow_relay=true" + ); + } + + // Cleanup + drop_insts(vec![admin_inst, cred_a_inst, cred_b_inst, cred_c_inst]).await; +} + +/// Test 2: Two credential nodes connect to same admin +/// Topology: Admin ← Credential_A, Admin ← Credential_B +/// Verifies that multiple credential nodes can connect to the same admin +#[tokio::test] +#[serial_test::serial] +async fn credential_two_credentials_communicate_tcp() { + prepare_credential_network(); + + // Create admin node + let admin_config = create_admin_config("admin", Some("ns_adm"), "10.144.144.1", "fd00::1/64"); + let mut admin_inst = Instance::new(admin_config); + admin_inst.run().await.unwrap(); + + // Create credential1 on ns_c1 + let cred1_config = create_credential_config( + &admin_inst, + "cred1", + Some("ns_c1"), + "10.144.144.2", + "fd00::2/64", + ) + .await; + let mut cred1_inst = Instance::new(cred1_config); + cred1_inst.run().await.unwrap(); + + // Create credential2 on ns_c2 + let cred2_config = create_credential_config( + &admin_inst, + "cred2", + Some("ns_c2"), + "10.144.144.3", + "fd00::3/64", + ) + .await; + let mut cred2_inst = Instance::new(cred2_config); + cred2_inst.run().await.unwrap(); + + // Both credentials connect to admin + cred1_inst + .get_conn_manager() + .add_connector(TcpTunnelConnector::new( + "tcp://10.1.1.1:11010".parse().unwrap(), + )); + cred2_inst + .get_conn_manager() + .add_connector(TcpTunnelConnector::new( + "tcp://10.1.1.1:11010".parse().unwrap(), + )); + + let cred1_peer_id = cred1_inst.peer_id(); + let cred2_peer_id = cred2_inst.peer_id(); + + // Wait for both credentials to appear in admin's route table + wait_for_condition( + || async { + let routes = admin_inst.get_peer_manager().list_routes().await; + routes.iter().any(|r| r.peer_id == cred1_peer_id) + && routes.iter().any(|r| r.peer_id == cred2_peer_id) + }, + Duration::from_secs(10), + ) + .await; + + // Verify admin can ping both credentials + wait_for_condition( + || async { ping_test("ns_adm", "10.144.144.2", None).await }, + Duration::from_secs(10), + ) + .await; + + wait_for_condition( + || async { ping_test("ns_adm", "10.144.144.3", None).await }, + Duration::from_secs(10), + ) + .await; + + drop_insts(vec![admin_inst, cred1_inst, cred2_inst]).await; +} + +/// Test 3: Credential revocation removes credential from route table +/// Topology: Admin ← Credential +/// Verifies that when credential is revoked, it's removed from admin's route table +#[tokio::test] +#[serial_test::serial] +async fn credential_revocation_propagates() { + prepare_credential_network(); + + // Create admin on ns_adm (10.1.1.1) + let admin_config = create_admin_config("admin", Some("ns_adm"), "10.144.144.1", "fd00::1/64"); + let mut admin_inst = Instance::new(admin_config); + admin_inst.run().await.unwrap(); + + // Generate credential on admin + let (cred_id, cred_secret) = admin_inst + .get_global_ctx() + .get_credential_manager() + .generate_credential(vec![], false, vec![], Duration::from_secs(3600)); + + // Create credential node + let cred_config = { + use base64::Engine as _; + let privkey_bytes: [u8; 32] = base64::prelude::BASE64_STANDARD + .decode(&cred_secret) + .unwrap() + .try_into() + .unwrap(); + let private = x25519_dalek::StaticSecret::from(privkey_bytes); + + let config = TomlConfigLoader::default(); + config.set_inst_name("cred".to_string()); + config.set_netns(Some("ns_c1".to_string())); + config.set_ipv4(Some("10.144.144.2".parse().unwrap())); + config.set_ipv6(Some("fd00::2/64".parse().unwrap())); + config.set_listeners(vec![]); + config.set_network_identity(NetworkIdentity::new_credential( + admin_inst + .get_global_ctx() + .get_network_identity() + .network_name + .clone(), + )); + config.set_secure_mode(Some(generate_secure_mode_config_with_key(&private))); + config + }; + + let mut cred_inst = Instance::new(cred_config); + cred_inst.run().await.unwrap(); + + // Credential connects to admin + cred_inst + .get_conn_manager() + .add_connector(TcpTunnelConnector::new( + "tcp://10.1.1.1:11010".parse().unwrap(), + )); + + let cred_peer_id = cred_inst.peer_id(); + + // Wait for credential to appear in admin's route table + wait_for_condition( + || async { + admin_inst + .get_peer_manager() + .list_routes() + .await + .iter() + .any(|r| r.peer_id == cred_peer_id) + }, + Duration::from_secs(10), + ) + .await; + + // Verify connectivity before revocation + wait_for_condition( + || async { ping_test("ns_adm", "10.144.144.2", None).await }, + Duration::from_secs(10), + ) + .await; + + // Revoke the credential + assert!( + admin_inst + .get_global_ctx() + .get_credential_manager() + .revoke_credential(&cred_id), + "Credential should be revoked successfully" + ); + + // Trigger OSPF sync + admin_inst + .get_global_ctx() + .issue_event(GlobalCtxEvent::CredentialChanged); + + // Wait for credential to disappear from admin's route table + wait_for_condition( + || async { + !admin_inst + .get_peer_manager() + .list_routes() + .await + .iter() + .any(|r| r.peer_id == cred_peer_id) + }, + Duration::from_secs(15), + ) + .await; + + drop_insts(vec![admin_inst, cred_inst]).await; +} + +/// Test 4: Unknown credential (not in trusted list) is rejected +/// Topology: Admin +/// Verifies that credential nodes with unknown/random keys cannot connect +#[tokio::test] +#[serial_test::serial] +async fn credential_unknown_rejected() { + prepare_credential_network(); + + // Create admin node + let admin_config = create_admin_config("admin", Some("ns_adm"), "10.144.144.1", "fd00::1/64"); + let mut admin_inst = Instance::new(admin_config); + admin_inst.run().await.unwrap(); + + // Create credential node with random key (not generated by admin) + let random_private = x25519_dalek::StaticSecret::random_from_rng(rand::rngs::OsRng); + let cred_config = { + let config = TomlConfigLoader::default(); + config.set_inst_name("cred".to_string()); + config.set_netns(Some("ns_c1".to_string())); + config.set_ipv4(Some("10.144.144.2".parse().unwrap())); + config.set_ipv6(Some("fd00::2/64".parse().unwrap())); + config.set_listeners(vec![]); + config.set_network_identity(NetworkIdentity::new_credential( + admin_inst + .get_global_ctx() + .get_network_identity() + .network_name + .clone(), + )); + config.set_secure_mode(Some(generate_secure_mode_config_with_key(&random_private))); + config + }; + + let mut cred_inst = Instance::new(cred_config); + cred_inst.run().await.unwrap(); + + // Attempt to connect to admin + cred_inst + .get_conn_manager() + .add_connector(TcpTunnelConnector::new( + "tcp://10.1.1.1:11010".parse().unwrap(), + )); + + let cred_peer_id = cred_inst.peer_id(); + + // Wait a bit for connection attempt + tokio::time::sleep(Duration::from_secs(5)).await; + + // Verify credential does NOT appear in admin's route table + let routes = admin_inst.get_peer_manager().list_routes().await; + assert!( + !routes.iter().any(|r| r.peer_id == cred_peer_id), + "Unknown credential node should NOT appear in admin's route table" + ); + + // Verify no connectivity + let ping_result = ping_test("ns_adm", "10.144.144.2", None).await; + assert!( + !ping_result, + "Should NOT be able to ping unknown credential node" + ); + + drop_insts(vec![admin_inst, cred_inst]).await; +} diff --git a/easytier/src/tests/mod.rs b/easytier/src/tests/mod.rs index 7996fe32..ec55d629 100644 --- a/easytier/src/tests/mod.rs +++ b/easytier/src/tests/mod.rs @@ -3,6 +3,11 @@ mod three_node; mod ipv6_test; +#[cfg(target_os = "linux")] +mod credential_tests; + +use std::io::IsTerminal as _; + use crate::common::PeerId; use crate::peers::peer_manager::PeerManager; @@ -126,9 +131,12 @@ pub fn enable_log() { .from_env() .unwrap() .add_directive("tarpc=error".parse().unwrap()); + let use_ansi = std::io::stderr().is_terminal(); tracing_subscriber::fmt::fmt() .pretty() + .with_ansi(use_ansi) .with_env_filter(filter) + .with_writer(std::io::stderr) .init(); } @@ -200,3 +208,45 @@ fn set_link_status(net_ns: &str, up: bool) { .unwrap(); tracing::info!("set link status: {:?}, net_ns: {}, up: {}", ret, net_ns, up); } + +pub async fn drop_insts(insts: Vec) { + let mut set = tokio::task::JoinSet::new(); + for mut inst in insts { + set.spawn(async move { + inst.clear_resources().await; + let pm = std::sync::Arc::downgrade(&inst.get_peer_manager()); + drop(inst); + let now = std::time::Instant::now(); + while now.elapsed().as_secs() < 5 && pm.strong_count() > 0 { + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + } + assert_eq!(pm.strong_count(), 0, "PeerManager should be dropped"); + }); + } + while set.join_next().await.is_some() {} +} + +pub async fn ping_test(from_netns: &str, target_ip: &str, payload_size: Option) -> bool { + use crate::common::netns::{NetNS, ROOT_NETNS_NAME}; + let _g = NetNS::new(Some(ROOT_NETNS_NAME.to_owned())).guard(); + let code = tokio::process::Command::new("ip") + .args([ + "netns", + "exec", + from_netns, + "ping", + "-c", + "1", + "-s", + payload_size.unwrap_or(56).to_string().as_str(), + "-W", + "1", + target_ip.to_string().as_str(), + ]) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status() + .await + .unwrap(); + code.code().unwrap() == 0 +} diff --git a/easytier/src/tests/three_node.rs b/easytier/src/tests/three_node.rs index aa8d4214..e704cb17 100644 --- a/easytier/src/tests/three_node.rs +++ b/easytier/src/tests/three_node.rs @@ -7,8 +7,9 @@ use std::{ time::Duration, }; -use rand::Rng; +use rand::{rngs::OsRng, Rng}; use tokio::{net::UdpSocket, task::JoinSet}; +use x25519_dalek::StaticSecret; use super::*; @@ -2763,21 +2764,28 @@ pub async fn config_patch_test() { drop_insts(insts).await; } -/// Generate SecureModeConfig with random x25519 keypair -fn generate_secure_mode_config() -> SecureModeConfig { +/// Generate SecureModeConfig with specified x25519 private key +pub fn generate_secure_mode_config_with_key( + private_key: &x25519_dalek::StaticSecret, +) -> SecureModeConfig { use base64::{prelude::BASE64_STANDARD, Engine}; - use rand::rngs::OsRng; - use x25519_dalek::{PublicKey, StaticSecret}; + use x25519_dalek::PublicKey; - let private = StaticSecret::random_from_rng(OsRng); - let public = PublicKey::from(&private); + let public = PublicKey::from(private_key); SecureModeConfig { enabled: true, - local_private_key: Some(BASE64_STANDARD.encode(private.as_bytes())), + local_private_key: Some(BASE64_STANDARD.encode(private_key.as_bytes())), local_public_key: Some(BASE64_STANDARD.encode(public.as_bytes())), } } + +/// Generate SecureModeConfig with random x25519 keypair +pub fn generate_secure_mode_config() -> SecureModeConfig { + let private = StaticSecret::random_from_rng(OsRng); + generate_secure_mode_config_with_key(&private) +} + /// Test relay peer end-to-end encryption with TCP #[rstest::rstest] #[tokio::test]