- Node stores _pqcMode; Identity::generate(bool,int) derives pq from daemon mode - OneService reads local.conf settings.pqcMode before new Node (opt-in, absent=classic) - Peer/IncomingPacket: hybrid KEM ct in HELLO/OK, capability-bit fallback classic - tools/pqc-lab.sh two-node E2E lab + tools/zt-dissector.lua capture dissector - docs/pqc-test-scenario.md test plan - SPEC.md review pass: V12 evidence -> live daemon check, V14/V15 cite realign, T24/T25 tasks (pqconly preset gap documented)
109 lines
4.4 KiB
Lua
109 lines
4.4 KiB
Lua
-- BackOne / ZeroTier wire dissector for Wireshark / tshark 4.x
|
|
-- Usage: tshark -X lua_script:tools/zt-dissector.lua -r capture.pcap -Y zt
|
|
-- Offsets per node/Packet.hpp:224-258 and node/Peer.cpp:463-517.
|
|
local zt = Proto("zt", "BackOne / ZeroTier P2P")
|
|
|
|
local VERB_HELLO = 1 -- Packet.hpp:592 (HELLO is 1, NOT 0)
|
|
local FRAGMENT_INDICATOR = 0xff -- Packet.hpp:253 (ZT_ADDRESS_RESERVED_PREFIX)
|
|
local IDENTITY_TYPE_PQ_HYBRID = 1
|
|
local IDENTITY_SIZE_CLASSIC = 39 -- 5 addr + 1 type + 32 pub + 1 privlen
|
|
local IDENTITY_SIZE_PQ = 3175 -- classic + 1184 ML-KEM-768 pk + 1952 ML-DSA-65 pk
|
|
local HELLO_IDENTITY_OFF = 13 -- payload + (pv1+maj1+min1+rev2+timestamp8)
|
|
|
|
local f = {
|
|
iv = ProtoField.bytes("zt.iv", "Packet ID / IV"),
|
|
dest = ProtoField.string("zt.dest", "Destination"),
|
|
src = ProtoField.string("zt.src", "Source"),
|
|
flags = ProtoField.uint8("zt.flags", "Flags", base.HEX),
|
|
verb = ProtoField.uint8("zt.verb", "Verb", base.DEC),
|
|
frag = ProtoField.uint8("zt.frag", "Fragment indicator", base.HEX),
|
|
fragno = ProtoField.uint8("zt.fragno", "Fragment #", base.DEC),
|
|
fragtot = ProtoField.uint8("zt.fragtot", "Total fragments", base.DEC),
|
|
hello = ProtoField.none("zt.hello", "HELLO"),
|
|
helloPv = ProtoField.uint8("zt.hello.pv", "Protocol version", base.DEC),
|
|
helloMaj = ProtoField.uint8("zt.hello.major", "Major", base.DEC),
|
|
helloMin = ProtoField.uint8("zt.hello.minor", "Minor", base.DEC),
|
|
helloRev = ProtoField.uint16("zt.hello.rev", "Revision", base.HEX),
|
|
helloCp = ProtoField.bool("zt.hello.cap", "PQC capability bit", 16, nil, 0x8000),
|
|
helloIdt = ProtoField.uint8("zt.hello.idtype", "Identity type", base.DEC),
|
|
helloIds = ProtoField.uint32("zt.hello.idsize", "Identity size", base.DEC),
|
|
}
|
|
zt.fields = f
|
|
|
|
local function addr(buf, off) return tostring(buf(off, 5):bytes():tohex()) end
|
|
|
|
-- Parse the HELLO verb body starting at payload offset `p`. `limit` is the number
|
|
-- of bytes actually present in this frame (a head fragment may truncate it).
|
|
local function parseHello(t, buf, p, limit, pinfo)
|
|
if limit < p + 3 then return end
|
|
local ty
|
|
local ht = t:add(f.hello, buf(p, limit - p))
|
|
ht:add(f.helloPv, buf(p + 0, 1))
|
|
ht:add(f.helloMaj, buf(p + 1, 1))
|
|
ht:add(f.helloMin, buf(p + 2, 1))
|
|
if limit >= p + 5 then
|
|
ht:add(f.helloRev, buf(p + 3, 2))
|
|
ht:add(f.helloCp, buf(p + 3, 2))
|
|
end
|
|
local id = p + HELLO_IDENTITY_OFF
|
|
if limit >= id + 6 then
|
|
ty = buf(id + 5, 1):uint()
|
|
ht:add(f.helloIdt, buf(id + 5, 1))
|
|
ht:add(f.helloIds, (ty == IDENTITY_TYPE_PQ_HYBRID) and IDENTITY_SIZE_PQ or IDENTITY_SIZE_CLASSIC)
|
|
end
|
|
-- The [moonCount][moons][hybridFlag(1)][ML-KEM ct] tail is cryptField()'d
|
|
-- with the classical key (Peer.cpp:500), so it is opaque to a passive
|
|
-- capture: the dissector never guesses the ciphertext length.
|
|
if ty then
|
|
pinfo.cols.info = ("ZT HELLO type=%d %s cap=%s"):format(
|
|
ty, addr(buf, id),
|
|
(limit >= p + 4) and (bit.band(buf(p + 3, 2):uint(), 0x8000) ~= 0 and "1" or "0") or "?")
|
|
else
|
|
pinfo.cols.info = "ZT HELLO"
|
|
end
|
|
end
|
|
|
|
function zt.dissector(buf, pinfo, tree)
|
|
if buf:len() < 16 then return end
|
|
pinfo.cols.protocol = "ZT"
|
|
|
|
-- Fragment frame: [8 pkID][5 dest][1 0xff][1 {total<<4 | no}][1 hops][data]
|
|
if buf(13, 1):uint() == FRAGMENT_INDICATOR then
|
|
local t = tree:add(zt, buf(), "BackOne fragment")
|
|
t:add(f.iv, buf(0, 8))
|
|
t:add(f.dest, addr(buf, 8))
|
|
t:add(f.frag, buf(13, 1))
|
|
local total = bit.rshift(buf(14, 1):uint(), 4)
|
|
local no = bit.band(buf(14, 1):uint(), 0x0f)
|
|
t:add(f.fragtot, buf(14, 1), total)
|
|
t:add(f.fragno, buf(14, 1), no)
|
|
pinfo.cols.info = ("BackOne fragment %d/%d"):format(no, total)
|
|
return
|
|
end
|
|
|
|
if buf:len() < 28 then return end
|
|
|
|
local verb = buf(27, 1):uint()
|
|
local encrypted = bit.band(buf(18, 1):uint(), 0x80) ~= 0
|
|
local t = tree:add(zt, buf(), "BackOne, verb " .. verb)
|
|
t:add(f.iv, buf(0, 8))
|
|
t:add(f.dest, addr(buf, 8))
|
|
t:add(f.src, addr(buf, 13))
|
|
t:add(f.flags, buf(18, 1))
|
|
t:add(f.verb, buf(27, 1))
|
|
|
|
-- Head of a fragmented packet still carries the original header, so the
|
|
-- verb and the leading payload bytes are parseable.
|
|
if bit.band(buf(18, 1):uint(), 0x40) ~= 0 then
|
|
t:add(f.frag, buf(18, 1))
|
|
end
|
|
|
|
if verb == VERB_HELLO and not encrypted then
|
|
parseHello(t, buf, 28, buf:len(), pinfo)
|
|
end
|
|
end
|
|
|
|
-- Default ZT port and the lab port used by docs/pqc-test-scenario.md.
|
|
DissectorTable.get("udp.port"):add(9993, zt)
|
|
DissectorTable.get("udp.port"):add(19993, zt)
|