diff --git a/docs/superpowers/plans/2026-07-09-stage-c-log-backed-receive-messages-loop.md b/docs/superpowers/plans/2026-07-09-stage-c-log-backed-receive-messages-loop.md index 408e51c..7f3331d 100644 --- a/docs/superpowers/plans/2026-07-09-stage-c-log-backed-receive-messages-loop.md +++ b/docs/superpowers/plans/2026-07-09-stage-c-log-backed-receive-messages-loop.md @@ -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 `` 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 + + redacted + + FILE_TRANSFER_CANCEL + + +` + 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. --- diff --git a/internal/isphere/logcodec/codec.go b/internal/isphere/logcodec/codec.go new file mode 100644 index 0000000..7797184 --- /dev/null +++ b/internal/isphere/logcodec/codec.go @@ -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 +} diff --git a/internal/isphere/logcodec/codec_test.go b/internal/isphere/logcodec/codec_test.go new file mode 100644 index 0000000..95071f0 --- /dev/null +++ b/internal/isphere/logcodec/codec_test.go @@ -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\nredacted" + 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 +} diff --git a/internal/isphere/packetlog/message.go b/internal/isphere/packetlog/message.go new file mode 100644 index 0000000..ffc9225 --- /dev/null +++ b/internal/isphere/packetlog/message.go @@ -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, "\r\n redacted.docx\r\n \r\n FILE_TRANSFER_CANCEL\r\n \r\n \r\n \r\n \r\n \r\n" + + 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) + } +}