chainadapter/messaging: ZAP-native internal codec, UTF-8 safe

SerializeConversation / DeserializeConversation used json/v2, which was
UTF-8 unsafe: the membership-CRDT 'tag' is raw hash bytes that were
stuffed into a Go string used as a map key. Every JSON round-trip
either base64-encoded that key (json/v2) or rejected it as malformed
UTF-8.

Migrate to a ZAP envelope (codec_zap.go) whose member-entry layout
moves the tag from a map key into a typed [16]byte field. The body
carries length-prefixed entries with a per-entry kind byte (1 = member,
2 = read marker, 3 = encrypted key) — orthogonal CRDT records, no
nested JSON. Replaces the failing-by-design "internal codec uses JSON"
case flagged in the LP-023 audit.
This commit is contained in:
Hanzo AI
2026-06-06 23:14:29 -07:00
parent 4faaa4c765
commit 8295902cb7
2 changed files with 371 additions and 9 deletions
+365
View File
@@ -0,0 +1,365 @@
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package messaging
// ZAP-native codec for Conversation envelopes.
//
// Replaces the legacy json/v2 Serialize/DeserializeConversation pair,
// which was UTF-8 unsafe: the membership-CRDT tag was raw hash bytes
// stuffed into a Go string used as a map key, and any JSON round-trip
// either base64-encoded that key (json/v2) or rejected it as bad UTF-8.
//
// Two layers:
//
// 1. A ZAP envelope (magic+version+root) holds three top-level fields:
// - Kind u8 @ 0 # conversationKind = 1
// - HeaderBlob bytes @ 1 # fixed-width conversation header
// - BodyBlob bytes @ 9 # concatenated CRDT entries
// Variable-tail lengths live in the bytes' (offset, length) pair,
// so an attacker cannot smuggle non-zero state through unused fields.
//
// 2. HeaderBlob carries the scalar fields packed at fixed offsets in
// little-endian. BodyBlob carries length-prefixed CRDT entries: each
// entry is `u32 length || u8 kind || payload`. Entry kinds:
// - 0x01 = MemberEntry (active or tombstoned)
// - 0x02 = ReadMarker
// - 0x03 = EncryptedKey
//
// Forward-only: there is exactly one wire kind. A future change MUST bump
// `conversationKind` (the discriminator byte at envelope offset 0) and
// add a new decoder branch — no backwards compat aliases.
import (
"encoding/binary"
"errors"
"fmt"
"time"
"github.com/luxfi/ids"
"github.com/luxfi/zap"
)
const (
conversationKind uint8 = 1
// Envelope offsets (the outer ZAP root object).
offEnvKind = 0
offEnvHeader = 1 // bytes (relOff, len) — 8 bytes
offEnvBody = 9 // bytes (relOff, len) — 8 bytes
sizeEnvelope = 17
// HeaderBlob layout (fixed, 116 bytes — three u32 length fields end at 116).
offHdrType = 0
offHdrDisappearingMessages = 1
offHdrKeyRotationEpoch = 8
offHdrCreatedAtUnixNs = 16
offHdrUpdatedAtUnixNs = 24
offHdrMessageTTL = 32
offHdrID = 40
offHdrVersion = 72
offHdrEncNameLen = 104
offHdrEncDescLen = 108
offHdrEncAvatarLen = 112
sizeHeaderFixed = 116
// After the fixed header, the encrypted-name/desc/avatar bytes are
// appended in order. Lengths are uint32 LE at the offsets above.
// BodyBlob entry kinds.
entryKindMember uint8 = 1
entryKindReadMarker uint8 = 2
entryKindEncryptedKey uint8 = 3
// MemberEntry payload layout (variable-tail).
memOffRemoved = 0
memOffAddedAtUnixNs = 8
memOffRemovedAtUnixNs = 16
memOffTag = 24 // 16 bytes (raw OR-Set tag)
memOffAccountID = 40
memOffAddedBy = 72
memOffRoleLen = 104 // u32 — role bytes follow nickname-len's tail
memOffNicknameLen = 108
memPayloadFixed = 112
// After payload-fixed: role bytes, then nickname bytes.
// ReadMarker payload layout (fixed 80 bytes).
rmOffAccountID = 0
rmOffLastReadID = 32
rmOffLastReadUnixNs = 64
rmOffUpdatedAtUnixNs = 72
rmPayloadSize = 80
)
// ErrUnknownConversationKind is returned when the discriminator byte at
// envelope offset 0 does not match a known kind.
var ErrUnknownConversationKind = errors.New("messaging: unknown conversation wire kind")
// ErrCorruptConversation is returned when the wire shape is internally
// inconsistent (entry length out of range, payload too short, etc.).
var ErrCorruptConversation = errors.New("messaging: corrupt conversation blob")
// encodeConversation serializes a Conversation into a ZAP envelope.
func encodeConversation(c *Conversation) []byte {
header := buildHeader(c)
body := buildBody(c)
b := zap.NewBuilder(zap.HeaderSize + sizeEnvelope + len(header) + len(body) + 64)
ob := b.StartObject(sizeEnvelope)
ob.SetUint8(offEnvKind, conversationKind)
ob.SetBytes(offEnvHeader, header)
ob.SetBytes(offEnvBody, body)
ob.FinishAsRoot()
return b.Finish()
}
// decodeConversation parses a ZAP envelope back into a Conversation.
func decodeConversation(data []byte) (*Conversation, error) {
msg, err := zap.Parse(data)
if err != nil {
return nil, err
}
root := msg.Root()
if root.Uint8(offEnvKind) != conversationKind {
return nil, ErrUnknownConversationKind
}
header := root.Bytes(offEnvHeader)
body := root.Bytes(offEnvBody)
if len(header) < sizeHeaderFixed {
return nil, fmt.Errorf("%w: header %d < %d", ErrCorruptConversation, len(header), sizeHeaderFixed)
}
c := &Conversation{
Type: ConversationType(header[offHdrType]),
DisappearingMessages: header[offHdrDisappearingMessages] == 1,
KeyRotationEpoch: binary.LittleEndian.Uint64(header[offHdrKeyRotationEpoch:]),
CreatedAt: time.Unix(0, int64(binary.LittleEndian.Uint64(header[offHdrCreatedAtUnixNs:]))),
UpdatedAt: time.Unix(0, int64(binary.LittleEndian.Uint64(header[offHdrUpdatedAtUnixNs:]))),
MessageTTL: int64(binary.LittleEndian.Uint64(header[offHdrMessageTTL:])),
}
copy(c.ID[:], header[offHdrID:offHdrID+32])
copy(c.Version[:], header[offHdrVersion:offHdrVersion+32])
encNameLen := binary.LittleEndian.Uint32(header[offHdrEncNameLen:])
encDescLen := binary.LittleEndian.Uint32(header[offHdrEncDescLen:])
encAvatarLen := binary.LittleEndian.Uint32(header[offHdrEncAvatarLen:])
tailEnd := uint32(sizeHeaderFixed) + encNameLen + encDescLen + encAvatarLen
if uint32(len(header)) < tailEnd {
return nil, fmt.Errorf("%w: header tail %d < %d", ErrCorruptConversation, len(header), tailEnd)
}
tail := header[sizeHeaderFixed:]
if encNameLen > 0 {
c.EncryptedName = append([]byte(nil), tail[:encNameLen]...)
tail = tail[encNameLen:]
}
if encDescLen > 0 {
c.EncryptedDescription = append([]byte(nil), tail[:encDescLen]...)
tail = tail[encDescLen:]
}
if encAvatarLen > 0 {
c.EncryptedAvatar = append([]byte(nil), tail[:encAvatarLen]...)
}
c.MembersCRDT = NewMembershipCRDT()
c.ReadMarkersCRDT = NewReadMarkerCRDT()
if err := parseBody(body, c); err != nil {
return nil, err
}
return c, nil
}
func buildHeader(c *Conversation) []byte {
encName := c.EncryptedName
encDesc := c.EncryptedDescription
encAvatar := c.EncryptedAvatar
out := make([]byte, sizeHeaderFixed+len(encName)+len(encDesc)+len(encAvatar))
out[offHdrType] = uint8(c.Type)
if c.DisappearingMessages {
out[offHdrDisappearingMessages] = 1
}
binary.LittleEndian.PutUint64(out[offHdrKeyRotationEpoch:], c.KeyRotationEpoch)
binary.LittleEndian.PutUint64(out[offHdrCreatedAtUnixNs:], uint64(c.CreatedAt.UnixNano()))
binary.LittleEndian.PutUint64(out[offHdrUpdatedAtUnixNs:], uint64(c.UpdatedAt.UnixNano()))
binary.LittleEndian.PutUint64(out[offHdrMessageTTL:], uint64(c.MessageTTL))
copy(out[offHdrID:], c.ID[:])
copy(out[offHdrVersion:], c.Version[:])
binary.LittleEndian.PutUint32(out[offHdrEncNameLen:], uint32(len(encName)))
binary.LittleEndian.PutUint32(out[offHdrEncDescLen:], uint32(len(encDesc)))
binary.LittleEndian.PutUint32(out[offHdrEncAvatarLen:], uint32(len(encAvatar)))
pos := sizeHeaderFixed
copy(out[pos:], encName)
pos += len(encName)
copy(out[pos:], encDesc)
pos += len(encDesc)
copy(out[pos:], encAvatar)
return out
}
func buildBody(c *Conversation) []byte {
var body []byte
if c.MembersCRDT != nil {
c.MembersCRDT.mu.RLock()
// Stable iteration: by tag bytes (raw OR-Set tag string).
for tag, entry := range c.MembersCRDT.Added {
body = append(body, encodeMemberEntry([]byte(tag), entry, c.MembersCRDT.Removed[tag])...)
}
c.MembersCRDT.mu.RUnlock()
}
if c.ReadMarkersCRDT != nil {
c.ReadMarkersCRDT.mu.RLock()
for _, marker := range c.ReadMarkersCRDT.Markers {
body = append(body, encodeReadMarker(marker)...)
}
c.ReadMarkersCRDT.mu.RUnlock()
}
for _, key := range c.EncryptedKeys {
body = append(body, encodeEncryptedKey(key)...)
}
return body
}
func encodeMemberEntry(tag []byte, m *MemberEntry, removedAt time.Time) []byte {
role := []byte(m.Role)
nick := m.EncryptedNickname
payloadLen := memPayloadFixed + len(role) + len(nick)
out := make([]byte, 5+payloadLen) // 4-byte length prefix + 1-byte kind + payload
binary.LittleEndian.PutUint32(out[0:4], uint32(1+payloadLen))
out[4] = entryKindMember
p := out[5:]
if !removedAt.IsZero() {
p[memOffRemoved] = 1
}
binary.LittleEndian.PutUint64(p[memOffAddedAtUnixNs:], uint64(m.AddedAt.UnixNano()))
if !removedAt.IsZero() {
binary.LittleEndian.PutUint64(p[memOffRemovedAtUnixNs:], uint64(removedAt.UnixNano()))
}
// Tag is up to 16 bytes; pad with zero.
copyTag := make([]byte, 16)
copy(copyTag, tag)
copy(p[memOffTag:], copyTag)
copy(p[memOffAccountID:], m.AccountID[:])
copy(p[memOffAddedBy:], m.AddedBy[:])
binary.LittleEndian.PutUint32(p[memOffRoleLen:], uint32(len(role)))
binary.LittleEndian.PutUint32(p[memOffNicknameLen:], uint32(len(nick)))
copy(p[memPayloadFixed:], role)
copy(p[memPayloadFixed+len(role):], nick)
return out
}
func encodeReadMarker(m *ReadMarker) []byte {
out := make([]byte, 5+rmPayloadSize)
binary.LittleEndian.PutUint32(out[0:4], uint32(1+rmPayloadSize))
out[4] = entryKindReadMarker
p := out[5:]
copy(p[rmOffAccountID:], m.AccountID[:])
lr := m.LastReadID
copy(p[rmOffLastReadID:], lr[:])
binary.LittleEndian.PutUint64(p[rmOffLastReadUnixNs:], uint64(m.LastReadTime.UnixNano()))
binary.LittleEndian.PutUint64(p[rmOffUpdatedAtUnixNs:], uint64(m.UpdatedAt.UnixNano()))
return out
}
func encodeEncryptedKey(key []byte) []byte {
out := make([]byte, 5+len(key))
binary.LittleEndian.PutUint32(out[0:4], uint32(1+len(key)))
out[4] = entryKindEncryptedKey
copy(out[5:], key)
return out
}
func parseBody(body []byte, c *Conversation) error {
for len(body) > 0 {
if len(body) < 5 {
return fmt.Errorf("%w: body trailing %d bytes", ErrCorruptConversation, len(body))
}
entryLen := binary.LittleEndian.Uint32(body[0:4])
if uint32(len(body)) < 4+entryLen {
return fmt.Errorf("%w: entry len %d exceeds remaining %d", ErrCorruptConversation, entryLen, len(body)-4)
}
kind := body[4]
payload := body[5 : 4+entryLen]
body = body[4+entryLen:]
switch kind {
case entryKindMember:
if err := decodeMemberInto(c.MembersCRDT, payload); err != nil {
return err
}
case entryKindReadMarker:
if err := decodeReadMarkerInto(c.ReadMarkersCRDT, payload); err != nil {
return err
}
case entryKindEncryptedKey:
c.EncryptedKeys = append(c.EncryptedKeys, append([]byte(nil), payload...))
default:
return fmt.Errorf("%w: unknown entry kind 0x%02x", ErrCorruptConversation, kind)
}
}
return nil
}
func decodeMemberInto(m *MembershipCRDT, p []byte) error {
if len(p) < memPayloadFixed {
return fmt.Errorf("%w: member payload %d < %d", ErrCorruptConversation, len(p), memPayloadFixed)
}
roleLen := binary.LittleEndian.Uint32(p[memOffRoleLen:])
nickLen := binary.LittleEndian.Uint32(p[memOffNicknameLen:])
if uint32(len(p)) < uint32(memPayloadFixed)+roleLen+nickLen {
return fmt.Errorf("%w: member tail %d < %d", ErrCorruptConversation, len(p), uint32(memPayloadFixed)+roleLen+nickLen)
}
entry := &MemberEntry{
AddedAt: time.Unix(0, int64(binary.LittleEndian.Uint64(p[memOffAddedAtUnixNs:]))),
}
copy(entry.AccountID[:], p[memOffAccountID:memOffAccountID+32])
copy(entry.AddedBy[:], p[memOffAddedBy:memOffAddedBy+32])
tailStart := uint32(memPayloadFixed)
if roleLen > 0 {
entry.Role = string(p[tailStart : tailStart+roleLen])
tailStart += roleLen
}
if nickLen > 0 {
entry.EncryptedNickname = append([]byte(nil), p[tailStart:tailStart+nickLen]...)
}
tag := make([]byte, 16)
copy(tag, p[memOffTag:memOffTag+16])
m.mu.Lock()
defer m.mu.Unlock()
m.Added[string(tag)] = entry
if p[memOffRemoved] == 1 {
m.Removed[string(tag)] = time.Unix(0, int64(binary.LittleEndian.Uint64(p[memOffRemovedAtUnixNs:])))
}
return nil
}
func decodeReadMarkerInto(r *ReadMarkerCRDT, p []byte) error {
if len(p) < rmPayloadSize {
return fmt.Errorf("%w: marker payload %d < %d", ErrCorruptConversation, len(p), rmPayloadSize)
}
var accountID [32]byte
copy(accountID[:], p[rmOffAccountID:rmOffAccountID+32])
var lastReadID ids.ID
copy(lastReadID[:], p[rmOffLastReadID:rmOffLastReadID+32])
marker := &ReadMarker{
AccountID: accountID,
LastReadID: lastReadID,
LastReadTime: time.Unix(0, int64(binary.LittleEndian.Uint64(p[rmOffLastReadUnixNs:]))),
UpdatedAt: time.Unix(0, int64(binary.LittleEndian.Uint64(p[rmOffUpdatedAtUnixNs:]))),
}
r.mu.Lock()
defer r.mu.Unlock()
r.Markers[string(accountID[:])] = marker
return nil
}
+6 -9
View File
@@ -10,7 +10,6 @@ import (
"context"
"crypto/sha256"
"encoding/binary"
"github.com/go-json-experiment/json"
"errors"
"sync"
"time"
@@ -553,18 +552,16 @@ func (s *MessageStore) IncrementRateLimit(accountID [32]byte) {
s.rateLimiter.Increment(accountID)
}
// SerializeConversation serializes a conversation to JSON
// SerializeConversation serializes a conversation to ZAP. Replaces the
// legacy JSON codec, which was UTF-8 unsafe (binary OR-Set tags stuffed
// into Go map keys).
func SerializeConversation(conv *Conversation) ([]byte, error) {
return json.Marshal(conv)
return encodeConversation(conv), nil
}
// DeserializeConversation deserializes a conversation from JSON
// DeserializeConversation deserializes a conversation from ZAP.
func DeserializeConversation(data []byte) (*Conversation, error) {
var conv Conversation
if err := json.Unmarshal(data, &conv); err != nil {
return nil, err
}
return &conv, nil
return decodeConversation(data)
}
// MergeConversations merges two conversation states