feat: trust peer

This commit is contained in:
2026-02-07 03:17:37 +08:00
parent d8ffc5eea5
commit f3adb56bd0
19 changed files with 438 additions and 155 deletions

View File

@@ -8,6 +8,26 @@
- **文件夹传输**:支持发送整个文件夹结构。
- **文本传输**:快速同步设备间的文本内容。
- **加密传输**:确保数据在传输过程中的安全性。
- **安全身份**:基于 Ed25519 的签名验证,防止伪造。
## 安全机制
Mesh Drop 采用多层安全设计来保护用户免受潜在的恶意攻击:
1. **身份验证 (Identity)**
- 每个设备在首次启动时生成一对唯一的 Ed25519 密钥。
- 所有广播包Presence Broadcast都使用私钥签名。
- 接收端通过公钥验证签名,确保身份未被篡改。
2. **信任机制 (Trust)**
- 采用 TOFU (Trust On First Use) 策略。
- 用户可以选择“信任”某个 Peer一旦信任该 Peer 的公钥将被固定Pinning
- 之后收到该 Peer ID 的所有数据包,必须通过已保存公钥的验证,否则会被标记为 **Mismatch**
- **防欺骗**:如果有人试图伪造已信任 Peer 的 IDUI 会显示明显的“Mismatch”安全警告并阻止元数据被覆盖。
3. **传输加密 (Encryption)**
- 文件传输服务使用 HTTPS 协议。
- 自动生成自签名证书进行通信加密,防止传输内容被窃听。
## 截图
@@ -27,8 +47,9 @@
- [x] 清理历史
- [x] 自动接收
- [x] 应用图标
- [x] 信任Peer
- [ ] 通过IP添加非局域网Peer
- [ ] 系统托盘(最小化到托盘)徽章 https://github.com/wailsapp/wails/issues/4494
- [ ] 收藏Peer
- [ ] 多语言
## 技术栈

View File

@@ -21,7 +21,7 @@ tasks:
# 1. Cross-compiling from non-Linux, OR
# 2. No C compiler is available, OR
# 3. Target architecture differs from host architecture (cross-arch compilation)
- task: '{{if and (eq OS "linux") (eq .HAS_CC "true") (eq .TARGET_ARCH ARCH)}}{{else}}build:docker{{end}}'
- task: '{{if and (eq OS "linux") (eq .HAS_CC "true") (eq .TARGET_ARCH ARCH)}}build:native{{else}}build:docker{{end}}'
vars:
ARCH: "{{.ARCH}}"
DEV: "{{.DEV}}"

View File

@@ -3,8 +3,8 @@ Type=Application
Name=mesh-drop
Exec=mesh-drop
Icon=mesh-drop
Categories=GTK;FileTransfer;Utility;
Categories=GTK;Utility
Terminal=false
Keywords=file transfer
Keywords=utility
Version=1.0
StartupNotify=false

View File

@@ -9,6 +9,10 @@ import { Call as $Call, CancellablePromise as $CancellablePromise, Create as $Cr
// @ts-ignore: Unused imports
import * as $models from "./models.js";
export function AddTrustedPeer(peerID: string, publicKey: string): $CancellablePromise<void> {
return $Call.ByID(2866399505, peerID, publicKey);
}
export function GetAutoAccept(): $CancellablePromise<boolean> {
return $Call.ByID(2605668438);
}
@@ -29,16 +33,30 @@ export function GetSavePath(): $CancellablePromise<string> {
return $Call.ByID(4081533263);
}
export function GetTrustedPeer(): $CancellablePromise<{ [_: string]: string }> {
return $Call.ByID(1253442080).then(($result: any) => {
return $$createType0($result);
});
}
export function GetVersion(): $CancellablePromise<string> {
return $Call.ByID(3578438023);
}
export function GetWindowState(): $CancellablePromise<$models.WindowState> {
return $Call.ByID(341414414).then(($result: any) => {
return $$createType0($result);
return $$createType1($result);
});
}
export function IsTrustedPeer(peerID: string): $CancellablePromise<boolean> {
return $Call.ByID(3452062706, peerID);
}
export function RemoveTrustedPeer(peerID: string): $CancellablePromise<void> {
return $Call.ByID(909233322, peerID);
}
/**
* Save 保存配置到磁盘
*/
@@ -70,4 +88,5 @@ export function SetWindowState(state: $models.WindowState): $CancellablePromise<
}
// Private type creation functions
const $$createType0 = $models.WindowState.createFrom;
const $$createType0 = $Create.Map($Create.Any, $Create.Any);
const $$createType1 = $models.WindowState.createFrom;

View File

@@ -47,6 +47,13 @@ export class Peer {
*/
"port": number;
"os": OS;
"pk": string;
/**
* TrustMismatch 指示该节点的公钥与本地信任列表中的公钥不匹配
* 如果为 true说明可能存在 ID 欺骗或密钥轮换
*/
"trust_mismatch": boolean;
/** Creates a new Peer instance. */
constructor($$source: Partial<Peer> = {}) {
@@ -65,6 +72,12 @@ export class Peer {
if (!("os" in $$source)) {
this["os"] = OS.$zero;
}
if (!("pk" in $$source)) {
this["pk"] = "";
}
if (!("trust_mismatch" in $$source)) {
this["trust_mismatch"] = false;
}
Object.assign(this, $$source);
}

View File

@@ -17,22 +17,29 @@ export function GetLocalIPInSameSubnet(receiverIP: string): $CancellablePromise<
return $Call.ByID(3089425954, receiverIP);
}
export function GetLocalIPs(): $CancellablePromise<[string[], boolean]> {
return $Call.ByID(2403939179).then(($result: any) => {
$result[0] = $$createType0($result[0]);
export function GetPeerByID(id: string): $CancellablePromise<[$models.Peer | null, boolean]> {
return $Call.ByID(1962377788, id).then(($result: any) => {
$result[0] = $$createType1($result[0]);
return $result;
});
}
export function GetPeerByIP(ip: string): $CancellablePromise<$models.Peer | null> {
export function GetPeerByIP(ip: string): $CancellablePromise<[$models.Peer | null, boolean]> {
return $Call.ByID(1626825408, ip).then(($result: any) => {
return $$createType2($result);
$result[0] = $$createType1($result[0]);
return $result;
});
}
export function GetPeers(): $CancellablePromise<$models.Peer[]> {
return $Call.ByID(3041084029).then(($result: any) => {
return $$createType3($result);
return $$createType2($result);
});
}
export function GetSelf(): $CancellablePromise<$models.Peer> {
return $Call.ByID(3599633538).then(($result: any) => {
return $$createType0($result);
});
}
@@ -41,7 +48,6 @@ export function Start(): $CancellablePromise<void> {
}
// Private type creation functions
const $$createType0 = $Create.Array($Create.Any);
const $$createType1 = $models.Peer.createFrom;
const $$createType2 = $Create.Nullable($$createType1);
const $$createType3 = $Create.Array($$createType1);
const $$createType0 = $models.Peer.createFrom;
const $$createType1 = $Create.Nullable($$createType0);
const $$createType2 = $Create.Array($$createType0);

View File

@@ -9,7 +9,6 @@ export {
export {
ContentType,
Progress,
Sender,
Transfer,
TransferStatus,
TransferType

View File

@@ -5,6 +5,10 @@
// @ts-ignore: Unused imports
import { Create as $Create } from "@wailsio/runtime";
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore: Unused imports
import * as discovery$0 from "../discovery/models.js";
export enum ContentType {
/**
* The Go zero value for the underlying type of the enum.
@@ -59,46 +63,6 @@ export class Progress {
}
}
export class Sender {
/**
* 发送者 ID
*/
"id": string;
/**
* 发送者名称
*/
"name": string;
/**
* 发送者 IP
*/
"ip": string;
/** Creates a new Sender instance. */
constructor($$source: Partial<Sender> = {}) {
if (!("id" in $$source)) {
this["id"] = "";
}
if (!("name" in $$source)) {
this["name"] = "";
}
if (!("ip" in $$source)) {
this["ip"] = "";
}
Object.assign(this, $$source);
}
/**
* Creates a new Sender instance from a string or object.
*/
static createFrom($$source: any = {}): Sender {
let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source;
return new Sender($$parsedSource as Partial<Sender>);
}
}
/**
* Transfer
*/
@@ -116,7 +80,7 @@ export class Transfer {
/**
* 发送者
*/
"sender": Sender;
"sender": discovery$0.Peer;
/**
* 文件名
@@ -177,7 +141,7 @@ export class Transfer {
this["create_time"] = 0;
}
if (!("sender" in $$source)) {
this["sender"] = (new Sender());
this["sender"] = (new discovery$0.Peer());
}
if (!("file_name" in $$source)) {
this["file_name"] = "";
@@ -256,5 +220,5 @@ export enum TransferType {
};
// Private type creation functions
const $$createType0 = Sender.createFrom;
const $$createType0 = discovery$0.Peer.createFrom;
const $$createType1 = Progress.createFrom;

View File

@@ -66,12 +66,10 @@ onMounted(async () => {
// --- 后端集成 & 事件监听 ---
onMounted(async () => {
peers.value = await GetPeers();
peers.value = peers.value.sort((a, b) => a.name.localeCompare(b.name));
});
Events.On("peers:update", (event) => {
peers.value = event.data;
peers.value = peers.value.sort((a, b) => a.name.localeCompare(b.name));
});
Events.On("transfer:refreshList", async () => {

View File

@@ -1,6 +1,6 @@
<script setup lang="ts">
// --- Vue 核心 ---
import { computed, ref, watch } from "vue";
import { computed, ref, watch, onMounted } from "vue";
// --- 组件 ---
import FileSendModal from "./modals/FileSendModal.vue";
@@ -13,6 +13,20 @@ import {
SendText,
} from "../../bindings/mesh-drop/internal/transfer/service";
import { Peer } from "../../bindings/mesh-drop/internal/discovery/models";
import {
IsTrustedPeer,
AddTrustedPeer,
RemoveTrustedPeer,
} from "../../bindings/mesh-drop/internal/config/config";
// --- 生命周期 ---
onMounted(async () => {
try {
isTrusted.value = await IsTrustedPeer(props.peer.id);
} catch (err) {
console.error("Failed to check trusted peer status:", err);
}
});
// --- 属性 & 事件 ---
const props = defineProps<{
@@ -27,6 +41,7 @@ const emit = defineEmits<{
const selectedIp = ref<string>("");
const showFileModal = ref(false);
const showTextModal = ref(false);
const isTrusted = ref(false);
const sendOptions = [
{
@@ -136,10 +151,20 @@ const handleSendClipboard = async () => {
});
emit("transferStarted");
};
const handleTrust = () => {
AddTrustedPeer(props.peer.id, props.peer.pk);
isTrusted.value = true;
};
const handleUntrust = () => {
RemoveTrustedPeer(props.peer.id);
isTrusted.value = false;
};
</script>
<template>
<v-card hover link class="peer-card pa-2">
<v-card hover link class="peer-card pa-2" :ripple="false">
<template #title>
<div class="d-flex align-center">
<v-icon :icon="osIcon" size="24" class="mr-2"></v-icon>
@@ -187,12 +212,25 @@ const handleSendClipboard = async () => {
</div>
</template>
<template #actions>
<v-menu>
<v-card-actions>
<!-- Trust Mismatch Warning -->
<v-btn
v-if="peer.trust_mismatch"
class="flex-grow-1"
color="warning"
variant="tonal"
prepend-icon="mdi-alert"
:ripple="false"
style="pointer-events: none"
>
Mismatch
</v-btn>
<v-menu v-else>
<template #activator="{ props }">
<v-btn
v-bind="props"
block
class="flex-grow-1"
color="primary"
variant="tonal"
:disabled="ips.length === 0"
@@ -218,7 +256,32 @@ const handleSendClipboard = async () => {
</v-list-item>
</v-list>
</v-menu>
</template>
<!-- Trust Mismatch Reset Override -->
<v-btn
v-if="peer.trust_mismatch"
variant="tonal"
color="error"
@click="handleUntrust"
>
<v-icon icon="mdi-delete"></v-icon>
<v-tooltip activator="parent" location="bottom">Reset Trust</v-tooltip>
</v-btn>
<v-btn
v-else-if="!isTrusted"
variant="tonal"
color="primary"
@click="handleTrust"
>
<v-icon icon="mdi-star-outline"></v-icon>
<v-tooltip activator="parent" location="bottom">Trust peer</v-tooltip>
</v-btn>
<v-btn v-else variant="tonal" color="primary" @click="handleUntrust">
<v-icon icon="mdi-star"></v-icon>
<v-tooltip activator="parent" location="bottom">Untrust peer</v-tooltip>
</v-btn>
</v-card-actions>
</v-card>
<!-- Modals -->

View File

@@ -187,9 +187,23 @@ const handleCopy = async () => {
v-if="
props.transfer.sender.name && props.transfer.type === 'receive'
"
prepend-icon="mdi-account"
:color="
props.transfer.sender.trust_mismatch ? 'warning' : undefined
"
:prepend-icon="
props.transfer.sender.trust_mismatch
? 'mdi-alert'
: 'mdi-account'
"
>
{{ props.transfer.sender.name }}
<v-tooltip
v-if="props.transfer.sender.trust_mismatch"
activator="parent"
location="bottom"
>
Security Alert: Key Mismatch
</v-tooltip>
</v-chip>
<v-chip

View File

@@ -1,6 +1,6 @@
<script setup lang="ts">
// --- Vue 核心 ---
import { computed, ref } from "vue";
import { computed, ref, watch, nextTick } from "vue";
// --- Wails & 后端绑定 ---
import { SendText } from "../../../bindings/mesh-drop/internal/transfer/service";
@@ -20,6 +20,7 @@ const emit = defineEmits<{
// --- 状态 ---
const textContent = ref("");
const textareaRef = ref();
// --- 计算属性 ---
const show = computed({
@@ -27,6 +28,14 @@ const show = computed({
set: (value) => emit("update:modelValue", value),
});
// --- 监听 ---
watch(show, async (val) => {
if (val) {
await nextTick();
textareaRef.value?.focus();
}
});
// --- 方法 ---
const executeSendText = async () => {
if (!props.selectedIp || !textContent.value) return;
@@ -48,6 +57,7 @@ const executeSendText = async () => {
<v-card title="Send Text">
<v-card-text>
<v-textarea
ref="textareaRef"
v-model="textContent"
label="Content"
placeholder="Type something to send..."

View File

@@ -2,6 +2,7 @@ package config
import (
"log/slog"
"mesh-drop/internal/security"
"os"
"path/filepath"
"sync"
@@ -19,7 +20,7 @@ type WindowState struct {
Maximised bool `mapstructure:"maximised"`
}
var Version = "0.0.2"
var Version = "next"
type Config struct {
v *viper.Viper
@@ -27,10 +28,13 @@ type Config struct {
WindowState WindowState `mapstructure:"window_state"`
ID string `mapstructure:"id"`
PrivateKey string `mapstructure:"private_key"`
PublicKey string `mapstructure:"public_key"`
SavePath string `mapstructure:"save_path"`
HostName string `mapstructure:"host_name"`
AutoAccept bool `mapstructure:"auto_accept"`
SaveHistory bool `mapstructure:"save_history"`
TrustedPeer map[string]string `mapstructure:"trusted_peer"` // ID -> PublicKey
}
// 默认窗口配置
@@ -104,6 +108,28 @@ func Load() *Config {
config.v = v
// 如果没有密钥对,生成新的
if config.PrivateKey == "" || config.PublicKey == "" {
priv, pub, err := security.GenerateKey()
if err != nil {
slog.Error("Failed to generate identity keys", "error", err)
} else {
config.PrivateKey = priv
config.PublicKey = pub
v.Set("private_key", priv)
v.Set("public_key", pub)
// 保存新生成的密钥
if err := config.Save(); err != nil {
slog.Error("Failed to save generated keys", "error", err)
}
}
}
// 初始化 TrustedPeer map if nil
if config.TrustedPeer == nil {
config.TrustedPeer = make(map[string]string)
}
return &config
}
@@ -111,7 +137,10 @@ func Load() *Config {
func (c *Config) Save() error {
c.mu.RLock()
defer c.mu.RUnlock()
return c.save()
}
func (c *Config) save() error {
configDir := GetConfigDir()
if err := os.MkdirAll(configDir, 0755); err != nil {
return err
@@ -122,6 +151,14 @@ func (c *Config) Save() error {
return err
}
// 设置配置文件权限为 0600 (仅所有者读写)
configFile := c.v.ConfigFileUsed()
if configFile != "" {
if err := os.Chmod(configFile, 0600); err != nil {
slog.Warn("Failed to set config file permissions", "error", err)
}
}
return nil
}
@@ -133,6 +170,7 @@ func (c *Config) SetSavePath(savePath string) {
c.SavePath = savePath
c.v.Set("save_path", savePath)
_ = os.MkdirAll(savePath, 0755)
_ = c.save()
}
func (c *Config) GetSavePath() string {
@@ -146,6 +184,7 @@ func (c *Config) SetHostName(hostName string) {
defer c.mu.Unlock()
c.HostName = hostName
c.v.Set("host_name", hostName)
_ = c.save()
}
func (c *Config) GetHostName() string {
@@ -165,6 +204,7 @@ func (c *Config) SetAutoAccept(autoAccept bool) {
defer c.mu.Unlock()
c.AutoAccept = autoAccept
c.v.Set("auto_accept", autoAccept)
_ = c.save()
}
func (c *Config) GetAutoAccept() bool {
@@ -178,6 +218,7 @@ func (c *Config) SetSaveHistory(saveHistory bool) {
defer c.mu.Unlock()
c.SaveHistory = saveHistory
c.v.Set("save_history", saveHistory)
_ = c.save()
}
func (c *Config) GetSaveHistory() bool {
@@ -195,6 +236,7 @@ func (c *Config) SetWindowState(state WindowState) {
defer c.mu.Unlock()
c.WindowState = state
c.v.Set("window_state", state)
_ = c.save()
}
func (c *Config) GetWindowState() WindowState {
@@ -202,3 +244,35 @@ func (c *Config) GetWindowState() WindowState {
defer c.mu.RUnlock()
return c.WindowState
}
func (c *Config) AddTrustedPeer(peerID string, publicKey string) {
c.mu.Lock()
defer c.mu.Unlock()
if c.TrustedPeer == nil {
c.TrustedPeer = make(map[string]string)
}
c.TrustedPeer[peerID] = publicKey
c.v.Set("trusted_peer", c.TrustedPeer)
_ = c.save()
}
func (c *Config) GetTrustedPeer() map[string]string {
c.mu.RLock()
defer c.mu.RUnlock()
return c.TrustedPeer
}
func (c *Config) RemoveTrustedPeer(peerID string) {
c.mu.Lock()
defer c.mu.Unlock()
delete(c.TrustedPeer, peerID)
c.v.Set("trusted_peer", c.TrustedPeer)
_ = c.save()
}
func (c *Config) IsTrustedPeer(peerID string) bool {
c.mu.RLock()
defer c.mu.RUnlock()
_, exists := c.TrustedPeer[peerID]
return exists
}

View File

@@ -1,6 +1,9 @@
package discovery
import "time"
import (
"fmt"
"time"
)
// Peer 代表一个可达的网络端点 (Network Endpoint)。
// 注意:一个物理设备 (Device) 可能通过多个网络接口广播,因此会对应多个 Peer 结构体。
@@ -20,6 +23,12 @@ type Peer struct {
Port int `json:"port"`
OS OS `json:"os"`
PublicKey string `json:"pk"`
// TrustMismatch 指示该节点的公钥与本地信任列表中的公钥不匹配
// 如果为 true说明可能存在 ID 欺骗或密钥轮换
TrustMismatch bool `json:"trust_mismatch"`
}
// RouteState 记录单条路径的状态
@@ -42,4 +51,13 @@ type PresencePacket struct {
Name string `json:"name"`
Port int `json:"port"`
OS OS `json:"os"`
PublicKey string `json:"pk"`
Signature string `json:"sig"`
}
// SignPayload 生成用于签名的确定性数据
func (p *PresencePacket) SignPayload() []byte {
// 使用固定格式拼接字段,避免 JSON 序列化的不确定性
// 格式: id|name|port|os|pk
return fmt.Appendf(nil, "%s|%s|%d|%s|%s", p.ID, p.Name, p.Port, p.OS, p.PublicKey)
}

View File

@@ -5,8 +5,10 @@ import (
"fmt"
"log/slog"
"mesh-drop/internal/config"
"mesh-drop/internal/security"
"net"
"runtime"
"sort"
"sync"
"time"
@@ -15,8 +17,8 @@ import (
const (
DiscoveryPort = 9988
HeartbeatRate = 3 * time.Second
PeerTimeout = 10 * time.Second
HeartbeatRate = 1 * time.Second
PeerTimeout = 2 * time.Second
)
type Service struct {
@@ -26,9 +28,11 @@ type Service struct {
config *config.Config
FileServerPort int
// key 使用 peer.id 和 peer.ip 组合而成的 hash
// Key: peer.ID
peers map[string]*Peer
peersMutex sync.RWMutex
self Peer
}
func NewService(config *config.Config, app *application.App, port int) *Service {
@@ -38,10 +42,17 @@ func NewService(config *config.Config, app *application.App, port int) *Service
config: config,
FileServerPort: port,
peers: make(map[string]*Peer),
self: Peer{
ID: config.GetID(),
Name: config.GetHostName(),
Port: port,
OS: OS(runtime.GOOS),
PublicKey: config.PublicKey,
},
}
}
func (s *Service) GetLocalIPs() ([]string, bool) {
func GetLocalIPs() ([]string, bool) {
interfaces, err := net.Interfaces()
if err != nil {
slog.Error("Failed to get network interfaces", "error", err, "component", "discovery")
@@ -118,7 +129,18 @@ func (s *Service) startBroadcasting() {
Name: s.config.GetHostName(),
Port: s.FileServerPort,
OS: OS(runtime.GOOS),
PublicKey: s.config.PublicKey,
}
// 签名
sigData := packet.SignPayload()
sig, err := security.Sign(s.config.PrivateKey, sigData)
if err != nil {
slog.Error("Failed to sign discovery packet", "error", err)
continue
}
packet.Signature = sig
data, _ := json.Marshal(packet)
for _, iface := range interfaces {
// 过滤掉 Down 的接口和 Loopback 接口
@@ -195,12 +217,33 @@ func (s *Service) startListening() {
continue
}
s.handleHeartbeat(packet, remoteAddr.IP.String())
// 验证签名
sig := packet.Signature
sigData := packet.SignPayload()
valid, err := security.Verify(packet.PublicKey, sigData, sig)
if err != nil || !valid {
slog.Warn("Received invalid discovery packet signature", "id", packet.ID, "ip", remoteAddr.IP.String())
continue
}
// 验证身份一致性 (防止 ID 欺骗)
trustMismatch := false
trustedKeys := s.config.GetTrustedPeer()
if knownKey, ok := trustedKeys[packet.ID]; ok {
if knownKey != packet.PublicKey {
slog.Warn("SECURITY ALERT: Peer ID mismatch with known public key (Spoofing attempt?)", "id", packet.ID, "known_key", knownKey, "received_key", packet.PublicKey)
trustMismatch = true
// 当发现 ID 欺骗时,不更新 peer而是标记为 trustMismatch
// 用户可以手动重新添加信任
}
}
s.handleHeartbeat(packet, remoteAddr.IP.String(), trustMismatch)
}
}
// handleHeartbeat 处理心跳包
func (s *Service) handleHeartbeat(pkt PresencePacket, ip string) {
func (s *Service) handleHeartbeat(pkt PresencePacket, ip string, trustMismatch bool) {
s.peersMutex.Lock()
peer, exists := s.peers[pkt.ID]
@@ -217,17 +260,25 @@ func (s *Service) handleHeartbeat(pkt PresencePacket, ip string) {
},
Port: pkt.Port,
OS: pkt.OS,
PublicKey: pkt.PublicKey,
TrustMismatch: trustMismatch,
}
s.peers[peer.ID] = peer
slog.Info("New device found", "name", pkt.Name, "ip", ip, "component", "discovery")
} else {
// 更新节点
// 只有在没有身份不匹配的情况下才更新元数据,防止欺骗攻击导致 UI 闪烁/篡改
if !trustMismatch {
peer.Name = pkt.Name
peer.OS = pkt.OS
peer.PublicKey = pkt.PublicKey
}
peer.Routes[ip] = &RouteState{
IP: ip,
LastSeen: time.Now(),
}
// 如果之前存在不匹配,即使这次匹配了,也不要重置,防止欺骗攻击
peer.TrustMismatch = peer.TrustMismatch || trustMismatch
}
s.peersMutex.Unlock()
@@ -246,7 +297,6 @@ func (s *Service) startCleanup() {
for id, peer := range s.peers {
for ip, route := range peer.Routes {
// 超过10秒没心跳认为下线
if now.Sub(route.LastSeen) > PeerTimeout {
delete(peer.Routes, ip)
changed = true
@@ -274,16 +324,24 @@ func (s *Service) Start() {
go s.startCleanup()
}
func (s *Service) GetPeerByIP(ip string) *Peer {
func (s *Service) GetPeerByIP(ip string) (*Peer, bool) {
s.peersMutex.RLock()
defer s.peersMutex.RUnlock()
for _, p := range s.peers {
if p.Routes[ip] != nil {
return p
return p, true
}
}
return nil
return nil, false
}
func (s *Service) GetPeerByID(id string) (*Peer, bool) {
s.peersMutex.RLock()
defer s.peersMutex.RUnlock()
peer, ok := s.peers[id]
return peer, ok
}
func (s *Service) GetPeers() []Peer {
@@ -294,9 +352,16 @@ func (s *Service) GetPeers() []Peer {
for _, p := range s.peers {
list = append(list, *p)
}
sort.Slice(list, func(i, j int) bool {
return list[i].Name < list[j].Name
})
return list
}
func (s *Service) GetID() string {
return s.ID
}
func (s *Service) GetSelf() Peer {
return s.self
}

View File

@@ -0,0 +1,56 @@
package security
import (
"crypto/ed25519"
"crypto/rand"
"encoding/base64"
"fmt"
)
// GenerateKey 生成新的 Ed25519 密钥对
// 返回 base64 编码的私钥和公钥
func GenerateKey() (string, string, error) {
pub, priv, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
return "", "", err
}
return base64.StdEncoding.EncodeToString(priv), base64.StdEncoding.EncodeToString(pub), nil
}
// Sign 使用私钥对数据进行签名
// privKeyStr: base64 编码的私钥
// data: 要签名的数据
// 返回: base64 编码的签名
func Sign(privKeyStr string, data []byte) (string, error) {
privKeyBytes, err := base64.StdEncoding.DecodeString(privKeyStr)
if err != nil {
return "", fmt.Errorf("invalid private key: %w", err)
}
if len(privKeyBytes) != ed25519.PrivateKeySize {
return "", fmt.Errorf("invalid private key length")
}
signature := ed25519.Sign(ed25519.PrivateKey(privKeyBytes), data)
return base64.StdEncoding.EncodeToString(signature), nil
}
// Verify 使用公钥验证签名
// pubKeyStr: base64 编码的公钥
// data: 原始数据
// sigStr: base64 编码的签名
func Verify(pubKeyStr string, data []byte, sigStr string) (bool, error) {
pubKeyBytes, err := base64.StdEncoding.DecodeString(pubKeyStr)
if err != nil {
return false, fmt.Errorf("invalid public key: %w", err)
}
if len(pubKeyBytes) != ed25519.PublicKeySize {
return false, fmt.Errorf("invalid public key length")
}
sigBytes, err := base64.StdEncoding.DecodeString(sigStr)
if err != nil {
return false, fmt.Errorf("invalid signature: %w", err)
}
return ed25519.Verify(ed25519.PublicKey(pubKeyBytes), data, sigBytes), nil
}

View File

@@ -44,11 +44,7 @@ func (s *Service) SendFile(target *discovery.Peer, targetIP string, filePath str
task := NewTransfer(
taskID,
NewSender(
s.discoveryService.GetID(),
s.config.GetHostName(),
WithReceiverIP(targetIP, s.discoveryService),
),
s.discoveryService.GetSelf(),
WithFileName(filepath.Base(filePath)),
WithFileSize(stat.Size()),
WithType(TransferTypeSend),
@@ -111,11 +107,7 @@ func (s *Service) SendFolder(target *discovery.Peer, targetIP string, folderPath
task := NewTransfer(
taskID,
NewSender(
s.discoveryService.GetID(),
s.config.GetHostName(),
WithReceiverIP(targetIP, s.discoveryService),
),
s.discoveryService.GetSelf(),
WithFileName(filepath.Base(folderPath)),
WithFileSize(size),
WithType(TransferTypeSend),
@@ -164,11 +156,7 @@ func (s *Service) SendText(target *discovery.Peer, targetIP string, text string)
r := bytes.NewReader([]byte(text))
task := NewTransfer(
taskID,
NewSender(
s.discoveryService.GetID(),
s.config.GetHostName(),
WithReceiverIP(targetIP, s.discoveryService),
),
s.discoveryService.GetSelf(),
WithFileSize(int64(len(text))),
WithType(TransferTypeSend),
WithContentType(ContentTypeText),

View File

@@ -1,7 +1,6 @@
package transfer
import (
"log/slog"
"mesh-drop/internal/discovery"
"time"
)
@@ -37,7 +36,7 @@ const (
type Transfer struct {
ID string `json:"id" binding:"required"` // 传输会话 ID
CreateTime int64 `json:"create_time"` // 创建时间
Sender Sender `json:"sender" binding:"required"` // 发送者
Sender discovery.Peer `json:"sender" binding:"required"` // 发送者
FileName string `json:"file_name"` // 文件名
FileSize int64 `json:"file_size"` // 文件大小 (字节)
SavePath string `json:"savePath"` // 保存路径
@@ -53,7 +52,7 @@ type Transfer struct {
type TransferOption func(*Transfer)
func NewTransfer(id string, sender Sender, opts ...TransferOption) *Transfer {
func NewTransfer(id string, sender discovery.Peer, opts ...TransferOption) *Transfer {
t := &Transfer{
ID: id,
CreateTime: time.Now().UnixMilli(),
@@ -122,41 +121,6 @@ func WithToken(token string) TransferOption {
}
}
type Sender struct {
ID string `json:"id" binding:"required"` // 发送者 ID
Name string `json:"name" binding:"required"` // 发送者名称
IP string `json:"ip" binding:"required"` // 发送者 IP
}
type NewSenderOption func(*Sender)
func NewSender(id string, name string, opts ...NewSenderOption) Sender {
s := &Sender{
ID: id,
Name: name,
}
for _, opt := range opts {
opt(s)
}
return *s
}
func WithIP(ip string) NewSenderOption {
return func(s *Sender) {
s.IP = ip
}
}
func WithReceiverIP(ip string, discoveryService *discovery.Service) NewSenderOption {
return func(s *Sender) {
ip, ok := discoveryService.GetLocalIPInSameSubnet(ip)
if !ok {
slog.Error("Failed to get local IP in same subnet", "ip", ip, "component", "transfer-client")
}
s.IP = ip
}
}
// Progress 用户前端传输进度
type Progress struct {
Current int64 `json:"current"` // 当前进度

View File

@@ -43,7 +43,13 @@ func (s *Service) handleAsk(c *gin.Context) {
task.DecisionChan = make(chan Decision, 1)
s.StoreTransferToList(&task)
if s.config.GetAutoAccept() {
// 从本地获取 peer 检查是否 mismatch
peer, ok := s.discoveryService.GetPeerByID(task.Sender.ID)
if ok {
task.Sender.TrustMismatch = peer.TrustMismatch
}
if s.config.GetAutoAccept() || (s.config.IsTrustedPeer(task.Sender.ID) && !task.Sender.TrustMismatch) {
task.DecisionChan <- Decision{
ID: task.ID,
Accepted: true,
@@ -54,7 +60,7 @@ func (s *Service) handleAsk(c *gin.Context) {
_ = s.notifier.SendNotification(notifications.NotificationOptions{
ID: uuid.New().String(),
Title: "File Transfer Request",
Body: fmt.Sprintf("%s(%s) wants to transfer %s", task.Sender.Name, task.Sender.IP, task.FileName),
Body: fmt.Sprintf("%s wants to transfer %s", task.Sender.Name, task.FileName),
})
}
@@ -74,6 +80,11 @@ func (s *Service) handleAsk(c *gin.Context) {
})
} else {
task.Status = TransferStatusRejected
c.JSON(http.StatusOK, TransferAskResponse{
ID: task.ID,
Accepted: false,
Message: "Transfer rejected",
})
}
case <-c.Request.Context().Done():
// 发送端放弃