feat: trust peer
This commit is contained in:
23
README.md
23
README.md
@@ -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 的 ID,UI 会显示明显的“Mismatch”安全警告,并阻止元数据被覆盖。
|
||||
|
||||
3. **传输加密 (Encryption)**
|
||||
- 文件传输服务使用 HTTPS 协议。
|
||||
- 自动生成自签名证书进行通信加密,防止传输内容被窃听。
|
||||
|
||||
## 截图
|
||||
|
||||
@@ -27,8 +47,9 @@
|
||||
- [x] 清理历史
|
||||
- [x] 自动接收
|
||||
- [x] 应用图标
|
||||
- [x] 信任Peer
|
||||
- [ ] 通过IP添加非局域网Peer
|
||||
- [ ] 系统托盘(最小化到托盘)徽章 https://github.com/wailsapp/wails/issues/4494
|
||||
- [ ] 收藏Peer
|
||||
- [ ] 多语言
|
||||
|
||||
## 技术栈
|
||||
|
||||
@@ -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}}"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -9,7 +9,6 @@ export {
|
||||
export {
|
||||
ContentType,
|
||||
Progress,
|
||||
Sender,
|
||||
Transfer,
|
||||
TransferStatus,
|
||||
TransferType
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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 -->
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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..."
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
56
internal/security/identity.go
Normal file
56
internal/security/identity.go
Normal 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
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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"` // 当前进度
|
||||
|
||||
@@ -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():
|
||||
// 发送端放弃
|
||||
|
||||
Reference in New Issue
Block a user