From 757eb852fd9c38dfe15d650068fb1697943afb65 Mon Sep 17 00:00:00 2001 From: kevin Date: Sun, 14 Jun 2026 19:41:52 +0800 Subject: [PATCH] =?UTF-8?q?=E6=9B=B4=E6=96=B0=E6=9C=BA=E5=99=A8=E4=BA=BA?= =?UTF-8?q?=E6=8E=A5=E6=94=B6=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- bot_pki_resolver.go | 77 +++++++++++++++++++++++++++++++++++++++++++++ main.go | 30 ++++++++++++------ mqtpp/mqtpp.go | 69 ++++++++++++++++++++++++++++++++++++---- mqtpp/pki_test.go | 48 ++++++++++++++++++++++++++++ 4 files changed, 209 insertions(+), 15 deletions(-) create mode 100644 bot_pki_resolver.go diff --git a/bot_pki_resolver.go b/bot_pki_resolver.go new file mode 100644 index 0000000..a79fb9c --- /dev/null +++ b/bot_pki_resolver.go @@ -0,0 +1,77 @@ +package main + +import ( + "encoding/base64" + "encoding/hex" + "errors" + "strings" + + "gorm.io/gorm" +) + +// pkiKeyResolver 是 mqtpp 在解密 PKI 加密包时回调的接收者私钥/发送者公钥查询函数。 +// +// to 是接收者节点号(应该匹配某个本地受管的 bot),from 是发送者节点号(应该已经有 nodeinfo 上报)。 +// 返回的 ok=false 时调用方会跳过 PKI 路径并回落到 channel PSK 解密。 +func newPKIKeyResolver(s *store) func(toNodeNum, fromNodeNum uint32) ([]byte, []byte, bool) { + if s == nil { + return nil + } + return func(toNodeNum, fromNodeNum uint32) ([]byte, []byte, bool) { + bot, err := s.GetBotNodeByNodeNum(int64(toNodeNum)) + if err != nil { + return nil, nil, false + } + privateKeyB64 := strings.TrimSpace(bot.PrivateKey) + if privateKeyB64 == "" { + return nil, nil, false + } + privateKey, err := base64.StdEncoding.DecodeString(privateKeyB64) + if err != nil || len(privateKey) != 32 { + return nil, nil, false + } + fromPublic, ok := lookupNodeInfoPublicKey(s, fromNodeNum) + if !ok { + return nil, nil, false + } + return privateKey, fromPublic, true + } +} + +// lookupNodeInfoPublicKey 在 nodeinfo 表中按 node_num 查 X25519 公钥, +// 兼容 hex 与 base64 两种历史存储格式。 +func lookupNodeInfoPublicKey(s *store, nodeNum uint32) ([]byte, bool) { + var row nodeInfoRecord + if err := s.db.Where("node_num = ?", int64(nodeNum)).Take(&row).Error; err != nil { + return nil, false + } + if row.PublicKey == nil { + return nil, false + } + value := strings.TrimSpace(*row.PublicKey) + if value == "" { + return nil, false + } + if decoded, err := hex.DecodeString(value); err == nil && len(decoded) == 32 { + return decoded, true + } + if decoded, err := base64.StdEncoding.DecodeString(value); err == nil && len(decoded) == 32 { + return decoded, true + } + return nil, false +} + +// GetBotNodeByNodeNum 按节点号查找受管 bot 节点;用于 PKI 解密时把 to 字段映射回本地私钥。 +func (s *store) GetBotNodeByNodeNum(nodeNum int64) (*botNodeRecord, error) { + if s == nil || s.db == nil { + return nil, errors.New("store not configured") + } + var row botNodeRecord + if err := s.db.Where("node_num = ?", nodeNum).Take(&row).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, err + } + return nil, err + } + return &row, nil +} diff --git a/main.go b/main.go index 7ec5a40..805d810 100644 --- a/main.go +++ b/main.go @@ -35,11 +35,12 @@ const ( type meshtasticFilterHook struct { mqtt.HookBase - key []byte - dbQueue *dbWriteQueue - stats *meshtasticMessageStats - blocking *blockingCache - settings *runtimeSettingsCache + key []byte + dbQueue *dbWriteQueue + stats *meshtasticMessageStats + blocking *blockingCache + settings *runtimeSettingsCache + pkiResolver func(toNodeNum, fromNodeNum uint32) ([]byte, []byte, bool) } // ID 返回用于识别 Meshtastic payload 过滤器的 hook 名称。 @@ -64,7 +65,10 @@ func (h *meshtasticFilterHook) OnConnect(cl *mqtt.Client, pk packets.Packet) err // OnPublish 在 broker 转发消息前校验 payload;无效消息会被拒绝并丢弃。 func (h *meshtasticFilterHook) OnPublish(cl *mqtt.Client, pk packets.Packet) (packets.Packet, error) { - valid, _, record := mqtpp.MQTTPP(pk.TopicName, pk.Payload, h.key, mqtpp.Options{AllowEncryptedForwarding: h.settings.AllowEncryptedForwarding()}) + valid, _, record := mqtpp.MQTTPP(pk.TopicName, pk.Payload, h.key, mqtpp.Options{ + AllowEncryptedForwarding: h.settings.AllowEncryptedForwarding(), + PKIKeyResolver: h.pkiResolver, + }) if !valid { h.rejectPublish(cl, pk, record) return pk, packets.ErrRejectPacket @@ -221,7 +225,7 @@ func run(cfg *config) error { } messageStats := &meshtasticMessageStats{} - server, mqttAddr, err := startMQTTServer(cfg, dbQueue, messageStats, blocking, settings) + server, mqttAddr, err := startMQTTServer(cfg, store, dbQueue, messageStats, blocking, settings) if err != nil { return err } @@ -285,12 +289,20 @@ func run(cfg *config) error { return runErr } -func startMQTTServer(cfg *config, dbQueue *dbWriteQueue, stats *meshtasticMessageStats, blocking *blockingCache, settings *runtimeSettingsCache) (*mqtt.Server, string, error) { +func startMQTTServer(cfg *config, store *store, dbQueue *dbWriteQueue, stats *meshtasticMessageStats, blocking *blockingCache, settings *runtimeSettingsCache) (*mqtt.Server, string, error) { server := mqtt.New(&mqtt.Options{InlineClient: true}) if err := server.AddHook(new(auth.AllowHook), nil); err != nil { return nil, "", err } - if err := server.AddHook(&meshtasticFilterHook{key: cfg.key, dbQueue: dbQueue, stats: stats, blocking: blocking, settings: settings}, nil); err != nil { + hook := &meshtasticFilterHook{ + key: cfg.key, + dbQueue: dbQueue, + stats: stats, + blocking: blocking, + settings: settings, + pkiResolver: newPKIKeyResolver(store), + } + if err := server.AddHook(hook, nil); err != nil { return nil, "", err } diff --git a/mqtpp/mqtpp.go b/mqtpp/mqtpp.go index 6256ab2..3ece21d 100644 --- a/mqtpp/mqtpp.go +++ b/mqtpp/mqtpp.go @@ -34,6 +34,10 @@ var defaultMeshtasticPSK = []byte{ type Options struct { AllowEncryptedForwarding bool + // PKIKeyResolver 在解密 PKI 加密包时被调用:toNodeNum 是包的接收者节点号(应为本地受管节点, + // 例如机器人),fromNodeNum 是发送方节点号。回调需要返回接收方的 X25519 私钥(32B)和发送方 + // 的 X25519 公钥(32B)。当回调缺失或返回 ok=false 时,PKI 解密会被跳过(仍尝试 channel PSK)。 + PKIKeyResolver func(toNodeNum, fromNodeNum uint32) (privateKey, fromPublicKey []byte, ok bool) } type serviceEnvelope struct { @@ -126,7 +130,7 @@ func MQTTPP(topic string, raw []byte, key []byte, opts Options) (bool, []byte, m //解包失败 return false, nil, map[string]any{"topic": topic, "error": "protobuf decode failed: " + err.Error(), "payload_len": len(raw)} } - record, err := describePacket(topic, env, key) + record, err := describePacket(topic, env, key, opts) if err != nil { //解码失败 return false, nil, map[string]any{"topic": topic, "error": err.Error(), "payload_len": len(raw)} @@ -664,7 +668,7 @@ func varintValue(typ protowire.Type, value any) uint64 { } // describePacket 根据 ServiceEnvelope 和 PSK 生成统一的 JSON 记录字段。 -func describePacket(topic string, env *serviceEnvelope, key []byte) (map[string]any, error) { +func describePacket(topic string, env *serviceEnvelope, key []byte, opts Options) (map[string]any, error) { packet := env.Packet if packet == nil { packet = &meshPacket{} @@ -685,7 +689,7 @@ func describePacket(topic string, env *serviceEnvelope, key []byte) (map[string] } if packet.PayloadVariant == "encrypted" { - decryptedPacket, decryptStatus := tryDecryptPacket(packet, env.ChannelID, key) + decryptedPacket, decryptStatus := tryDecryptPacket(packet, env.ChannelID, key, opts) if decryptedPacket == nil { return merge(base, map[string]any{ "type": "encrypted_packet", @@ -697,13 +701,15 @@ func describePacket(topic string, env *serviceEnvelope, key []byte) (map[string] decryptedEnv := *env decryptedEnv.Packet = decryptedPacket - decrypted, err := describePacket(topic, &decryptedEnv, key) + decrypted, err := describePacket(topic, &decryptedEnv, key, opts) if err != nil { return nil, err } decrypted["payload_variant"] = "decoded" decrypted["decrypt_success"] = true decrypted["decrypt_status"] = decryptStatus + // PKI 解密的包要保留 pki_encrypted 标记(tryDecryptPacket 在成功后会把它标记到 packet 上) + decrypted["pki_encrypted"] = decryptedPacket.PKIEncrypted return decrypted, nil } @@ -752,8 +758,22 @@ func describePacket(topic string, env *serviceEnvelope, key []byte) (map[string] } } -// tryDecryptPacket 尝试用 channel PSK 解密 encrypted MeshPacket,并返回解密状态。 -func tryDecryptPacket(packet *meshPacket, channelID string, key []byte) (*meshPacket, string) { +// tryDecryptPacket 尝试解密 encrypted MeshPacket,并返回解密状态。 +// 解密优先级(与固件 perhapsDecode 对齐): +// 1. 若包是 PKI 风格(channel=0、to 非广播、PSK 无关)且调用方提供了 PKIKeyResolver, +// 则用 X25519 + AES-CCM(M=8,L=2) 解密。 +// 2. 否则回落到 channel PSK + AES-CTR 路径。 +func tryDecryptPacket(packet *meshPacket, channelID string, key []byte, opts Options) (*meshPacket, string) { + // 先尝试 PKI 路径:固件发出的 PKI 包 channel=0、to 非广播、长度 > pkcOverhead。 + // channel_id 字面量在 ServiceEnvelope 上一般是 "PKI",但有些转发路径会保留原 channel 名, + // 因此这里以 channel 字段=0 + 注册了 resolver 为充分条件即尝试解密。 + if opts.PKIKeyResolver != nil && packet.Channel == 0 && packet.To != 0 && packet.To != NodeNumBroadcast && + len(packet.Encrypted) > pkcOverhead { + if decrypted, status, ok := tryDecryptPKIPacket(packet, opts.PKIKeyResolver); ok { + return decrypted, status + } + } + if len(key) == 0 { return nil, "psk disables encryption" } @@ -780,6 +800,43 @@ func tryDecryptPacket(packet *meshPacket, channelID string, key []byte) (*meshPa return &decrypted, "success" } +// tryDecryptPKIPacket 用接收方私钥 + 发送方公钥派生共享密钥并 AES-CCM 解密。 +// 第三个返回值表示是否“尝试且解出了合法 Data 包”——返回 false 时调用方会回落到 PSK 路径。 +func tryDecryptPKIPacket(packet *meshPacket, resolver func(toNodeNum, fromNodeNum uint32) ([]byte, []byte, bool)) (*meshPacket, string, bool) { + privateKey, fromPublic, ok := resolver(packet.To, packet.From) + if !ok { + return nil, "", false + } + if len(privateKey) != 32 || len(fromPublic) != 32 { + return nil, "", false + } + encryptedLen := len(packet.Encrypted) - pkcOverhead + ciphertext := packet.Encrypted[:encryptedLen] + auth := packet.Encrypted[encryptedLen : encryptedLen+8] + extraNonce := binary.LittleEndian.Uint32(packet.Encrypted[encryptedLen+8:]) + sharedKey, err := pkiSharedKey(privateKey, fromPublic) + if err != nil { + return nil, "", false + } + plaintext, err := aesCCMDecrypt(sharedKey, pkiNonce(packet.ID, packet.From, extraNonce), ciphertext, auth) + if err != nil { + return nil, "", false + } + decoded, err := parseDataPacket(plaintext) + if err != nil { + return nil, "", false + } + if decoded.Portnum == unknownApp { + return nil, "", false + } + decrypted := *packet + decrypted.Encrypted = nil + decrypted.Decoded = decoded + decrypted.PayloadVariant = "decoded" + decrypted.PKIEncrypted = true + return &decrypted, "pki success", true +} + // decodeUser 将 NODEINFO_APP payload 解码为节点信息 JSON 字段。 func decodeUser(packet *meshPacket) (map[string]any, error) { user, err := parseUser(packet.Decoded.Payload) diff --git a/mqtpp/pki_test.go b/mqtpp/pki_test.go index 610e93d..002a039 100644 --- a/mqtpp/pki_test.go +++ b/mqtpp/pki_test.go @@ -159,3 +159,51 @@ func TestBuildPKIMeshPacketTags(t *testing.T) { } } } + +// 端到端:发送方构造 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"]) + } +}