feat: parse encrypted isphere packet logs

This commit is contained in:
zhaoyilun
2026-07-09 22:53:23 +08:00
parent de3bb922da
commit 4d5b314f83
5 changed files with 420 additions and 23 deletions

View File

@@ -81,7 +81,7 @@ Stop and ask the user only when:
- Produces: `packetlog.ParseMessageLog(plaintext string) (packetlog.Message, error)`.
- Produces: `packetlog.Message` with fields `ID`, `From`, `To`, `Type`, `Body`, `Subject`, `SendTime`, `ReceiptID`, `ReceiptType`, `Consumed`, and `Read`.
- [ ] **Step 1: Write failing decrypt test**
- [x] **Step 1: Write failing decrypt test**
Create `internal/isphere/logcodec/codec_test.go`:
@@ -112,7 +112,7 @@ go test ./internal/isphere/logcodec -run TestDecryptLineWithKnownPolicyRoundTrip
Expected: FAIL because the package/function does not exist.
- [ ] **Step 2: Implement decryptor and test helper**
- [x] **Step 2: Implement decryptor and test helper**
Create `internal/isphere/logcodec/codec.go`:
@@ -207,7 +207,7 @@ import (
)
```
- [ ] **Step 3: Verify decryptor test passes**
- [x] **Step 3: Verify decryptor test passes**
Run:
@@ -217,7 +217,7 @@ go test ./internal/isphere/logcodec -run TestDecryptLineWithKnownPolicyRoundTrip
Expected: PASS.
- [ ] **Step 4: Write failing PacketReader parser test**
- [x] **Step 4: Write failing PacketReader parser test**
Create `internal/isphere/packetlog/message_test.go`:
@@ -259,7 +259,7 @@ go test ./internal/isphere/packetlog -run TestParseMessageLogExtractsXMPPMessage
Expected: FAIL because the package/function does not exist.
- [ ] **Step 5: Implement PacketReader parser**
- [x] **Step 5: Implement PacketReader parser**
Create `internal/isphere/packetlog/message.go`:
@@ -339,7 +339,7 @@ func ParseMessageLog(plaintext string) (Message, error) {
}
```
- [ ] **Step 6: Verify PacketReader parser test passes**
- [x] **Step 6: Verify PacketReader parser test passes**
Run:
@@ -349,7 +349,7 @@ go test ./internal/isphere/packetlog -run TestParseMessageLogExtractsXMPPMessage
Expected: PASS.
- [ ] **Step 7: Run full verification**
- [x] **Step 7: Run full verification**
Run:
@@ -360,7 +360,7 @@ go build ./cmd/isphere-mcp
Expected: both exit 0.
- [ ] **Step 8: Commit Loop C1**
- [x] **Step 8: Commit Loop C1**
Run:
@@ -372,34 +372,143 @@ git commit -m "feat: parse encrypted isphere packet logs"
Expected: commit succeeds.
- [ ] **Step 9: Rewrite next loop before continuing**
- [x] **Step 9: Rewrite next loop before continuing**
After Loop C1 passes, update the `Loop C2` section below with actual package names and parser behavior from the implementation. If Loop C1 exposes a parser limitation, write that limitation into Loop C2 before coding.
---
## Loop C1 Result
Implemented and verified in this loop:
- `internal/isphere/logcodec.DecryptLine(ciphertextBase64 string) (string, error)` decrypts DES/CBC/PKCS7 Base64 log lines using the iSphere log policy key `hyhccdtm` and IV `12 34 56 78 90 AB CD EF`.
- `internal/isphere/packetlog.ParseMessageLog(plaintext string) (packetlog.Message, error)` extracts an XMPP `<message>` stanza from decrypted `PacketReader.ProcessPacket` plaintext and maps id, from, to, type, body, subject, `isphere sendtime`, receipt id/type, consumed, and read flags.
- Tests use synthetic/redacted fixtures only.
---
## Loop C2: Log-backed source abstraction
**Current draft goal:** Wrap `logcodec.DecryptLine` and `packetlog.ParseMessageLog` into a source abstraction that returns normalized messages from a list of encrypted log lines.
**Goal:** Wrap `logcodec.DecryptLine` and `packetlog.ParseMessageLog` into a source abstraction that returns normalized messages from encrypted PacketReader log lines.
**Planned files:**
**Files:**
- Create: `internal/isphere/source.go`
- Create: `internal/isphere/source_test.go`
- Modify: `docs/superpowers/plans/2026-07-09-stage-c-log-backed-receive-messages-loop.md`
**Draft interface after Loop C1:**
**Interfaces:**
- Consumes: `logcodec.DecryptLine(ciphertextBase64 string) (string, error)`.
- Consumes: `packetlog.ParseMessageLog(plaintext string) (packetlog.Message, error)`.
- Produces: `EncryptedPacketLogSource.ReceiveMessages(ctx context.Context, query ReceiveMessagesQuery) (ReceiveMessagesResult, error)`.
- [ ] **Step 1: Write failing source abstraction test**
Create `internal/isphere/source_test.go`:
```go
package isphere
import (
"context"
"crypto/cipher"
"crypto/des"
"encoding/base64"
"testing"
)
func TestEncryptedPacketLogSourceReturnsNormalizedMessages(t *testing.T) {
plaintext := `--------------------------------------------------------------------------------------------------------------------------------------------
2026/7/7 15:30:07
<message id="msg-1" from="sender@imopenfire1-lanzhou/imp_pc_4.1.2.6842" to="receiver@imopenfire1-lanzhou" type="chat">
<body>redacted</body>
<received xmlns="urn:xmpp:receipts" id="receipt-1" type="1" stamp="" />
<subject>FILE_TRANSFER_CANCEL</subject>
<isphere xmlns="isphere.im" type="1001" sendtime="1783423807000" version="1" />
<readed />
</message>`
source := EncryptedPacketLogSource{Lines: []string{encryptPacketLineForTest(t, plaintext)}}
got, err := source.ReceiveMessages(context.Background(), ReceiveMessagesQuery{Limit: 10})
if err != nil {
t.Fatalf("ReceiveMessages returned error: %v", err)
}
if len(got.Messages) != 1 {
t.Fatalf("message count = %d, want 1", len(got.Messages))
}
msg := got.Messages[0]
if msg.ID != "msg-1" || msg.Text != "redacted" || msg.Subject != "FILE_TRANSFER_CANCEL" {
t.Fatalf("unexpected message content: %+v", msg)
}
if msg.ConversationID != "sender@imopenfire1-lanzhou|receiver@imopenfire1-lanzhou" {
t.Fatalf("conversation id = %q", msg.ConversationID)
}
if msg.SenderID != "sender@imopenfire1-lanzhou" || msg.ReceiverID != "receiver@imopenfire1-lanzhou" {
t.Fatalf("sender/receiver = %q/%q", msg.SenderID, msg.ReceiverID)
}
if msg.Timestamp != "1783423807000" || msg.ReceiptID != "receipt-1" || !msg.Read {
t.Fatalf("metadata = %+v", msg)
}
}
func encryptPacketLineForTest(t *testing.T, plaintext string) string {
t.Helper()
block, err := des.NewCipher([]byte("hyhccdtm"))
if err != nil {
t.Fatalf("NewCipher: %v", err)
}
plain := padPacketLineForTest([]byte(plaintext), des.BlockSize)
ciphertext := make([]byte, len(plain))
iv := []byte{0x12, 0x34, 0x56, 0x78, 0x90, 0xAB, 0xCD, 0xEF}
cipher.NewCBCEncrypter(block, iv).CryptBlocks(ciphertext, plain)
return base64.StdEncoding.EncodeToString(ciphertext)
}
func padPacketLineForTest(data []byte, blockSize int) []byte {
pad := blockSize - len(data)%blockSize
out := append([]byte(nil), data...)
for i := 0; i < pad; i++ {
out = append(out, byte(pad))
}
return out
}
```
Run:
```powershell
go test ./internal/isphere -run TestEncryptedPacketLogSourceReturnsNormalizedMessages -v
```
Expected: FAIL because `EncryptedPacketLogSource`, `ReceiveMessagesQuery`, and related types do not exist.
- [ ] **Step 2: Implement source abstraction**
Create `internal/isphere/source.go`:
```go
package isphere
import (
"context"
"fmt"
"strings"
"isphere-ai-bridge/internal/isphere/logcodec"
"isphere-ai-bridge/internal/isphere/packetlog"
)
type Message struct {
ID string
ConversationID string
ID string
ConversationID string
ConversationType string
SenderID string
ReceiverID string
Text string
Timestamp string
Subject string
ReceiptID string
Read bool
SenderID string
ReceiverID string
Text string
Timestamp string
Subject string
ReceiptID string
Read bool
}
type ReceiveMessagesQuery struct {
@@ -414,10 +523,103 @@ type EncryptedPacketLogSource struct {
Lines []string
}
func (s EncryptedPacketLogSource) ReceiveMessages(ctx context.Context, query ReceiveMessagesQuery) (ReceiveMessagesResult, error)
func (s EncryptedPacketLogSource) ReceiveMessages(ctx context.Context, query ReceiveMessagesQuery) (ReceiveMessagesResult, error) {
limit := query.Limit
if limit <= 0 || limit > len(s.Lines) {
limit = len(s.Lines)
}
messages := make([]Message, 0, limit)
for index, line := range s.Lines {
if len(messages) == limit {
break
}
select {
case <-ctx.Done():
return ReceiveMessagesResult{}, ctx.Err()
default:
}
plaintext, err := logcodec.DecryptLine(line)
if err != nil {
return ReceiveMessagesResult{}, fmt.Errorf("decrypt packet log line %d: %w", index, err)
}
parsed, err := packetlog.ParseMessageLog(plaintext)
if err != nil {
return ReceiveMessagesResult{}, fmt.Errorf("parse packet log line %d: %w", index, err)
}
messages = append(messages, normalizePacketMessage(parsed))
}
return ReceiveMessagesResult{Messages: messages}, nil
}
func normalizePacketMessage(msg packetlog.Message) Message {
sender := bareJID(msg.From)
receiver := bareJID(msg.To)
return Message{
ID: msg.ID,
ConversationID: conversationID(sender, receiver),
ConversationType: msg.Type,
SenderID: sender,
ReceiverID: receiver,
Text: msg.Body,
Timestamp: msg.SendTime,
Subject: msg.Subject,
ReceiptID: msg.ReceiptID,
Read: msg.Read,
}
}
func bareJID(value string) string {
beforeResource, _, _ := strings.Cut(value, "/")
return beforeResource
}
func conversationID(sender string, receiver string) string {
if sender == "" {
return receiver
}
if receiver == "" {
return sender
}
return sender + "|" + receiver
}
```
**Rewrite rule before implementation:** Replace this draft with exact code after Loop C1 is committed.
- [ ] **Step 3: Verify source abstraction test passes**
Run:
```powershell
go test ./internal/isphere -run TestEncryptedPacketLogSourceReturnsNormalizedMessages -v
```
Expected: PASS.
- [ ] **Step 4: Run full verification**
Run:
```powershell
go test ./...
go build ./cmd/isphere-mcp
```
Expected: both exit 0.
- [ ] **Step 5: Commit Loop C2**
Run:
```powershell
git add -- internal/isphere/source.go internal/isphere/source_test.go docs/superpowers/plans/2026-07-09-stage-c-log-backed-receive-messages-loop.md
git diff --cached --check
git commit -m "feat: add log-backed receive message source"
```
Expected: commit succeeds.
- [ ] **Step 6: Rewrite Loop C3 before continuing**
After Loop C2 passes, update `Loop C3` with the exact source interface and MCP response shape from the committed implementation.
---

View File

@@ -0,0 +1,52 @@
package logcodec
import (
"crypto/cipher"
"crypto/des"
"encoding/base64"
"errors"
"fmt"
)
var (
policyKey = []byte("hyhccdtm")
policyIV = []byte{0x12, 0x34, 0x56, 0x78, 0x90, 0xAB, 0xCD, 0xEF}
)
func DecryptLine(ciphertextBase64 string) (string, error) {
ciphertext, err := base64.StdEncoding.DecodeString(ciphertextBase64)
if err != nil {
return "", fmt.Errorf("decode base64 log line: %w", err)
}
if len(ciphertext) == 0 || len(ciphertext)%des.BlockSize != 0 {
return "", fmt.Errorf("ciphertext length %d is not a positive DES block multiple", len(ciphertext))
}
block, err := des.NewCipher(policyKey)
if err != nil {
return "", fmt.Errorf("create DES cipher: %w", err)
}
plain := make([]byte, len(ciphertext))
cipher.NewCBCDecrypter(block, policyIV).CryptBlocks(plain, ciphertext)
plain, err = unpadPKCS7(plain, des.BlockSize)
if err != nil {
return "", err
}
return string(plain), nil
}
func unpadPKCS7(data []byte, blockSize int) ([]byte, error) {
if len(data) == 0 || len(data)%blockSize != 0 {
return nil, errors.New("pkcs7 data is empty or not block aligned")
}
pad := int(data[len(data)-1])
if pad == 0 || pad > blockSize || pad > len(data) {
return nil, fmt.Errorf("invalid pkcs7 padding length %d", pad)
}
for i := len(data) - pad; i < len(data); i++ {
if int(data[i]) != pad {
return nil, errors.New("invalid pkcs7 padding bytes")
}
}
return data[:len(data)-pad], nil
}

View File

@@ -0,0 +1,43 @@
package logcodec
import (
"crypto/cipher"
"crypto/des"
"encoding/base64"
"testing"
)
func TestDecryptLineWithKnownPolicyRoundTrip(t *testing.T) {
plaintext := "--------------------------------------------------------------------------------------------------------------------------------------------\r\n2026/7/7 15:30:07\r\n<message id=\"msg-1\" from=\"sender@imopenfire1-lanzhou/imp_pc_4.1.2.6842\" to=\"receiver@imopenfire1-lanzhou\" type=\"chat\"><body>redacted</body></message>"
ciphertext := encryptLineForTest(t, plaintext)
got, err := DecryptLine(ciphertext)
if err != nil {
t.Fatalf("DecryptLine returned error: %v", err)
}
if got != plaintext {
t.Fatalf("DecryptLine() = %q, want %q", got, plaintext)
}
}
func encryptLineForTest(t *testing.T, plaintext string) string {
t.Helper()
block, err := des.NewCipher([]byte("hyhccdtm"))
if err != nil {
t.Fatalf("NewCipher: %v", err)
}
plain := padPKCS7ForTest([]byte(plaintext), des.BlockSize)
ciphertext := make([]byte, len(plain))
iv := []byte{0x12, 0x34, 0x56, 0x78, 0x90, 0xAB, 0xCD, 0xEF}
cipher.NewCBCEncrypter(block, iv).CryptBlocks(ciphertext, plain)
return base64.StdEncoding.EncodeToString(ciphertext)
}
func padPKCS7ForTest(data []byte, blockSize int) []byte {
pad := blockSize - len(data)%blockSize
out := append([]byte(nil), data...)
for i := 0; i < pad; i++ {
out = append(out, byte(pad))
}
return out
}

View File

@@ -0,0 +1,73 @@
package packetlog
import (
"encoding/xml"
"errors"
"fmt"
"strings"
)
type Message struct {
ID string
From string
To string
Type string
Body string
Subject string
SendTime string
ReceiptID string
ReceiptType string
Consumed bool
Read bool
}
type xmppMessage struct {
XMLName xml.Name `xml:"message"`
ID string `xml:"id,attr"`
From string `xml:"from,attr"`
To string `xml:"to,attr"`
Type string `xml:"type,attr"`
Body string `xml:"body"`
Subject string `xml:"subject"`
Receipt xmppReceipt `xml:"received"`
ISphere xmppISphere `xml:"isphere"`
Consumed *struct{} `xml:"consumed"`
Readed *struct{} `xml:"readed"`
}
type xmppReceipt struct {
ID string `xml:"id,attr"`
Type string `xml:"type,attr"`
}
type xmppISphere struct {
SendTime string `xml:"sendtime,attr"`
}
func ParseMessageLog(plaintext string) (Message, error) {
start := strings.Index(plaintext, "<message")
if start < 0 {
return Message{}, errors.New("message stanza not found")
}
payload := strings.TrimSpace(plaintext[start:])
var parsed xmppMessage
if err := xml.Unmarshal([]byte(payload), &parsed); err != nil {
return Message{}, fmt.Errorf("parse xmpp message: %w", err)
}
if parsed.ID == "" {
return Message{}, errors.New("message id is empty")
}
return Message{
ID: parsed.ID,
From: parsed.From,
To: parsed.To,
Type: parsed.Type,
Body: strings.TrimSpace(parsed.Body),
Subject: strings.TrimSpace(parsed.Subject),
SendTime: parsed.ISphere.SendTime,
ReceiptID: parsed.Receipt.ID,
ReceiptType: parsed.Receipt.Type,
Consumed: parsed.Consumed != nil,
Read: parsed.Readed != nil,
}, nil
}

View File

@@ -0,0 +1,27 @@
package packetlog
import "testing"
func TestParseMessageLogExtractsXMPPMessage(t *testing.T) {
plaintext := "--------------------------------------------------------------------------------------------------------------------------------------------\r\n2026/7/7 15:30:07\r\n<message id=\"2de080bf-f5cc-45e2-b229-2df6bcd4afe3\" from=\"sender@imopenfire1-lanzhou/imp_pc_4.1.2.6842\" to=\"receiver@imopenfire1-lanzhou\" type=\"chat\">\r\n <body>redacted.docx</body>\r\n <received xmlns=\"urn:xmpp:receipts\" id=\"167c08c3d710489f812c13dc23d4e404\" type=\"1\" stamp=\"\" />\r\n <subject>FILE_TRANSFER_CANCEL</subject>\r\n <isphere xmlns=\"isphere.im\" type=\"1001\" sendtime=\"1783423807000\" version=\"1\">\r\n <alert type=\"0\" />\r\n </isphere>\r\n <consumed />\r\n <readed />\r\n</message>"
got, err := ParseMessageLog(plaintext)
if err != nil {
t.Fatalf("ParseMessageLog returned error: %v", err)
}
if got.ID != "2de080bf-f5cc-45e2-b229-2df6bcd4afe3" {
t.Fatalf("ID = %q", got.ID)
}
if got.From != "sender@imopenfire1-lanzhou/imp_pc_4.1.2.6842" || got.To != "receiver@imopenfire1-lanzhou" {
t.Fatalf("from/to = %q/%q", got.From, got.To)
}
if got.Body != "redacted.docx" || got.Subject != "FILE_TRANSFER_CANCEL" {
t.Fatalf("body/subject = %q/%q", got.Body, got.Subject)
}
if got.SendTime != "1783423807000" || got.ReceiptID != "167c08c3d710489f812c13dc23d4e404" || got.ReceiptType != "1" {
t.Fatalf("receipt fields = %+v", got)
}
if !got.Consumed || !got.Read {
t.Fatalf("consumed/read = %v/%v", got.Consumed, got.Read)
}
}