重构:把剩余 12 个顶层包全部迁到 internal/
继续之前的重构:把根目录残留的非数据/资源类子目录(agents、agenttool、ai、 autoreply、completion、conversation、llm、message、mqtpp、stream、 toolmanager、toolrouter)一并搬到 internal/ 下,并把全工程引用它们的 import 路径从 "meshtastic_mqtt_server/<x>" 重写为 "meshtastic_mqtt_server/internal/<x>"。 变更 - git mv 12 个顶层目录进 internal/,无函数体改动。 - 全工程 sed 把 import path 加上 internal/ 前缀,包括 main.go、 internal/bot/bot_service.go、internal/store/bot_store.go 中对 mqtpp 的引用,以及 ai 子系统内部 agents/agenttool/autoreply/conversation/ llm/toolmanager/toolrouter 之间的相互引用。 验证 - go build ./... 通过;go test ./... 全部包通过。 - AI 自动回复链路(main → ai.NewService → autoreply.Service → botSender) 保持不变,仅 import 路径调整。 - 根目录最终只剩 main.go / main_test.go / internal/ 加 go.mod / go.sum 与数据资源目录(dist、doc、firmware、py、win、meshmap_frontend)。 Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,267 @@
|
||||
package mqtpp
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"google.golang.org/protobuf/encoding/protowire"
|
||||
)
|
||||
|
||||
const NodeNumBroadcast uint32 = 0xffffffff
|
||||
|
||||
type PacketBuildOptions struct {
|
||||
FromNodeNum uint32
|
||||
ToNodeNum uint32
|
||||
PacketID uint32
|
||||
ChannelID string
|
||||
GatewayID string
|
||||
PSK []byte
|
||||
Encrypt bool
|
||||
ViaMQTT bool
|
||||
}
|
||||
|
||||
type TextMessageBuildOptions struct {
|
||||
PacketBuildOptions
|
||||
Text string
|
||||
}
|
||||
|
||||
type NodeInfoBuildOptions struct {
|
||||
PacketBuildOptions
|
||||
NodeID string
|
||||
LongName string
|
||||
ShortName string
|
||||
HWModel uint32
|
||||
Role uint32
|
||||
IsLicensed bool
|
||||
PublicKey []byte
|
||||
}
|
||||
|
||||
// AckBuildOptions 描述构造一个 Routing-NONE ACK(PSK 频道路径)所需字段。
|
||||
// RequestID 是被 ACK 原始包的 packet_id(写入 Data.request_id, tag 6)。
|
||||
type AckBuildOptions struct {
|
||||
PacketBuildOptions
|
||||
RequestID uint32
|
||||
}
|
||||
|
||||
func BuildTextMessageServiceEnvelope(opts TextMessageBuildOptions) ([]byte, error) {
|
||||
if opts.FromNodeNum == 0 {
|
||||
return nil, fmt.Errorf("from node number is required")
|
||||
}
|
||||
if opts.PacketID == 0 {
|
||||
return nil, fmt.Errorf("packet id is required")
|
||||
}
|
||||
if opts.ChannelID == "" {
|
||||
return nil, fmt.Errorf("channel id is required")
|
||||
}
|
||||
if strings.TrimSpace(opts.GatewayID) == "" {
|
||||
opts.GatewayID = NodeNumToID(opts.FromNodeNum)
|
||||
}
|
||||
if opts.Text == "" {
|
||||
return nil, fmt.Errorf("text is required")
|
||||
}
|
||||
if !utf8.ValidString(opts.Text) {
|
||||
return nil, fmt.Errorf("text must be valid utf-8")
|
||||
}
|
||||
|
||||
data := buildDataPacket(textMessageApp, []byte(opts.Text))
|
||||
packet, err := buildMeshPacket(opts.PacketBuildOptions, data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return buildServiceEnvelope(packet, opts.ChannelID, opts.GatewayID), nil
|
||||
}
|
||||
|
||||
func BuildNodeInfoServiceEnvelope(opts NodeInfoBuildOptions) ([]byte, error) {
|
||||
if opts.NodeID == "" {
|
||||
opts.NodeID = NodeNumToID(opts.FromNodeNum)
|
||||
}
|
||||
opts.NodeID = truncateUTF8Bytes(opts.NodeID, 16)
|
||||
opts.LongName = truncateUTF8Bytes(strings.TrimSpace(opts.LongName), 40)
|
||||
opts.ShortName = truncateUTF8Bytes(strings.TrimSpace(opts.ShortName), 5)
|
||||
if opts.LongName == "" {
|
||||
return nil, fmt.Errorf("long name is required")
|
||||
}
|
||||
if opts.ShortName == "" {
|
||||
return nil, fmt.Errorf("short name is required")
|
||||
}
|
||||
if len(opts.PublicKey) > 32 {
|
||||
opts.PublicKey = opts.PublicKey[:32]
|
||||
}
|
||||
user := buildUserPacket(opts)
|
||||
data := buildDataPacket(nodeInfoApp, user)
|
||||
packet, err := buildMeshPacket(opts.PacketBuildOptions, data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return buildServiceEnvelope(packet, opts.ChannelID, opts.GatewayID), nil
|
||||
}
|
||||
|
||||
// BuildAckServiceEnvelope 构造一个 PSK 频道上的 Routing ACK(error_reason=NONE)。
|
||||
// 与固件 MeshModule::allocAckNak/Router::sendAckNak 行为对齐:
|
||||
// - portnum = ROUTING_APP(5)
|
||||
// - Data.request_id = 原始包 ID
|
||||
// - Routing.which_variant = error_reason,error_reason = NONE(0)
|
||||
//
|
||||
// 用 PacketBuildOptions 中 ChannelID + PSK 加密;调用方负责把 ToNodeNum 设为原 from。
|
||||
func BuildAckServiceEnvelope(opts AckBuildOptions) ([]byte, error) {
|
||||
if opts.RequestID == 0 {
|
||||
return nil, fmt.Errorf("ack request_id is required")
|
||||
}
|
||||
if opts.ChannelID == "" {
|
||||
return nil, fmt.Errorf("channel id is required")
|
||||
}
|
||||
data := buildAckDataPacket(opts.RequestID)
|
||||
packet, err := buildMeshPacket(opts.PacketBuildOptions, data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if strings.TrimSpace(opts.GatewayID) == "" {
|
||||
opts.GatewayID = NodeNumToID(opts.FromNodeNum)
|
||||
}
|
||||
return buildServiceEnvelope(packet, opts.ChannelID, opts.GatewayID), nil
|
||||
}
|
||||
|
||||
// buildAckDataPacket 构造 Data { portnum=ROUTING_APP, payload=Routing{error_reason=NONE}, request_id=req }。
|
||||
func buildAckDataPacket(requestID uint32) []byte {
|
||||
// Routing payload: oneof variant=error_reason(tag 3), value=NONE(0) → 0x18 0x00
|
||||
routing := protowire.AppendTag(nil, 3, protowire.VarintType)
|
||||
routing = protowire.AppendVarint(routing, 0)
|
||||
|
||||
var out []byte
|
||||
out = protowire.AppendTag(out, 1, protowire.VarintType)
|
||||
out = protowire.AppendVarint(out, uint64(routingApp))
|
||||
out = protowire.AppendTag(out, 2, protowire.BytesType)
|
||||
out = protowire.AppendBytes(out, routing)
|
||||
out = protowire.AppendTag(out, 6, protowire.Fixed32Type)
|
||||
out = protowire.AppendFixed32(out, requestID)
|
||||
return out
|
||||
}
|
||||
|
||||
func NodeNumToID(nodeNum uint32) string {
|
||||
return nodeNumToID(nodeNum)
|
||||
}
|
||||
|
||||
func truncateUTF8Bytes(value string, maxBytes int) string {
|
||||
if maxBytes <= 0 || len(value) <= maxBytes {
|
||||
return value
|
||||
}
|
||||
out := make([]byte, 0, maxBytes)
|
||||
for _, r := range value {
|
||||
part := string(r)
|
||||
if len(out)+len(part) > maxBytes {
|
||||
break
|
||||
}
|
||||
out = append(out, part...)
|
||||
}
|
||||
return string(out)
|
||||
}
|
||||
|
||||
func ParseNodeID(nodeID string) (uint32, error) {
|
||||
value := strings.TrimSpace(nodeID)
|
||||
if value == "" {
|
||||
return 0, fmt.Errorf("node id is required")
|
||||
}
|
||||
value = strings.TrimPrefix(value, "!")
|
||||
if len(value) != 8 {
|
||||
return 0, fmt.Errorf("node id must be !xxxxxxxx")
|
||||
}
|
||||
num, err := strconv.ParseUint(value, 16, 32)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("invalid node id: %w", err)
|
||||
}
|
||||
return uint32(num), nil
|
||||
}
|
||||
|
||||
func buildDataPacket(portnum uint32, payload []byte) []byte {
|
||||
var out []byte
|
||||
out = protowire.AppendTag(out, 1, protowire.VarintType)
|
||||
out = protowire.AppendVarint(out, uint64(portnum))
|
||||
out = protowire.AppendTag(out, 2, protowire.BytesType)
|
||||
out = protowire.AppendBytes(out, payload)
|
||||
return out
|
||||
}
|
||||
|
||||
func buildUserPacket(opts NodeInfoBuildOptions) []byte {
|
||||
var out []byte
|
||||
out = protowire.AppendTag(out, 1, protowire.BytesType)
|
||||
out = protowire.AppendBytes(out, []byte(opts.NodeID))
|
||||
out = protowire.AppendTag(out, 2, protowire.BytesType)
|
||||
out = protowire.AppendBytes(out, []byte(opts.LongName))
|
||||
out = protowire.AppendTag(out, 3, protowire.BytesType)
|
||||
out = protowire.AppendBytes(out, []byte(opts.ShortName))
|
||||
if opts.HWModel != 0 {
|
||||
out = protowire.AppendTag(out, 5, protowire.VarintType)
|
||||
out = protowire.AppendVarint(out, uint64(opts.HWModel))
|
||||
}
|
||||
out = protowire.AppendTag(out, 6, protowire.VarintType)
|
||||
if opts.IsLicensed {
|
||||
out = protowire.AppendVarint(out, 1)
|
||||
} else {
|
||||
out = protowire.AppendVarint(out, 0)
|
||||
}
|
||||
out = protowire.AppendTag(out, 7, protowire.VarintType)
|
||||
out = protowire.AppendVarint(out, uint64(opts.Role))
|
||||
if len(opts.PublicKey) > 0 {
|
||||
out = protowire.AppendTag(out, 8, protowire.BytesType)
|
||||
out = protowire.AppendBytes(out, opts.PublicKey)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func buildMeshPacket(opts PacketBuildOptions, data []byte) ([]byte, error) {
|
||||
if opts.FromNodeNum == 0 {
|
||||
return nil, fmt.Errorf("from node number is required")
|
||||
}
|
||||
if opts.PacketID == 0 {
|
||||
return nil, fmt.Errorf("packet id is required")
|
||||
}
|
||||
if opts.ChannelID == "" {
|
||||
return nil, fmt.Errorf("channel id is required")
|
||||
}
|
||||
if strings.TrimSpace(opts.GatewayID) == "" {
|
||||
opts.GatewayID = NodeNumToID(opts.FromNodeNum)
|
||||
}
|
||||
var out []byte
|
||||
out = protowire.AppendTag(out, 1, protowire.Fixed32Type)
|
||||
out = protowire.AppendFixed32(out, opts.FromNodeNum)
|
||||
out = protowire.AppendTag(out, 2, protowire.Fixed32Type)
|
||||
out = protowire.AppendFixed32(out, opts.ToNodeNum)
|
||||
|
||||
if opts.Encrypt {
|
||||
if len(opts.PSK) == 0 {
|
||||
return nil, fmt.Errorf("psk is required for encrypted packet")
|
||||
}
|
||||
ciphertext, err := cryptAESCTR(opts.PSK, opts.FromNodeNum, opts.PacketID, data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = protowire.AppendTag(out, 3, protowire.VarintType)
|
||||
out = protowire.AppendVarint(out, uint64(channelHash(opts.ChannelID, opts.PSK)))
|
||||
out = protowire.AppendTag(out, 5, protowire.BytesType)
|
||||
out = protowire.AppendBytes(out, ciphertext)
|
||||
} else {
|
||||
out = protowire.AppendTag(out, 4, protowire.BytesType)
|
||||
out = protowire.AppendBytes(out, data)
|
||||
}
|
||||
|
||||
out = protowire.AppendTag(out, 6, protowire.Fixed32Type)
|
||||
out = protowire.AppendFixed32(out, opts.PacketID)
|
||||
if opts.ViaMQTT {
|
||||
out = protowire.AppendTag(out, 14, protowire.VarintType)
|
||||
out = protowire.AppendVarint(out, 1)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func buildServiceEnvelope(packet []byte, channelID string, gatewayID string) []byte {
|
||||
var out []byte
|
||||
out = protowire.AppendTag(out, 1, protowire.BytesType)
|
||||
out = protowire.AppendBytes(out, packet)
|
||||
out = protowire.AppendTag(out, 2, protowire.BytesType)
|
||||
out = protowire.AppendBytes(out, []byte(channelID))
|
||||
out = protowire.AppendTag(out, 3, protowire.BytesType)
|
||||
out = protowire.AppendBytes(out, []byte(gatewayID))
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
package mqtpp
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestBuildTextMessageServiceEnvelopeRoundTrip(t *testing.T) {
|
||||
key, err := ExpandPSK("AQ==")
|
||||
if err != nil {
|
||||
t.Fatalf("ExpandPSK() error = %v", err)
|
||||
}
|
||||
|
||||
raw, err := BuildTextMessageServiceEnvelope(TextMessageBuildOptions{
|
||||
PacketBuildOptions: PacketBuildOptions{
|
||||
FromNodeNum: 0x12345678,
|
||||
ToNodeNum: NodeNumBroadcast,
|
||||
PacketID: 0x87654321,
|
||||
ChannelID: "LongFast",
|
||||
GatewayID: "!12345678",
|
||||
PSK: key,
|
||||
Encrypt: true,
|
||||
ViaMQTT: true,
|
||||
},
|
||||
Text: "hello from bot",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildTextMessageServiceEnvelope() error = %v", err)
|
||||
}
|
||||
|
||||
valid, _, record := MQTTPP("msh/2/e/LongFast/!12345678", raw, key, Options{})
|
||||
if !valid {
|
||||
t.Fatalf("MQTTPP() valid = false, record = %#v", record)
|
||||
}
|
||||
if record["type"] != "text_message" {
|
||||
t.Fatalf("record type = %v", record["type"])
|
||||
}
|
||||
if record["text"] != "hello from bot" {
|
||||
t.Fatalf("text = %v", record["text"])
|
||||
}
|
||||
if record["from_num"] != uint32(0x12345678) {
|
||||
t.Fatalf("from_num = %v", record["from_num"])
|
||||
}
|
||||
if record["packet_to_num"] != uint32(NodeNumBroadcast) {
|
||||
t.Fatalf("packet_to_num = %v", record["packet_to_num"])
|
||||
}
|
||||
if record["decrypt_success"] != true {
|
||||
t.Fatalf("decrypt_success = %v", record["decrypt_success"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildTextMessageServiceEnvelopeDirectRoundTrip(t *testing.T) {
|
||||
key, err := ExpandPSK("AQ==")
|
||||
if err != nil {
|
||||
t.Fatalf("ExpandPSK() error = %v", err)
|
||||
}
|
||||
|
||||
raw, err := BuildTextMessageServiceEnvelope(TextMessageBuildOptions{
|
||||
PacketBuildOptions: PacketBuildOptions{
|
||||
FromNodeNum: 0x12345678,
|
||||
ToNodeNum: 0x10203040,
|
||||
PacketID: 0x11111111,
|
||||
ChannelID: "LongFast",
|
||||
GatewayID: "!12345678",
|
||||
PSK: key,
|
||||
Encrypt: true,
|
||||
ViaMQTT: true,
|
||||
},
|
||||
Text: "direct hello",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildTextMessageServiceEnvelope() error = %v", err)
|
||||
}
|
||||
|
||||
valid, _, record := MQTTPP("msh/2/e/LongFast/!12345678", raw, key, Options{})
|
||||
if !valid {
|
||||
t.Fatalf("MQTTPP() valid = false, record = %#v", record)
|
||||
}
|
||||
if record["text"] != "direct hello" {
|
||||
t.Fatalf("text = %v", record["text"])
|
||||
}
|
||||
if record["packet_to"] != "!10203040" {
|
||||
t.Fatalf("packet_to = %v", record["packet_to"])
|
||||
}
|
||||
if record["packet_to_num"] != uint32(0x10203040) {
|
||||
t.Fatalf("packet_to_num = %v", record["packet_to_num"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildNodeInfoServiceEnvelopeRoundTrip(t *testing.T) {
|
||||
key, err := ExpandPSK("AQ==")
|
||||
if err != nil {
|
||||
t.Fatalf("ExpandPSK() error = %v", err)
|
||||
}
|
||||
|
||||
raw, err := BuildNodeInfoServiceEnvelope(NodeInfoBuildOptions{
|
||||
PacketBuildOptions: PacketBuildOptions{
|
||||
FromNodeNum: 0x12345678,
|
||||
ToNodeNum: NodeNumBroadcast,
|
||||
PacketID: 0x22222222,
|
||||
ChannelID: "LongFast",
|
||||
GatewayID: "!12345678",
|
||||
PSK: key,
|
||||
Encrypt: true,
|
||||
ViaMQTT: true,
|
||||
},
|
||||
NodeID: "!12345678",
|
||||
LongName: "MQTT Bot",
|
||||
ShortName: "BT",
|
||||
HWModel: 255,
|
||||
Role: 0,
|
||||
IsLicensed: false,
|
||||
PublicKey: []byte{1, 2, 3},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildNodeInfoServiceEnvelope() error = %v", err)
|
||||
}
|
||||
|
||||
valid, _, record := MQTTPP("msh/2/e/LongFast/!12345678", raw, key, Options{})
|
||||
if !valid {
|
||||
t.Fatalf("MQTTPP() valid = false, record = %#v", record)
|
||||
}
|
||||
if record["type"] != "nodeinfo" {
|
||||
t.Fatalf("record type = %v", record["type"])
|
||||
}
|
||||
if record["long_name"] != "MQTT Bot" {
|
||||
t.Fatalf("long_name = %v", record["long_name"])
|
||||
}
|
||||
if record["short_name"] != "BT" {
|
||||
t.Fatalf("short_name = %v", record["short_name"])
|
||||
}
|
||||
if record["hw_model"] != "PRIVATE_HW" {
|
||||
t.Fatalf("hw_model = %v", record["hw_model"])
|
||||
}
|
||||
if record["role"] != "CLIENT" {
|
||||
t.Fatalf("role = %v", record["role"])
|
||||
}
|
||||
if record["is_licensed"] != false {
|
||||
t.Fatalf("is_licensed = %v", record["is_licensed"])
|
||||
}
|
||||
if record["public_key"] != "010203" {
|
||||
t.Fatalf("public_key = %v", record["public_key"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildNodeInfoTruncatesNanopbStrings(t *testing.T) {
|
||||
key, err := ExpandPSK("AQ==")
|
||||
if err != nil {
|
||||
t.Fatalf("ExpandPSK() error = %v", err)
|
||||
}
|
||||
|
||||
raw, err := BuildNodeInfoServiceEnvelope(NodeInfoBuildOptions{
|
||||
PacketBuildOptions: PacketBuildOptions{FromNodeNum: 0x12345678, ToNodeNum: NodeNumBroadcast, PacketID: 0x33333333, ChannelID: "LongFast", GatewayID: "!12345678", PSK: key, Encrypt: true, ViaMQTT: true},
|
||||
NodeID: "!12345678",
|
||||
LongName: "这是一个非常非常非常非常长的机器人节点名称",
|
||||
ShortName: "机器人",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildNodeInfoServiceEnvelope() error = %v", err)
|
||||
}
|
||||
valid, _, record := MQTTPP("msh/2/e/LongFast/!12345678", raw, key, Options{})
|
||||
if !valid {
|
||||
t.Fatalf("MQTTPP() valid = false, record = %#v", record)
|
||||
}
|
||||
if len([]byte(record["long_name"].(string))) > 40 {
|
||||
t.Fatalf("long_name byte length = %d", len([]byte(record["long_name"].(string))))
|
||||
}
|
||||
if len([]byte(record["short_name"].(string))) > 5 {
|
||||
t.Fatalf("short_name byte length = %d", len([]byte(record["short_name"].(string))))
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildAckServiceEnvelopeRoundTrip(t *testing.T) {
|
||||
key, err := ExpandPSK("AQ==")
|
||||
if err != nil {
|
||||
t.Fatalf("ExpandPSK: %v", err)
|
||||
}
|
||||
const requestID uint32 = 0xabcd1234
|
||||
raw, err := BuildAckServiceEnvelope(AckBuildOptions{
|
||||
PacketBuildOptions: PacketBuildOptions{
|
||||
FromNodeNum: 0x10101010,
|
||||
ToNodeNum: 0x20202020,
|
||||
PacketID: 0x30303030,
|
||||
ChannelID: "LongFast",
|
||||
GatewayID: "!10101010",
|
||||
PSK: key,
|
||||
Encrypt: true,
|
||||
ViaMQTT: true,
|
||||
},
|
||||
RequestID: requestID,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildAckServiceEnvelope: %v", err)
|
||||
}
|
||||
valid, _, record := MQTTPP("msh/2/e/LongFast/!10101010", raw, key, Options{})
|
||||
if !valid {
|
||||
t.Fatalf("MQTTPP not valid: %#v", record)
|
||||
}
|
||||
if record["portnum"] != "ROUTING_APP" {
|
||||
t.Fatalf("portnum = %v", record["portnum"])
|
||||
}
|
||||
if record["type"] != "routing" {
|
||||
t.Fatalf("type = %v", record["type"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseNodeID(t *testing.T) {
|
||||
num, err := ParseNodeID("!1234abcd")
|
||||
if err != nil {
|
||||
t.Fatalf("ParseNodeID() error = %v", err)
|
||||
}
|
||||
if num != 0x1234abcd {
|
||||
t.Fatalf("num = %#x", num)
|
||||
}
|
||||
if NodeNumToID(num) != "!1234abcd" {
|
||||
t.Fatalf("NodeNumToID() = %s", NodeNumToID(num))
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,48 @@
|
||||
package mqtpp
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"google.golang.org/protobuf/encoding/protowire"
|
||||
)
|
||||
|
||||
func TestMQTTPPEncryptedPacketDefaultRejected(t *testing.T) {
|
||||
raw := encryptedServiceEnvelopeTestPayload()
|
||||
valid, payload, record := MQTTPP("msh/test", raw, nil, Options{})
|
||||
if valid {
|
||||
t.Fatalf("valid = true, want false")
|
||||
}
|
||||
if payload != nil {
|
||||
t.Fatalf("payload = %v, want nil", payload)
|
||||
}
|
||||
if record["type"] != "encrypted_packet" {
|
||||
t.Fatalf("type = %v, want encrypted_packet", record["type"])
|
||||
}
|
||||
if record["error"] != "cannot be decrypted" {
|
||||
t.Fatalf("error = %v, want cannot be decrypted", record["error"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestMQTTPPEncryptedPacketAllowed(t *testing.T) {
|
||||
raw := encryptedServiceEnvelopeTestPayload()
|
||||
valid, payload, record := MQTTPP("msh/test", raw, nil, Options{AllowEncryptedForwarding: true})
|
||||
if !valid {
|
||||
t.Fatalf("valid = false, want true: %+v", record)
|
||||
}
|
||||
if string(payload) != string(raw) {
|
||||
t.Fatalf("payload = %v, want raw payload", payload)
|
||||
}
|
||||
if record["type"] != "encrypted_packet" {
|
||||
t.Fatalf("type = %v, want encrypted_packet", record["type"])
|
||||
}
|
||||
if record["error"] != nil {
|
||||
t.Fatalf("error = %v, want nil", record["error"])
|
||||
}
|
||||
}
|
||||
|
||||
func encryptedServiceEnvelopeTestPayload() []byte {
|
||||
packet := protowire.AppendTag(nil, 5, protowire.BytesType)
|
||||
packet = protowire.AppendBytes(packet, []byte{1, 2, 3, 4})
|
||||
envelope := protowire.AppendTag(nil, 1, protowire.BytesType)
|
||||
return protowire.AppendBytes(envelope, packet)
|
||||
}
|
||||
@@ -0,0 +1,363 @@
|
||||
package mqtpp
|
||||
|
||||
import (
|
||||
"crypto/aes"
|
||||
"crypto/ecdh"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"crypto/subtle"
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"google.golang.org/protobuf/encoding/protowire"
|
||||
)
|
||||
|
||||
// PKIChannelID 是固件在 ServiceEnvelope/MQTT topic 中标识 PKI 加密包时使用的字面量
|
||||
// 见 firmware/src/mqtt/MQTT.cpp 中 `channelId = isPKIEncrypted ? "PKI" : channels.getGlobalId(chIndex);`
|
||||
const PKIChannelID = "PKI"
|
||||
|
||||
// pkcOverhead 与固件 MESHTASTIC_PKC_OVERHEAD 一致:8 字节 AES-CCM 认证标签 + 4 字节 extraNonce
|
||||
const pkcOverhead = 12
|
||||
|
||||
// PKITextMessageBuildOptions 描述构造一条 PKI 加密 DM 所需的全部上下文
|
||||
type PKITextMessageBuildOptions struct {
|
||||
FromNodeNum uint32
|
||||
ToNodeNum uint32
|
||||
PacketID uint32
|
||||
GatewayID string
|
||||
ViaMQTT bool
|
||||
SenderPrivate []byte // X25519 32 字节私钥
|
||||
RecipientPub []byte // X25519 32 字节公钥
|
||||
SenderPublic []byte // 可选;附在 MeshPacket.public_key (tag 16)
|
||||
Text string
|
||||
}
|
||||
|
||||
// BuildPKITextMessageServiceEnvelope 构造一条遵循固件实现的 PKI 私聊文本消息:
|
||||
// - data 包: portnum=TEXT_MESSAGE_APP, payload=text
|
||||
// - 共享密钥: SHA256(X25519(senderPriv, recipientPub))
|
||||
// - AES-CCM(M=8,L=2,AAD=0); nonce = packetId(8B LE) | fromNode(4B LE) | extraNonce(4B LE,覆盖 fromNode 后续 4 字节)
|
||||
// - encrypted bytes 末尾追加 8 字节 auth + 4 字节 extraNonce(LE)
|
||||
// - MeshPacket.channel = 0, pki_encrypted(tag17)=1
|
||||
// - ServiceEnvelope.channel_id 固定 "PKI"
|
||||
func BuildPKITextMessageServiceEnvelope(opts PKITextMessageBuildOptions) ([]byte, error) {
|
||||
if opts.FromNodeNum == 0 {
|
||||
return nil, fmt.Errorf("from node number is required")
|
||||
}
|
||||
if opts.ToNodeNum == 0 || opts.ToNodeNum == NodeNumBroadcast {
|
||||
return nil, fmt.Errorf("pki direct message requires a non-broadcast destination")
|
||||
}
|
||||
if opts.PacketID == 0 {
|
||||
return nil, fmt.Errorf("packet id is required")
|
||||
}
|
||||
if opts.Text == "" {
|
||||
return nil, fmt.Errorf("text is required")
|
||||
}
|
||||
if !utf8.ValidString(opts.Text) {
|
||||
return nil, fmt.Errorf("text must be valid utf-8")
|
||||
}
|
||||
if len(opts.SenderPrivate) != 32 {
|
||||
return nil, fmt.Errorf("sender private key must be 32 bytes")
|
||||
}
|
||||
if len(opts.RecipientPub) != 32 {
|
||||
return nil, fmt.Errorf("recipient public key must be 32 bytes")
|
||||
}
|
||||
if strings.TrimSpace(opts.GatewayID) == "" {
|
||||
opts.GatewayID = NodeNumToID(opts.FromNodeNum)
|
||||
}
|
||||
|
||||
plaintext := buildDataPacket(textMessageApp, []byte(opts.Text))
|
||||
|
||||
sharedKey, err := pkiSharedKey(opts.SenderPrivate, opts.RecipientPub)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var extraNonceBuf [4]byte
|
||||
if _, err := rand.Read(extraNonceBuf[:]); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
extraNonce := binary.LittleEndian.Uint32(extraNonceBuf[:])
|
||||
|
||||
ciphertext, auth, err := aesCCMEncrypt(sharedKey, pkiNonce(opts.PacketID, opts.FromNodeNum, extraNonce), plaintext)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
encrypted := make([]byte, 0, len(ciphertext)+pkcOverhead)
|
||||
encrypted = append(encrypted, ciphertext...)
|
||||
encrypted = append(encrypted, auth...)
|
||||
encrypted = append(encrypted, extraNonceBuf[:]...)
|
||||
|
||||
packet := buildPKIMeshPacket(opts.FromNodeNum, opts.ToNodeNum, opts.PacketID, opts.ViaMQTT, encrypted, opts.SenderPublic)
|
||||
return buildServiceEnvelope(packet, PKIChannelID, opts.GatewayID), nil
|
||||
}
|
||||
|
||||
// PKIAckBuildOptions 描述构造一个 PKI 加密的 Routing-NONE ACK 所需字段。
|
||||
type PKIAckBuildOptions struct {
|
||||
FromNodeNum uint32 // 我们(机器人)的节点号
|
||||
ToNodeNum uint32 // 原发送者
|
||||
PacketID uint32 // 新生成的 ACK 自身的 packet id
|
||||
RequestID uint32 // 被 ACK 的原始包 packet id
|
||||
GatewayID string
|
||||
ViaMQTT bool
|
||||
SenderPrivate []byte
|
||||
RecipientPub []byte
|
||||
SenderPublic []byte
|
||||
}
|
||||
|
||||
// BuildPKIAckServiceEnvelope 构造一条 PKI 加密的 Routing-NONE ACK,与固件
|
||||
// MeshModule::allocAckNak + Router::perhapsEncode (PKI 分支) 行为对齐。
|
||||
func BuildPKIAckServiceEnvelope(opts PKIAckBuildOptions) ([]byte, error) {
|
||||
if opts.FromNodeNum == 0 {
|
||||
return nil, fmt.Errorf("from node number is required")
|
||||
}
|
||||
if opts.ToNodeNum == 0 || opts.ToNodeNum == NodeNumBroadcast {
|
||||
return nil, fmt.Errorf("pki ack requires a non-broadcast destination")
|
||||
}
|
||||
if opts.PacketID == 0 {
|
||||
return nil, fmt.Errorf("packet id is required")
|
||||
}
|
||||
if opts.RequestID == 0 {
|
||||
return nil, fmt.Errorf("request id is required")
|
||||
}
|
||||
if len(opts.SenderPrivate) != 32 || len(opts.RecipientPub) != 32 {
|
||||
return nil, fmt.Errorf("pki keys must be 32 bytes each")
|
||||
}
|
||||
if strings.TrimSpace(opts.GatewayID) == "" {
|
||||
opts.GatewayID = NodeNumToID(opts.FromNodeNum)
|
||||
}
|
||||
|
||||
plaintext := buildAckDataPacket(opts.RequestID)
|
||||
|
||||
sharedKey, err := pkiSharedKey(opts.SenderPrivate, opts.RecipientPub)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var extraNonceBuf [4]byte
|
||||
if _, err := rand.Read(extraNonceBuf[:]); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
extraNonce := binary.LittleEndian.Uint32(extraNonceBuf[:])
|
||||
ciphertext, auth, err := aesCCMEncrypt(sharedKey, pkiNonce(opts.PacketID, opts.FromNodeNum, extraNonce), plaintext)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
encrypted := make([]byte, 0, len(ciphertext)+pkcOverhead)
|
||||
encrypted = append(encrypted, ciphertext...)
|
||||
encrypted = append(encrypted, auth...)
|
||||
encrypted = append(encrypted, extraNonceBuf[:]...)
|
||||
|
||||
packet := buildPKIMeshPacket(opts.FromNodeNum, opts.ToNodeNum, opts.PacketID, opts.ViaMQTT, encrypted, opts.SenderPublic)
|
||||
return buildServiceEnvelope(packet, PKIChannelID, opts.GatewayID), nil
|
||||
}
|
||||
|
||||
|
||||
func pkiSharedKey(privateKey, publicKey []byte) ([]byte, error) {
|
||||
curve := ecdh.X25519()
|
||||
priv, err := curve.NewPrivateKey(privateKey)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid sender private key: %w", err)
|
||||
}
|
||||
pub, err := curve.NewPublicKey(publicKey)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid recipient public key: %w", err)
|
||||
}
|
||||
shared, err := priv.ECDH(pub)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("x25519 ecdh failed: %w", err)
|
||||
}
|
||||
digest := sha256.Sum256(shared)
|
||||
return digest[:], nil
|
||||
}
|
||||
|
||||
// pkiNonce 完整复刻固件 CryptoEngine::initNonce(fromNode, packetId, extraNonce) 的字节布局。
|
||||
// 固件实现(mesh/CryptoEngine.cpp):
|
||||
//
|
||||
// memcpy(nonce + 0, &packetId, 8); // packetId 是 uint64,写入 nonce[0..8)
|
||||
// memcpy(nonce + 8, &fromNode, 4); // fromNode 写入 nonce[8..12)
|
||||
// if (extraNonce)
|
||||
// memcpy(nonce + 4, &extraNonce, 4); // extraNonce 覆盖 nonce[4..8)
|
||||
//
|
||||
// 因此 13 字节 nonce 布局为:packetId_lo(4B LE) | extraNonce_or_packetId_hi(4B LE) | fromNode(4B LE) | 0x00
|
||||
func pkiNonce(packetID, fromNode, extraNonce uint32) []byte {
|
||||
nonce := make([]byte, 16)
|
||||
binary.LittleEndian.PutUint64(nonce[0:8], uint64(packetID)) // packetId 是 uint64,高 32 位为 0
|
||||
binary.LittleEndian.PutUint32(nonce[8:12], fromNode)
|
||||
if extraNonce != 0 {
|
||||
binary.LittleEndian.PutUint32(nonce[4:8], extraNonce)
|
||||
}
|
||||
// CCM L=2 → nonce 占 15-L=13 字节
|
||||
return nonce[:13]
|
||||
}
|
||||
|
||||
// aesCCMEncrypt 使用与固件相同的参数(AES-CCM, M=8 即 8 字节 tag, L=2, 无 AAD)。
|
||||
func aesCCMEncrypt(key, nonce, plaintext []byte) (ciphertext []byte, auth []byte, err error) {
|
||||
if len(nonce) != 13 {
|
||||
return nil, nil, fmt.Errorf("ccm nonce must be 13 bytes")
|
||||
}
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
const tagLen = 8
|
||||
if len(plaintext) > 0xffff {
|
||||
return nil, nil, fmt.Errorf("plaintext too large for L=2 ccm")
|
||||
}
|
||||
|
||||
// CBC-MAC 鉴权
|
||||
var x [aes.BlockSize]byte
|
||||
var b [aes.BlockSize]byte
|
||||
b[0] = byte((tagLen-2)/2) << 3 // M', AAD=0 时 Adata=0
|
||||
b[0] |= byte(2 - 1) // L'=L-1
|
||||
copy(b[1:], nonce[:13])
|
||||
binary.BigEndian.PutUint16(b[14:], uint16(len(plaintext)))
|
||||
block.Encrypt(x[:], b[:])
|
||||
|
||||
// 鉴权明文
|
||||
for offset := 0; offset < len(plaintext); offset += aes.BlockSize {
|
||||
end := offset + aes.BlockSize
|
||||
if end > len(plaintext) {
|
||||
end = len(plaintext)
|
||||
}
|
||||
var blk [aes.BlockSize]byte
|
||||
copy(blk[:], plaintext[offset:end])
|
||||
for i := range x {
|
||||
x[i] ^= blk[i]
|
||||
}
|
||||
block.Encrypt(x[:], x[:])
|
||||
}
|
||||
|
||||
// CTR 流:A_i = L' | nonce | counter_be16
|
||||
var a [aes.BlockSize]byte
|
||||
a[0] = byte(2 - 1)
|
||||
copy(a[1:], nonce[:13])
|
||||
encryptCounter := func(i uint16) [aes.BlockSize]byte {
|
||||
var ai [aes.BlockSize]byte
|
||||
copy(ai[:], a[:])
|
||||
binary.BigEndian.PutUint16(ai[14:], i)
|
||||
var s [aes.BlockSize]byte
|
||||
block.Encrypt(s[:], ai[:])
|
||||
return s
|
||||
}
|
||||
|
||||
ciphertext = make([]byte, len(plaintext))
|
||||
for i, offset := 1, 0; offset < len(plaintext); i, offset = i+1, offset+aes.BlockSize {
|
||||
s := encryptCounter(uint16(i))
|
||||
end := offset + aes.BlockSize
|
||||
if end > len(plaintext) {
|
||||
end = len(plaintext)
|
||||
}
|
||||
for j := offset; j < end; j++ {
|
||||
ciphertext[j] = plaintext[j] ^ s[j-offset]
|
||||
}
|
||||
}
|
||||
|
||||
// auth = T XOR S_0
|
||||
s0 := encryptCounter(0)
|
||||
auth = make([]byte, tagLen)
|
||||
for i := 0; i < tagLen; i++ {
|
||||
auth[i] = x[i] ^ s0[i]
|
||||
}
|
||||
return ciphertext, auth, nil
|
||||
}
|
||||
|
||||
// aesCCMDecrypt 与 encrypt 对称,验证标签后返回明文。仅用于测试与可能的回程解密。
|
||||
func aesCCMDecrypt(key, nonce, ciphertext, auth []byte) ([]byte, error) {
|
||||
if len(nonce) != 13 {
|
||||
return nil, fmt.Errorf("ccm nonce must be 13 bytes")
|
||||
}
|
||||
if len(auth) != 8 {
|
||||
return nil, fmt.Errorf("ccm auth tag must be 8 bytes")
|
||||
}
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 先 CTR 解密
|
||||
var a [aes.BlockSize]byte
|
||||
a[0] = byte(2 - 1)
|
||||
copy(a[1:], nonce[:13])
|
||||
encryptCounter := func(i uint16) [aes.BlockSize]byte {
|
||||
var ai [aes.BlockSize]byte
|
||||
copy(ai[:], a[:])
|
||||
binary.BigEndian.PutUint16(ai[14:], i)
|
||||
var s [aes.BlockSize]byte
|
||||
block.Encrypt(s[:], ai[:])
|
||||
return s
|
||||
}
|
||||
plain := make([]byte, len(ciphertext))
|
||||
for i, offset := 1, 0; offset < len(ciphertext); i, offset = i+1, offset+aes.BlockSize {
|
||||
s := encryptCounter(uint16(i))
|
||||
end := offset + aes.BlockSize
|
||||
if end > len(ciphertext) {
|
||||
end = len(ciphertext)
|
||||
}
|
||||
for j := offset; j < end; j++ {
|
||||
plain[j] = ciphertext[j] ^ s[j-offset]
|
||||
}
|
||||
}
|
||||
|
||||
// 再 CBC-MAC 校验
|
||||
var x [aes.BlockSize]byte
|
||||
var b [aes.BlockSize]byte
|
||||
b[0] = byte((8-2)/2) << 3
|
||||
b[0] |= byte(2 - 1)
|
||||
copy(b[1:], nonce[:13])
|
||||
binary.BigEndian.PutUint16(b[14:], uint16(len(plain)))
|
||||
block.Encrypt(x[:], b[:])
|
||||
for offset := 0; offset < len(plain); offset += aes.BlockSize {
|
||||
end := offset + aes.BlockSize
|
||||
if end > len(plain) {
|
||||
end = len(plain)
|
||||
}
|
||||
var blk [aes.BlockSize]byte
|
||||
copy(blk[:], plain[offset:end])
|
||||
for i := range x {
|
||||
x[i] ^= blk[i]
|
||||
}
|
||||
block.Encrypt(x[:], x[:])
|
||||
}
|
||||
s0 := encryptCounter(0)
|
||||
expected := make([]byte, 8)
|
||||
for i := 0; i < 8; i++ {
|
||||
expected[i] = x[i] ^ s0[i]
|
||||
}
|
||||
if subtle.ConstantTimeCompare(expected, auth) != 1 {
|
||||
return nil, fmt.Errorf("aes-ccm auth mismatch")
|
||||
}
|
||||
return plain, nil
|
||||
}
|
||||
|
||||
// buildPKIMeshPacket 构造一个 PKI 加密的 MeshPacket:
|
||||
// - tag 1/2: from/to (fixed32)
|
||||
// - tag 3 channel = 0 (省略,默认即为 0)
|
||||
// - tag 5 encrypted (含 ciphertext|auth|extraNonce)
|
||||
// - tag 6 packet_id
|
||||
// - tag 14 via_mqtt
|
||||
// - tag 16 public_key(可选,附带发送者公钥)
|
||||
// - tag 17 pki_encrypted = 1
|
||||
func buildPKIMeshPacket(from, to, packetID uint32, viaMQTT bool, encrypted []byte, senderPublic []byte) []byte {
|
||||
var out []byte
|
||||
out = protowire.AppendTag(out, 1, protowire.Fixed32Type)
|
||||
out = protowire.AppendFixed32(out, from)
|
||||
out = protowire.AppendTag(out, 2, protowire.Fixed32Type)
|
||||
out = protowire.AppendFixed32(out, to)
|
||||
out = protowire.AppendTag(out, 5, protowire.BytesType)
|
||||
out = protowire.AppendBytes(out, encrypted)
|
||||
out = protowire.AppendTag(out, 6, protowire.Fixed32Type)
|
||||
out = protowire.AppendFixed32(out, packetID)
|
||||
if viaMQTT {
|
||||
out = protowire.AppendTag(out, 14, protowire.VarintType)
|
||||
out = protowire.AppendVarint(out, 1)
|
||||
}
|
||||
if len(senderPublic) == 32 {
|
||||
out = protowire.AppendTag(out, 16, protowire.BytesType)
|
||||
out = protowire.AppendBytes(out, senderPublic)
|
||||
}
|
||||
out = protowire.AppendTag(out, 17, protowire.VarintType)
|
||||
out = protowire.AppendVarint(out, 1)
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,273 @@
|
||||
package mqtpp
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/ecdh"
|
||||
"crypto/rand"
|
||||
"encoding/binary"
|
||||
"testing"
|
||||
|
||||
"google.golang.org/protobuf/encoding/protowire"
|
||||
)
|
||||
|
||||
func TestBuildPKITextMessageRoundTrip(t *testing.T) {
|
||||
curve := ecdh.X25519()
|
||||
senderPriv, err := curve.GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatalf("generate sender key: %v", err)
|
||||
}
|
||||
recipientPriv, err := curve.GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatalf("generate recipient key: %v", err)
|
||||
}
|
||||
|
||||
const text = "hello over PKI 你好"
|
||||
const fromNum uint32 = 0x12345678
|
||||
const toNum uint32 = 0xa1b2c3d4
|
||||
const packetID uint32 = 0xdeadbeef
|
||||
|
||||
raw, err := BuildPKITextMessageServiceEnvelope(PKITextMessageBuildOptions{
|
||||
FromNodeNum: fromNum,
|
||||
ToNodeNum: toNum,
|
||||
PacketID: packetID,
|
||||
GatewayID: NodeNumToID(fromNum),
|
||||
ViaMQTT: true,
|
||||
SenderPrivate: senderPriv.Bytes(),
|
||||
RecipientPub: recipientPriv.PublicKey().Bytes(),
|
||||
SenderPublic: senderPriv.PublicKey().Bytes(),
|
||||
Text: text,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildPKITextMessageServiceEnvelope: %v", err)
|
||||
}
|
||||
|
||||
env, err := parseServiceEnvelope(raw)
|
||||
if err != nil {
|
||||
t.Fatalf("parseServiceEnvelope: %v", err)
|
||||
}
|
||||
if env.ChannelID != PKIChannelID {
|
||||
t.Fatalf("channel_id = %q want %q", env.ChannelID, PKIChannelID)
|
||||
}
|
||||
if env.GatewayID != NodeNumToID(fromNum) {
|
||||
t.Fatalf("gateway_id = %q", env.GatewayID)
|
||||
}
|
||||
pkt := env.Packet
|
||||
if pkt.From != fromNum || pkt.To != toNum || pkt.ID != packetID {
|
||||
t.Fatalf("packet header mismatch: %+v", pkt)
|
||||
}
|
||||
if !pkt.PKIEncrypted {
|
||||
t.Fatalf("pki_encrypted = false")
|
||||
}
|
||||
if !pkt.ViaMQTT {
|
||||
t.Fatalf("via_mqtt = false")
|
||||
}
|
||||
if pkt.Channel != 0 {
|
||||
t.Fatalf("channel = %d want 0", pkt.Channel)
|
||||
}
|
||||
if pkt.PayloadVariant != "encrypted" || len(pkt.Encrypted) <= pkcOverhead {
|
||||
t.Fatalf("encrypted payload missing: %+v", pkt)
|
||||
}
|
||||
|
||||
// 收件人用对端私钥 + 发件人公钥推导共享密钥并解密
|
||||
sharedKey, err := pkiSharedKey(recipientPriv.Bytes(), senderPriv.PublicKey().Bytes())
|
||||
if err != nil {
|
||||
t.Fatalf("pkiSharedKey: %v", err)
|
||||
}
|
||||
encryptedLen := len(pkt.Encrypted) - pkcOverhead
|
||||
ciphertext := pkt.Encrypted[:encryptedLen]
|
||||
auth := pkt.Encrypted[encryptedLen : encryptedLen+8]
|
||||
extraNonce := binary.LittleEndian.Uint32(pkt.Encrypted[encryptedLen+8:])
|
||||
plaintext, err := aesCCMDecrypt(sharedKey, pkiNonce(packetID, fromNum, extraNonce), ciphertext, auth)
|
||||
if err != nil {
|
||||
t.Fatalf("aesCCMDecrypt: %v", err)
|
||||
}
|
||||
data, err := parseDataPacket(plaintext)
|
||||
if err != nil {
|
||||
t.Fatalf("parseDataPacket: %v", err)
|
||||
}
|
||||
if data.Portnum != textMessageApp {
|
||||
t.Fatalf("portnum = %d", data.Portnum)
|
||||
}
|
||||
if string(data.Payload) != text {
|
||||
t.Fatalf("text = %q want %q", string(data.Payload), text)
|
||||
}
|
||||
|
||||
// 同样用 MQTTPP 解析路径:PKI 包对外应被识别为 encrypted_packet(无法解密),
|
||||
// 但用错的 PSK 不应误报“channel hash mismatch” 之外的奇怪错误。
|
||||
dummyPSK, _ := ExpandPSK("AQ==")
|
||||
_, _, record := MQTTPP("msh/2/e/PKI/!12345678", raw, dummyPSK, Options{AllowEncryptedForwarding: true})
|
||||
if record["channel_id"] != PKIChannelID {
|
||||
t.Fatalf("MQTTPP record channel_id = %v", record["channel_id"])
|
||||
}
|
||||
if record["pki_encrypted"] != true {
|
||||
t.Fatalf("pki_encrypted record = %v", record["pki_encrypted"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestPKINonceLayoutMatchesFirmware(t *testing.T) {
|
||||
// 复刻 firmware initNonce(fromNode, packetId, extraNonce) 期望的字节布局:
|
||||
// nonce[0..8) = packetId(uint64 LE)
|
||||
// nonce[4..8) 被 extraNonce(uint32 LE) 覆盖(当 extraNonce != 0)
|
||||
// nonce[8..12) = fromNode(uint32 LE)
|
||||
// nonce[12] = 0
|
||||
got := pkiNonce(0xaabbccdd, 0x11223344, 0x55667788)
|
||||
want := []byte{
|
||||
0xdd, 0xcc, 0xbb, 0xaa, // packetId low 4 bytes,未被 extraNonce 覆盖前
|
||||
0x88, 0x77, 0x66, 0x55, // extraNonce 覆盖 nonce[4..8)
|
||||
0x44, 0x33, 0x22, 0x11, // fromNode
|
||||
0x00,
|
||||
}
|
||||
if !bytes.Equal(got, want) {
|
||||
t.Fatalf("pkiNonce = % x\nwant % x", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildPKITextMessageRejectsBroadcast(t *testing.T) {
|
||||
curve := ecdh.X25519()
|
||||
priv, _ := curve.GenerateKey(rand.Reader)
|
||||
pub, _ := curve.GenerateKey(rand.Reader)
|
||||
if _, err := BuildPKITextMessageServiceEnvelope(PKITextMessageBuildOptions{
|
||||
FromNodeNum: 0x1,
|
||||
ToNodeNum: NodeNumBroadcast,
|
||||
PacketID: 0x2,
|
||||
SenderPrivate: priv.Bytes(),
|
||||
RecipientPub: pub.PublicKey().Bytes(),
|
||||
Text: "hi",
|
||||
}); err == nil {
|
||||
t.Fatalf("expected error for broadcast destination")
|
||||
}
|
||||
}
|
||||
|
||||
// 确认 MeshPacket 中确实带上 pki_encrypted (tag 17) 与 public_key (tag 16)
|
||||
func TestBuildPKIMeshPacketTags(t *testing.T) {
|
||||
encrypted := []byte{0x01, 0x02, 0x03}
|
||||
pub := make([]byte, 32)
|
||||
for i := range pub {
|
||||
pub[i] = byte(i)
|
||||
}
|
||||
raw := buildPKIMeshPacket(0x11, 0x22, 0x33, true, encrypted, pub)
|
||||
tags := map[protowire.Number]bool{}
|
||||
if err := walkFields(raw, func(num protowire.Number, _ protowire.Type, _ any) error {
|
||||
tags[num] = true
|
||||
return nil
|
||||
}); err != nil {
|
||||
t.Fatalf("walkFields: %v", err)
|
||||
}
|
||||
for _, want := range []protowire.Number{1, 2, 5, 6, 14, 16, 17} {
|
||||
if !tags[want] {
|
||||
t.Fatalf("missing tag %d", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 端到端:发送方构造 PKI 包,接收方通过 PKIKeyResolver 解密并还原文本消息记录。
|
||||
func TestMQTTPPDecryptsPKIWithResolver(t *testing.T) {
|
||||
curve := ecdh.X25519()
|
||||
senderPriv, _ := curve.GenerateKey(rand.Reader)
|
||||
recipientPriv, _ := curve.GenerateKey(rand.Reader)
|
||||
|
||||
const text = "hello PKI inbound"
|
||||
const fromNum uint32 = 0xaaaa1111
|
||||
const toNum uint32 = 0xbbbb2222
|
||||
const packetID uint32 = 0x77777777
|
||||
|
||||
raw, err := BuildPKITextMessageServiceEnvelope(PKITextMessageBuildOptions{
|
||||
FromNodeNum: fromNum,
|
||||
ToNodeNum: toNum,
|
||||
PacketID: packetID,
|
||||
GatewayID: NodeNumToID(fromNum),
|
||||
ViaMQTT: true,
|
||||
SenderPrivate: senderPriv.Bytes(),
|
||||
RecipientPub: recipientPriv.PublicKey().Bytes(),
|
||||
SenderPublic: senderPriv.PublicKey().Bytes(),
|
||||
Text: text,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("build: %v", err)
|
||||
}
|
||||
|
||||
resolver := func(to, from uint32) ([]byte, []byte, bool) {
|
||||
if to != toNum || from != fromNum {
|
||||
return nil, nil, false
|
||||
}
|
||||
return recipientPriv.Bytes(), senderPriv.PublicKey().Bytes(), true
|
||||
}
|
||||
dummyPSK, _ := ExpandPSK("AQ==")
|
||||
valid, _, record := MQTTPP("msh/2/e/PKI/!aaaa1111", raw, dummyPSK, Options{PKIKeyResolver: resolver})
|
||||
if !valid {
|
||||
t.Fatalf("MQTTPP not valid: %#v", record)
|
||||
}
|
||||
if record["type"] != "text_message" {
|
||||
t.Fatalf("type = %v, want text_message", record["type"])
|
||||
}
|
||||
if record["text"] != text {
|
||||
t.Fatalf("text = %v", record["text"])
|
||||
}
|
||||
if record["pki_encrypted"] != true {
|
||||
t.Fatalf("pki_encrypted = %v", record["pki_encrypted"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildPKIAckRoundTrip(t *testing.T) {
|
||||
curve := ecdh.X25519()
|
||||
botPriv, _ := curve.GenerateKey(rand.Reader)
|
||||
devicePriv, _ := curve.GenerateKey(rand.Reader)
|
||||
|
||||
const fromNum uint32 = 0x0000beef // bot
|
||||
const toNum uint32 = 0xfeed0000 // 原 device
|
||||
const ackPacketID uint32 = 0xaaaa5555
|
||||
const requestID uint32 = 0xdeadbeef
|
||||
|
||||
raw, err := BuildPKIAckServiceEnvelope(PKIAckBuildOptions{
|
||||
FromNodeNum: fromNum,
|
||||
ToNodeNum: toNum,
|
||||
PacketID: ackPacketID,
|
||||
RequestID: requestID,
|
||||
GatewayID: NodeNumToID(fromNum),
|
||||
ViaMQTT: true,
|
||||
SenderPrivate: botPriv.Bytes(),
|
||||
RecipientPub: devicePriv.PublicKey().Bytes(),
|
||||
SenderPublic: botPriv.PublicKey().Bytes(),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildPKIAckServiceEnvelope: %v", err)
|
||||
}
|
||||
|
||||
// 设备侧解密
|
||||
env, err := parseServiceEnvelope(raw)
|
||||
if err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
if env.ChannelID != PKIChannelID {
|
||||
t.Fatalf("channel_id = %q", env.ChannelID)
|
||||
}
|
||||
pkt := env.Packet
|
||||
if !pkt.PKIEncrypted || pkt.From != fromNum || pkt.To != toNum || pkt.ID != ackPacketID {
|
||||
t.Fatalf("ack header mismatch: %+v", pkt)
|
||||
}
|
||||
encryptedLen := len(pkt.Encrypted) - pkcOverhead
|
||||
cipher := pkt.Encrypted[:encryptedLen]
|
||||
auth := pkt.Encrypted[encryptedLen : encryptedLen+8]
|
||||
extraNonce := binary.LittleEndian.Uint32(pkt.Encrypted[encryptedLen+8:])
|
||||
sharedKey, err := pkiSharedKey(devicePriv.Bytes(), botPriv.PublicKey().Bytes())
|
||||
if err != nil {
|
||||
t.Fatalf("shared: %v", err)
|
||||
}
|
||||
plain, err := aesCCMDecrypt(sharedKey, pkiNonce(ackPacketID, fromNum, extraNonce), cipher, auth)
|
||||
if err != nil {
|
||||
t.Fatalf("decrypt: %v", err)
|
||||
}
|
||||
data, err := parseDataPacket(plain)
|
||||
if err != nil {
|
||||
t.Fatalf("data: %v", err)
|
||||
}
|
||||
if data.Portnum != routingApp {
|
||||
t.Fatalf("portnum = %d, want ROUTING_APP(%d)", data.Portnum, routingApp)
|
||||
}
|
||||
|
||||
// Routing payload 解析: 期望 oneof error_reason=NONE(0),即 wire 字节 0x18 0x00
|
||||
wantRouting := []byte{0x18, 0x00}
|
||||
if !bytes.Equal(data.Payload, wantRouting) {
|
||||
t.Fatalf("routing payload = % x, want % x", data.Payload, wantRouting)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user