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] 自动接收
|
||||||
- [x] 应用图标
|
- [x] 应用图标
|
||||||
|
- [x] 信任Peer
|
||||||
|
- [ ] 通过IP添加非局域网Peer
|
||||||
- [ ] 系统托盘(最小化到托盘)徽章 https://github.com/wailsapp/wails/issues/4494
|
- [ ] 系统托盘(最小化到托盘)徽章 https://github.com/wailsapp/wails/issues/4494
|
||||||
- [ ] 收藏Peer
|
|
||||||
- [ ] 多语言
|
- [ ] 多语言
|
||||||
|
|
||||||
## 技术栈
|
## 技术栈
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ tasks:
|
|||||||
# 1. Cross-compiling from non-Linux, OR
|
# 1. Cross-compiling from non-Linux, OR
|
||||||
# 2. No C compiler is available, OR
|
# 2. No C compiler is available, OR
|
||||||
# 3. Target architecture differs from host architecture (cross-arch compilation)
|
# 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:
|
vars:
|
||||||
ARCH: "{{.ARCH}}"
|
ARCH: "{{.ARCH}}"
|
||||||
DEV: "{{.DEV}}"
|
DEV: "{{.DEV}}"
|
||||||
|
|||||||
@@ -3,8 +3,8 @@ Type=Application
|
|||||||
Name=mesh-drop
|
Name=mesh-drop
|
||||||
Exec=mesh-drop
|
Exec=mesh-drop
|
||||||
Icon=mesh-drop
|
Icon=mesh-drop
|
||||||
Categories=GTK;FileTransfer;Utility;
|
Categories=GTK;Utility
|
||||||
Terminal=false
|
Terminal=false
|
||||||
Keywords=file transfer
|
Keywords=utility
|
||||||
Version=1.0
|
Version=1.0
|
||||||
StartupNotify=false
|
StartupNotify=false
|
||||||
|
|||||||
@@ -9,6 +9,10 @@ import { Call as $Call, CancellablePromise as $CancellablePromise, Create as $Cr
|
|||||||
// @ts-ignore: Unused imports
|
// @ts-ignore: Unused imports
|
||||||
import * as $models from "./models.js";
|
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> {
|
export function GetAutoAccept(): $CancellablePromise<boolean> {
|
||||||
return $Call.ByID(2605668438);
|
return $Call.ByID(2605668438);
|
||||||
}
|
}
|
||||||
@@ -29,16 +33,30 @@ export function GetSavePath(): $CancellablePromise<string> {
|
|||||||
return $Call.ByID(4081533263);
|
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> {
|
export function GetVersion(): $CancellablePromise<string> {
|
||||||
return $Call.ByID(3578438023);
|
return $Call.ByID(3578438023);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function GetWindowState(): $CancellablePromise<$models.WindowState> {
|
export function GetWindowState(): $CancellablePromise<$models.WindowState> {
|
||||||
return $Call.ByID(341414414).then(($result: any) => {
|
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 保存配置到磁盘
|
* Save 保存配置到磁盘
|
||||||
*/
|
*/
|
||||||
@@ -70,4 +88,5 @@ export function SetWindowState(state: $models.WindowState): $CancellablePromise<
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Private type creation functions
|
// 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;
|
"port": number;
|
||||||
"os": OS;
|
"os": OS;
|
||||||
|
"pk": string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* TrustMismatch 指示该节点的公钥与本地信任列表中的公钥不匹配
|
||||||
|
* 如果为 true,说明可能存在 ID 欺骗或密钥轮换
|
||||||
|
*/
|
||||||
|
"trust_mismatch": boolean;
|
||||||
|
|
||||||
/** Creates a new Peer instance. */
|
/** Creates a new Peer instance. */
|
||||||
constructor($$source: Partial<Peer> = {}) {
|
constructor($$source: Partial<Peer> = {}) {
|
||||||
@@ -65,6 +72,12 @@ export class Peer {
|
|||||||
if (!("os" in $$source)) {
|
if (!("os" in $$source)) {
|
||||||
this["os"] = OS.$zero;
|
this["os"] = OS.$zero;
|
||||||
}
|
}
|
||||||
|
if (!("pk" in $$source)) {
|
||||||
|
this["pk"] = "";
|
||||||
|
}
|
||||||
|
if (!("trust_mismatch" in $$source)) {
|
||||||
|
this["trust_mismatch"] = false;
|
||||||
|
}
|
||||||
|
|
||||||
Object.assign(this, $$source);
|
Object.assign(this, $$source);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,22 +17,29 @@ export function GetLocalIPInSameSubnet(receiverIP: string): $CancellablePromise<
|
|||||||
return $Call.ByID(3089425954, receiverIP);
|
return $Call.ByID(3089425954, receiverIP);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function GetLocalIPs(): $CancellablePromise<[string[], boolean]> {
|
export function GetPeerByID(id: string): $CancellablePromise<[$models.Peer | null, boolean]> {
|
||||||
return $Call.ByID(2403939179).then(($result: any) => {
|
return $Call.ByID(1962377788, id).then(($result: any) => {
|
||||||
$result[0] = $$createType0($result[0]);
|
$result[0] = $$createType1($result[0]);
|
||||||
return $result;
|
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 $Call.ByID(1626825408, ip).then(($result: any) => {
|
||||||
return $$createType2($result);
|
$result[0] = $$createType1($result[0]);
|
||||||
|
return $result;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function GetPeers(): $CancellablePromise<$models.Peer[]> {
|
export function GetPeers(): $CancellablePromise<$models.Peer[]> {
|
||||||
return $Call.ByID(3041084029).then(($result: any) => {
|
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
|
// Private type creation functions
|
||||||
const $$createType0 = $Create.Array($Create.Any);
|
const $$createType0 = $models.Peer.createFrom;
|
||||||
const $$createType1 = $models.Peer.createFrom;
|
const $$createType1 = $Create.Nullable($$createType0);
|
||||||
const $$createType2 = $Create.Nullable($$createType1);
|
const $$createType2 = $Create.Array($$createType0);
|
||||||
const $$createType3 = $Create.Array($$createType1);
|
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ export {
|
|||||||
export {
|
export {
|
||||||
ContentType,
|
ContentType,
|
||||||
Progress,
|
Progress,
|
||||||
Sender,
|
|
||||||
Transfer,
|
Transfer,
|
||||||
TransferStatus,
|
TransferStatus,
|
||||||
TransferType
|
TransferType
|
||||||
|
|||||||
@@ -5,6 +5,10 @@
|
|||||||
// @ts-ignore: Unused imports
|
// @ts-ignore: Unused imports
|
||||||
import { Create as $Create } from "@wailsio/runtime";
|
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 {
|
export enum ContentType {
|
||||||
/**
|
/**
|
||||||
* The Go zero value for the underlying type of the enum.
|
* 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
|
* 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;
|
this["create_time"] = 0;
|
||||||
}
|
}
|
||||||
if (!("sender" in $$source)) {
|
if (!("sender" in $$source)) {
|
||||||
this["sender"] = (new Sender());
|
this["sender"] = (new discovery$0.Peer());
|
||||||
}
|
}
|
||||||
if (!("file_name" in $$source)) {
|
if (!("file_name" in $$source)) {
|
||||||
this["file_name"] = "";
|
this["file_name"] = "";
|
||||||
@@ -256,5 +220,5 @@ export enum TransferType {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Private type creation functions
|
// Private type creation functions
|
||||||
const $$createType0 = Sender.createFrom;
|
const $$createType0 = discovery$0.Peer.createFrom;
|
||||||
const $$createType1 = Progress.createFrom;
|
const $$createType1 = Progress.createFrom;
|
||||||
|
|||||||
@@ -66,12 +66,10 @@ onMounted(async () => {
|
|||||||
// --- 后端集成 & 事件监听 ---
|
// --- 后端集成 & 事件监听 ---
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
peers.value = await GetPeers();
|
peers.value = await GetPeers();
|
||||||
peers.value = peers.value.sort((a, b) => a.name.localeCompare(b.name));
|
|
||||||
});
|
});
|
||||||
|
|
||||||
Events.On("peers:update", (event) => {
|
Events.On("peers:update", (event) => {
|
||||||
peers.value = event.data;
|
peers.value = event.data;
|
||||||
peers.value = peers.value.sort((a, b) => a.name.localeCompare(b.name));
|
|
||||||
});
|
});
|
||||||
|
|
||||||
Events.On("transfer:refreshList", async () => {
|
Events.On("transfer:refreshList", async () => {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
// --- Vue 核心 ---
|
// --- Vue 核心 ---
|
||||||
import { computed, ref, watch } from "vue";
|
import { computed, ref, watch, onMounted } from "vue";
|
||||||
|
|
||||||
// --- 组件 ---
|
// --- 组件 ---
|
||||||
import FileSendModal from "./modals/FileSendModal.vue";
|
import FileSendModal from "./modals/FileSendModal.vue";
|
||||||
@@ -13,6 +13,20 @@ import {
|
|||||||
SendText,
|
SendText,
|
||||||
} from "../../bindings/mesh-drop/internal/transfer/service";
|
} from "../../bindings/mesh-drop/internal/transfer/service";
|
||||||
import { Peer } from "../../bindings/mesh-drop/internal/discovery/models";
|
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<{
|
const props = defineProps<{
|
||||||
@@ -27,6 +41,7 @@ const emit = defineEmits<{
|
|||||||
const selectedIp = ref<string>("");
|
const selectedIp = ref<string>("");
|
||||||
const showFileModal = ref(false);
|
const showFileModal = ref(false);
|
||||||
const showTextModal = ref(false);
|
const showTextModal = ref(false);
|
||||||
|
const isTrusted = ref(false);
|
||||||
|
|
||||||
const sendOptions = [
|
const sendOptions = [
|
||||||
{
|
{
|
||||||
@@ -136,10 +151,20 @@ const handleSendClipboard = async () => {
|
|||||||
});
|
});
|
||||||
emit("transferStarted");
|
emit("transferStarted");
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleTrust = () => {
|
||||||
|
AddTrustedPeer(props.peer.id, props.peer.pk);
|
||||||
|
isTrusted.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUntrust = () => {
|
||||||
|
RemoveTrustedPeer(props.peer.id);
|
||||||
|
isTrusted.value = false;
|
||||||
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<v-card hover link class="peer-card pa-2">
|
<v-card hover link class="peer-card pa-2" :ripple="false">
|
||||||
<template #title>
|
<template #title>
|
||||||
<div class="d-flex align-center">
|
<div class="d-flex align-center">
|
||||||
<v-icon :icon="osIcon" size="24" class="mr-2"></v-icon>
|
<v-icon :icon="osIcon" size="24" class="mr-2"></v-icon>
|
||||||
@@ -187,12 +212,25 @@ const handleSendClipboard = async () => {
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template #actions>
|
<v-card-actions>
|
||||||
<v-menu>
|
<!-- 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 }">
|
<template #activator="{ props }">
|
||||||
<v-btn
|
<v-btn
|
||||||
v-bind="props"
|
v-bind="props"
|
||||||
block
|
class="flex-grow-1"
|
||||||
color="primary"
|
color="primary"
|
||||||
variant="tonal"
|
variant="tonal"
|
||||||
:disabled="ips.length === 0"
|
:disabled="ips.length === 0"
|
||||||
@@ -218,7 +256,32 @@ const handleSendClipboard = async () => {
|
|||||||
</v-list-item>
|
</v-list-item>
|
||||||
</v-list>
|
</v-list>
|
||||||
</v-menu>
|
</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>
|
</v-card>
|
||||||
|
|
||||||
<!-- Modals -->
|
<!-- Modals -->
|
||||||
|
|||||||
@@ -187,9 +187,23 @@ const handleCopy = async () => {
|
|||||||
v-if="
|
v-if="
|
||||||
props.transfer.sender.name && props.transfer.type === 'receive'
|
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 }}
|
{{ 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>
|
||||||
|
|
||||||
<v-chip
|
<v-chip
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
// --- Vue 核心 ---
|
// --- Vue 核心 ---
|
||||||
import { computed, ref } from "vue";
|
import { computed, ref, watch, nextTick } from "vue";
|
||||||
|
|
||||||
// --- Wails & 后端绑定 ---
|
// --- Wails & 后端绑定 ---
|
||||||
import { SendText } from "../../../bindings/mesh-drop/internal/transfer/service";
|
import { SendText } from "../../../bindings/mesh-drop/internal/transfer/service";
|
||||||
@@ -20,6 +20,7 @@ const emit = defineEmits<{
|
|||||||
|
|
||||||
// --- 状态 ---
|
// --- 状态 ---
|
||||||
const textContent = ref("");
|
const textContent = ref("");
|
||||||
|
const textareaRef = ref();
|
||||||
|
|
||||||
// --- 计算属性 ---
|
// --- 计算属性 ---
|
||||||
const show = computed({
|
const show = computed({
|
||||||
@@ -27,6 +28,14 @@ const show = computed({
|
|||||||
set: (value) => emit("update:modelValue", value),
|
set: (value) => emit("update:modelValue", value),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// --- 监听 ---
|
||||||
|
watch(show, async (val) => {
|
||||||
|
if (val) {
|
||||||
|
await nextTick();
|
||||||
|
textareaRef.value?.focus();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// --- 方法 ---
|
// --- 方法 ---
|
||||||
const executeSendText = async () => {
|
const executeSendText = async () => {
|
||||||
if (!props.selectedIp || !textContent.value) return;
|
if (!props.selectedIp || !textContent.value) return;
|
||||||
@@ -48,6 +57,7 @@ const executeSendText = async () => {
|
|||||||
<v-card title="Send Text">
|
<v-card title="Send Text">
|
||||||
<v-card-text>
|
<v-card-text>
|
||||||
<v-textarea
|
<v-textarea
|
||||||
|
ref="textareaRef"
|
||||||
v-model="textContent"
|
v-model="textContent"
|
||||||
label="Content"
|
label="Content"
|
||||||
placeholder="Type something to send..."
|
placeholder="Type something to send..."
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package config
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"log/slog"
|
"log/slog"
|
||||||
|
"mesh-drop/internal/security"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"sync"
|
"sync"
|
||||||
@@ -19,18 +20,21 @@ type WindowState struct {
|
|||||||
Maximised bool `mapstructure:"maximised"`
|
Maximised bool `mapstructure:"maximised"`
|
||||||
}
|
}
|
||||||
|
|
||||||
var Version = "0.0.2"
|
var Version = "next"
|
||||||
|
|
||||||
type Config struct {
|
type Config struct {
|
||||||
v *viper.Viper
|
v *viper.Viper
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
|
|
||||||
WindowState WindowState `mapstructure:"window_state"`
|
WindowState WindowState `mapstructure:"window_state"`
|
||||||
ID string `mapstructure:"id"`
|
ID string `mapstructure:"id"`
|
||||||
SavePath string `mapstructure:"save_path"`
|
PrivateKey string `mapstructure:"private_key"`
|
||||||
HostName string `mapstructure:"host_name"`
|
PublicKey string `mapstructure:"public_key"`
|
||||||
AutoAccept bool `mapstructure:"auto_accept"`
|
SavePath string `mapstructure:"save_path"`
|
||||||
SaveHistory bool `mapstructure:"save_history"`
|
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
|
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
|
return &config
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -111,7 +137,10 @@ func Load() *Config {
|
|||||||
func (c *Config) Save() error {
|
func (c *Config) Save() error {
|
||||||
c.mu.RLock()
|
c.mu.RLock()
|
||||||
defer c.mu.RUnlock()
|
defer c.mu.RUnlock()
|
||||||
|
return c.save()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Config) save() error {
|
||||||
configDir := GetConfigDir()
|
configDir := GetConfigDir()
|
||||||
if err := os.MkdirAll(configDir, 0755); err != nil {
|
if err := os.MkdirAll(configDir, 0755); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -122,6 +151,14 @@ func (c *Config) Save() error {
|
|||||||
return err
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -133,6 +170,7 @@ func (c *Config) SetSavePath(savePath string) {
|
|||||||
c.SavePath = savePath
|
c.SavePath = savePath
|
||||||
c.v.Set("save_path", savePath)
|
c.v.Set("save_path", savePath)
|
||||||
_ = os.MkdirAll(savePath, 0755)
|
_ = os.MkdirAll(savePath, 0755)
|
||||||
|
_ = c.save()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Config) GetSavePath() string {
|
func (c *Config) GetSavePath() string {
|
||||||
@@ -146,6 +184,7 @@ func (c *Config) SetHostName(hostName string) {
|
|||||||
defer c.mu.Unlock()
|
defer c.mu.Unlock()
|
||||||
c.HostName = hostName
|
c.HostName = hostName
|
||||||
c.v.Set("host_name", hostName)
|
c.v.Set("host_name", hostName)
|
||||||
|
_ = c.save()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Config) GetHostName() string {
|
func (c *Config) GetHostName() string {
|
||||||
@@ -165,6 +204,7 @@ func (c *Config) SetAutoAccept(autoAccept bool) {
|
|||||||
defer c.mu.Unlock()
|
defer c.mu.Unlock()
|
||||||
c.AutoAccept = autoAccept
|
c.AutoAccept = autoAccept
|
||||||
c.v.Set("auto_accept", autoAccept)
|
c.v.Set("auto_accept", autoAccept)
|
||||||
|
_ = c.save()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Config) GetAutoAccept() bool {
|
func (c *Config) GetAutoAccept() bool {
|
||||||
@@ -178,6 +218,7 @@ func (c *Config) SetSaveHistory(saveHistory bool) {
|
|||||||
defer c.mu.Unlock()
|
defer c.mu.Unlock()
|
||||||
c.SaveHistory = saveHistory
|
c.SaveHistory = saveHistory
|
||||||
c.v.Set("save_history", saveHistory)
|
c.v.Set("save_history", saveHistory)
|
||||||
|
_ = c.save()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Config) GetSaveHistory() bool {
|
func (c *Config) GetSaveHistory() bool {
|
||||||
@@ -195,6 +236,7 @@ func (c *Config) SetWindowState(state WindowState) {
|
|||||||
defer c.mu.Unlock()
|
defer c.mu.Unlock()
|
||||||
c.WindowState = state
|
c.WindowState = state
|
||||||
c.v.Set("window_state", state)
|
c.v.Set("window_state", state)
|
||||||
|
_ = c.save()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Config) GetWindowState() WindowState {
|
func (c *Config) GetWindowState() WindowState {
|
||||||
@@ -202,3 +244,35 @@ func (c *Config) GetWindowState() WindowState {
|
|||||||
defer c.mu.RUnlock()
|
defer c.mu.RUnlock()
|
||||||
return c.WindowState
|
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
|
package discovery
|
||||||
|
|
||||||
import "time"
|
import (
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
// Peer 代表一个可达的网络端点 (Network Endpoint)。
|
// Peer 代表一个可达的网络端点 (Network Endpoint)。
|
||||||
// 注意:一个物理设备 (Device) 可能通过多个网络接口广播,因此会对应多个 Peer 结构体。
|
// 注意:一个物理设备 (Device) 可能通过多个网络接口广播,因此会对应多个 Peer 结构体。
|
||||||
@@ -20,6 +23,12 @@ type Peer struct {
|
|||||||
Port int `json:"port"`
|
Port int `json:"port"`
|
||||||
|
|
||||||
OS OS `json:"os"`
|
OS OS `json:"os"`
|
||||||
|
|
||||||
|
PublicKey string `json:"pk"`
|
||||||
|
|
||||||
|
// TrustMismatch 指示该节点的公钥与本地信任列表中的公钥不匹配
|
||||||
|
// 如果为 true,说明可能存在 ID 欺骗或密钥轮换
|
||||||
|
TrustMismatch bool `json:"trust_mismatch"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// RouteState 记录单条路径的状态
|
// RouteState 记录单条路径的状态
|
||||||
@@ -38,8 +47,17 @@ const (
|
|||||||
|
|
||||||
// PresencePacket 是 UDP 广播的载荷
|
// PresencePacket 是 UDP 广播的载荷
|
||||||
type PresencePacket struct {
|
type PresencePacket struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Port int `json:"port"`
|
Port int `json:"port"`
|
||||||
OS OS `json:"os"`
|
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"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"mesh-drop/internal/config"
|
"mesh-drop/internal/config"
|
||||||
|
"mesh-drop/internal/security"
|
||||||
"net"
|
"net"
|
||||||
"runtime"
|
"runtime"
|
||||||
|
"sort"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -15,8 +17,8 @@ import (
|
|||||||
|
|
||||||
const (
|
const (
|
||||||
DiscoveryPort = 9988
|
DiscoveryPort = 9988
|
||||||
HeartbeatRate = 3 * time.Second
|
HeartbeatRate = 1 * time.Second
|
||||||
PeerTimeout = 10 * time.Second
|
PeerTimeout = 2 * time.Second
|
||||||
)
|
)
|
||||||
|
|
||||||
type Service struct {
|
type Service struct {
|
||||||
@@ -26,9 +28,11 @@ type Service struct {
|
|||||||
config *config.Config
|
config *config.Config
|
||||||
FileServerPort int
|
FileServerPort int
|
||||||
|
|
||||||
// key 使用 peer.id 和 peer.ip 组合而成的 hash
|
// Key: peer.ID
|
||||||
peers map[string]*Peer
|
peers map[string]*Peer
|
||||||
peersMutex sync.RWMutex
|
peersMutex sync.RWMutex
|
||||||
|
|
||||||
|
self Peer
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewService(config *config.Config, app *application.App, port int) *Service {
|
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,
|
config: config,
|
||||||
FileServerPort: port,
|
FileServerPort: port,
|
||||||
peers: make(map[string]*Peer),
|
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()
|
interfaces, err := net.Interfaces()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("Failed to get network interfaces", "error", err, "component", "discovery")
|
slog.Error("Failed to get network interfaces", "error", err, "component", "discovery")
|
||||||
@@ -114,11 +125,22 @@ func (s *Service) startBroadcasting() {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
packet := PresencePacket{
|
packet := PresencePacket{
|
||||||
ID: s.ID,
|
ID: s.ID,
|
||||||
Name: s.config.GetHostName(),
|
Name: s.config.GetHostName(),
|
||||||
Port: s.FileServerPort,
|
Port: s.FileServerPort,
|
||||||
OS: OS(runtime.GOOS),
|
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)
|
data, _ := json.Marshal(packet)
|
||||||
for _, iface := range interfaces {
|
for _, iface := range interfaces {
|
||||||
// 过滤掉 Down 的接口和 Loopback 接口
|
// 过滤掉 Down 的接口和 Loopback 接口
|
||||||
@@ -195,12 +217,33 @@ func (s *Service) startListening() {
|
|||||||
continue
|
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 处理心跳包
|
// handleHeartbeat 处理心跳包
|
||||||
func (s *Service) handleHeartbeat(pkt PresencePacket, ip string) {
|
func (s *Service) handleHeartbeat(pkt PresencePacket, ip string, trustMismatch bool) {
|
||||||
s.peersMutex.Lock()
|
s.peersMutex.Lock()
|
||||||
|
|
||||||
peer, exists := s.peers[pkt.ID]
|
peer, exists := s.peers[pkt.ID]
|
||||||
@@ -215,19 +258,27 @@ func (s *Service) handleHeartbeat(pkt PresencePacket, ip string) {
|
|||||||
LastSeen: time.Now(),
|
LastSeen: time.Now(),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
Port: pkt.Port,
|
Port: pkt.Port,
|
||||||
OS: pkt.OS,
|
OS: pkt.OS,
|
||||||
|
PublicKey: pkt.PublicKey,
|
||||||
|
TrustMismatch: trustMismatch,
|
||||||
}
|
}
|
||||||
s.peers[peer.ID] = peer
|
s.peers[peer.ID] = peer
|
||||||
slog.Info("New device found", "name", pkt.Name, "ip", ip, "component", "discovery")
|
slog.Info("New device found", "name", pkt.Name, "ip", ip, "component", "discovery")
|
||||||
} else {
|
} else {
|
||||||
// 更新节点
|
// 更新节点
|
||||||
peer.Name = pkt.Name
|
// 只有在没有身份不匹配的情况下才更新元数据,防止欺骗攻击导致 UI 闪烁/篡改
|
||||||
peer.OS = pkt.OS
|
if !trustMismatch {
|
||||||
|
peer.Name = pkt.Name
|
||||||
|
peer.OS = pkt.OS
|
||||||
|
peer.PublicKey = pkt.PublicKey
|
||||||
|
}
|
||||||
peer.Routes[ip] = &RouteState{
|
peer.Routes[ip] = &RouteState{
|
||||||
IP: ip,
|
IP: ip,
|
||||||
LastSeen: time.Now(),
|
LastSeen: time.Now(),
|
||||||
}
|
}
|
||||||
|
// 如果之前存在不匹配,即使这次匹配了,也不要重置,防止欺骗攻击
|
||||||
|
peer.TrustMismatch = peer.TrustMismatch || trustMismatch
|
||||||
}
|
}
|
||||||
|
|
||||||
s.peersMutex.Unlock()
|
s.peersMutex.Unlock()
|
||||||
@@ -246,7 +297,6 @@ func (s *Service) startCleanup() {
|
|||||||
|
|
||||||
for id, peer := range s.peers {
|
for id, peer := range s.peers {
|
||||||
for ip, route := range peer.Routes {
|
for ip, route := range peer.Routes {
|
||||||
// 超过10秒没心跳,认为下线
|
|
||||||
if now.Sub(route.LastSeen) > PeerTimeout {
|
if now.Sub(route.LastSeen) > PeerTimeout {
|
||||||
delete(peer.Routes, ip)
|
delete(peer.Routes, ip)
|
||||||
changed = true
|
changed = true
|
||||||
@@ -274,16 +324,24 @@ func (s *Service) Start() {
|
|||||||
go s.startCleanup()
|
go s.startCleanup()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) GetPeerByIP(ip string) *Peer {
|
func (s *Service) GetPeerByIP(ip string) (*Peer, bool) {
|
||||||
s.peersMutex.RLock()
|
s.peersMutex.RLock()
|
||||||
defer s.peersMutex.RUnlock()
|
defer s.peersMutex.RUnlock()
|
||||||
|
|
||||||
for _, p := range s.peers {
|
for _, p := range s.peers {
|
||||||
if p.Routes[ip] != nil {
|
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 {
|
func (s *Service) GetPeers() []Peer {
|
||||||
@@ -294,9 +352,16 @@ func (s *Service) GetPeers() []Peer {
|
|||||||
for _, p := range s.peers {
|
for _, p := range s.peers {
|
||||||
list = append(list, *p)
|
list = append(list, *p)
|
||||||
}
|
}
|
||||||
|
sort.Slice(list, func(i, j int) bool {
|
||||||
|
return list[i].Name < list[j].Name
|
||||||
|
})
|
||||||
return list
|
return list
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) GetID() string {
|
func (s *Service) GetID() string {
|
||||||
return s.ID
|
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(
|
task := NewTransfer(
|
||||||
taskID,
|
taskID,
|
||||||
NewSender(
|
s.discoveryService.GetSelf(),
|
||||||
s.discoveryService.GetID(),
|
|
||||||
s.config.GetHostName(),
|
|
||||||
WithReceiverIP(targetIP, s.discoveryService),
|
|
||||||
),
|
|
||||||
WithFileName(filepath.Base(filePath)),
|
WithFileName(filepath.Base(filePath)),
|
||||||
WithFileSize(stat.Size()),
|
WithFileSize(stat.Size()),
|
||||||
WithType(TransferTypeSend),
|
WithType(TransferTypeSend),
|
||||||
@@ -111,11 +107,7 @@ func (s *Service) SendFolder(target *discovery.Peer, targetIP string, folderPath
|
|||||||
|
|
||||||
task := NewTransfer(
|
task := NewTransfer(
|
||||||
taskID,
|
taskID,
|
||||||
NewSender(
|
s.discoveryService.GetSelf(),
|
||||||
s.discoveryService.GetID(),
|
|
||||||
s.config.GetHostName(),
|
|
||||||
WithReceiverIP(targetIP, s.discoveryService),
|
|
||||||
),
|
|
||||||
WithFileName(filepath.Base(folderPath)),
|
WithFileName(filepath.Base(folderPath)),
|
||||||
WithFileSize(size),
|
WithFileSize(size),
|
||||||
WithType(TransferTypeSend),
|
WithType(TransferTypeSend),
|
||||||
@@ -164,11 +156,7 @@ func (s *Service) SendText(target *discovery.Peer, targetIP string, text string)
|
|||||||
r := bytes.NewReader([]byte(text))
|
r := bytes.NewReader([]byte(text))
|
||||||
task := NewTransfer(
|
task := NewTransfer(
|
||||||
taskID,
|
taskID,
|
||||||
NewSender(
|
s.discoveryService.GetSelf(),
|
||||||
s.discoveryService.GetID(),
|
|
||||||
s.config.GetHostName(),
|
|
||||||
WithReceiverIP(targetIP, s.discoveryService),
|
|
||||||
),
|
|
||||||
WithFileSize(int64(len(text))),
|
WithFileSize(int64(len(text))),
|
||||||
WithType(TransferTypeSend),
|
WithType(TransferTypeSend),
|
||||||
WithContentType(ContentTypeText),
|
WithContentType(ContentTypeText),
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
package transfer
|
package transfer
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"log/slog"
|
|
||||||
"mesh-drop/internal/discovery"
|
"mesh-drop/internal/discovery"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
@@ -37,7 +36,7 @@ const (
|
|||||||
type Transfer struct {
|
type Transfer struct {
|
||||||
ID string `json:"id" binding:"required"` // 传输会话 ID
|
ID string `json:"id" binding:"required"` // 传输会话 ID
|
||||||
CreateTime int64 `json:"create_time"` // 创建时间
|
CreateTime int64 `json:"create_time"` // 创建时间
|
||||||
Sender Sender `json:"sender" binding:"required"` // 发送者
|
Sender discovery.Peer `json:"sender" binding:"required"` // 发送者
|
||||||
FileName string `json:"file_name"` // 文件名
|
FileName string `json:"file_name"` // 文件名
|
||||||
FileSize int64 `json:"file_size"` // 文件大小 (字节)
|
FileSize int64 `json:"file_size"` // 文件大小 (字节)
|
||||||
SavePath string `json:"savePath"` // 保存路径
|
SavePath string `json:"savePath"` // 保存路径
|
||||||
@@ -53,7 +52,7 @@ type Transfer struct {
|
|||||||
|
|
||||||
type TransferOption func(*Transfer)
|
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{
|
t := &Transfer{
|
||||||
ID: id,
|
ID: id,
|
||||||
CreateTime: time.Now().UnixMilli(),
|
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 用户前端传输进度
|
// Progress 用户前端传输进度
|
||||||
type Progress struct {
|
type Progress struct {
|
||||||
Current int64 `json:"current"` // 当前进度
|
Current int64 `json:"current"` // 当前进度
|
||||||
|
|||||||
@@ -43,7 +43,13 @@ func (s *Service) handleAsk(c *gin.Context) {
|
|||||||
task.DecisionChan = make(chan Decision, 1)
|
task.DecisionChan = make(chan Decision, 1)
|
||||||
s.StoreTransferToList(&task)
|
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{
|
task.DecisionChan <- Decision{
|
||||||
ID: task.ID,
|
ID: task.ID,
|
||||||
Accepted: true,
|
Accepted: true,
|
||||||
@@ -54,7 +60,7 @@ func (s *Service) handleAsk(c *gin.Context) {
|
|||||||
_ = s.notifier.SendNotification(notifications.NotificationOptions{
|
_ = s.notifier.SendNotification(notifications.NotificationOptions{
|
||||||
ID: uuid.New().String(),
|
ID: uuid.New().String(),
|
||||||
Title: "File Transfer Request",
|
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 {
|
} else {
|
||||||
task.Status = TransferStatusRejected
|
task.Status = TransferStatusRejected
|
||||||
|
c.JSON(http.StatusOK, TransferAskResponse{
|
||||||
|
ID: task.ID,
|
||||||
|
Accepted: false,
|
||||||
|
Message: "Transfer rejected",
|
||||||
|
})
|
||||||
}
|
}
|
||||||
case <-c.Request.Context().Done():
|
case <-c.Request.Context().Done():
|
||||||
// 发送端放弃
|
// 发送端放弃
|
||||||
|
|||||||
Reference in New Issue
Block a user