- 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)
13 KiB
13 KiB
SPEC — BackOne
§G (goal)
Make BackOne quantum ready: phases 1-6 of backone-quantum-fork-research.md — liboqs + hybrid X25519+ML-KEM-768 / Ed25519+ML-DSA-65 handshake, identity/COM v2 double-sign, capability-bit negotiation. Phase 7 (AES-256-GCM) OUT.
§C (constraints)
- C++17. GNU make = real build. Top
CMakeLists.txt= stub, never product build. - Deps vendored in
ext/. No new dep for few-liners. - Tabs always,
.clang-formatLLVM/Stroustrup, indent 4. No reformat of untouched lines. - New
.cpp/.hpp/.hgets 11-line BSL header verbatim (node/Mutex.hpp:1-12). node/stays OS-independent. Sockets/files →osdep/. Orchestration →service/. Persistence →controller/.- No exceptions across C API. Fatal
ZT_ResultCode= 100–999. - One reactor thread (
Phy::poll). Cross-thread wake onlyPhy::whack(). - New compiled file → register in
objects.mkor no link. - Platforms: Linux (primary), macOS, BSD/NetBSD, Windows (msbuild).
- Header edits: no
-MMDdep tracking →make cleanafter touching.h. - PQC lock: liboqs static vendored
ext/; hybrid key = KDF(classical ‖ pq); presetshybrid(ML-KEM-768/ML-DSA-65) +pqconly(intended ML-KEM-1024/ML-DSA-87 — not yet distinct, see T25) - wire/identity/COM use versioned v2 + capability-bit fallback to classic
include/ZeroTierOne.hchanges additive-only (Java/libzt ripple)- OUT: AES-256-GCM swap, trusted-path skipCrypto, forward secrecy, protocol standardization, crypto audit, production release/signing
?address v2 derivation keeping 40-bit + PoW?VL1 fragmentation capacity for +4KB identity (checknode/Packet.*)?liboqs version/tag + build flags forext/?measured ML-KEM/ML-DSA latency (doc estimates unverified)?capability-bit placement in existing handshake
§I (public surfaces)
| id | surface | detail |
|---|---|---|
| I.capi | C API | include/ZeroTierOne.h, ZT_Node_* (lines 1796-2258), ZT_SDK_API. Ripples to Java/libzt/SDK |
| I.api | HTTP JSON API | 127.0.0.1:9993, /status /peer /network /member /controller /metrics /info, auth X-ZT1-Auth header or ?auth= from authtoken.secret |
| I.cli | CLI | backone-cli info|listpeers|listnetworks|join|leave (-j -p -D), backone-idtool generate|validate|getpublic|sign|verify|mkcom — symlink dispatch on argv[0] |
| I.cfg | config | home /var/lib/backone (macOS /Library/Application Support/BackOne), local.conf, identity.secret, networks.d/, controller.db, backone.port, backone.pid |
| I.build | build | make, make one, make selftest, make core, make debug, make install, make debian/redhat, make central-controller; env ZT_* knobs |
| I.ffi | Rust FFI | rustybits/zeroidc (zeroidc_new…), smeeclient — C header zeroidc.h, link on ZT_SSO_SUPPORTED=1/ZT_CONTROLLER=1 |
| I.jni | Java SDK | java/jni/…Node.cpp, ant targets build_java/build_android/build_jar |
| I.pqc.cfg | config | local.conf settings.pqcMode (off/hybrid/pqconly); absent = off/classic (opt-in), unknown value = warn + classic. Per-algorithm kem/sig selection is not wired (research doc only, see T25) |
| I.pqc.wire | wire | static-static hybrid, no ephemeral exchange: identity v2 (type byte 1) carries ML-KEM-768 pub + ML-DSA-65 pub; agree = KDF(X25519-identity-agree ‖ ML-KEM-encaps(peer static pub)) (node/Identity.hpp:364-379 encaps / :392+ decaps, driven from node/Peer.cpp ensurePendingHybridCt/setHybridSessionKey); capability via HELLO protocol-version bytes (node/Packet.hpp:329-333); identity.secret/identity.public v2 on disk |
| I.pqc.com | credentials | COM double-sign/verify — controller + node/CertificateOfMembership.* |
| I.rules | rules compiler | node rule-compiler/cli.js <rules> → rules/tags JSON |
§V (invariants)
Derived from selftest.cpp + CI gates. ? = code-derived, no test yet.
| id | invariant | evidence |
|---|---|---|
| V1 | Crypto KATs pass: Salsa20 vectors, C25519 agreement, Ed25519 sign/verify, SHA512, Poly1305 | selftest.cpp:137 testCrypto |
| V2 | Identity known-good accepted, known-bad rejected | selftest.cpp:475 testIdentity |
| V3 | CertificateOfMembership signs and agreesWith roundtrip |
selftest.cpp:577 testCertificate |
| V4 | Packet encode/decode roundtrip exact | selftest.cpp:641 testPacket |
| V5 | Phy UDP+TCP loopback on 127.0.0.1 works, no root | selftest.cpp:1002 testPhy |
| V6 | Zero definite leaks under valgrind; test exit code 0 | .github/workflows/validate-report.sh |
| V7 | ? No exception crosses C API; ZT_ResultCode_isFatal only 100–999 |
include/ZeroTierOne.h:426 |
| V8 | ? node/ contains no OS calls (sockets/files/interfaces) |
node/README.md rule |
| V9 | hybrid↔vanilla negotiates classic, link stays up | interop matrix |
| V10 | identity v2 survives 1280B MTU tunnel intact; v2 = type byte 1 (wire Identity.hpp:219, string slot Identity.cpp:173); address derivation hashes X25519 material only → v1/v2 addresses bit-identical; v1 parser rejects type ≠ 0 cleanly |
interop matrix |
| V11 | ML-KEM/ML-DSA KATs + hybrid KDF vector + identity v2 roundtrip + COM double-sign verify pass | extended selftest.cpp |
| V12 | pqcMode negotiation honors local.conf (off -> no v2 fields) |
live daemon: bogus settings.pqcMode -> WARNING (service/OneService.cpp:1478), hybrid -> clean (2026-10-03); selftest has no pqcMode coverage |
| V13 | hybrid agree = KDF(X25519-identity-agree ‖ ML-KEM static-static), no ephemeral exchange; hybrid↔hybrid agree + hybrid↔vanilla classic fallback pass | node/Peer.cpp:105-117 (encaps) node/IncomingPacket.cpp:534-542,697-703 (decaps) |
| V14 | COM double-sign = type byte 1→2 appends ML-DSA sig alongside Ed25519; v1 verify path unchanged; v1 rejects type 2 cleanly | node/CertificateOfMembership.hpp:222-296, node/CertificateOfMembership.cpp:97-162, selftest.cpp:717 |
| V15 | handshake vs fragment loss measured: PQC identity in clear HELLO = 3273B → 3 frags (classic 137B → 1 frag, max 7 Packet.hpp:235); Monte Carlo 20k trials obs≈analytic at p∈{.01,.05,.10,.30}; fragment loss delays HELLO (retransmit), does not break handshake |
selftest.cpp:1713 benchmark |
| V16 | hybrid handshake puts a 1088 B ML-KEM-768 ct on the wire and both sides derive the same hybrid _key; classic/off peers carry none and fall back |
write node/Peer.cpp:493-502, parse node/IncomingPacket.cpp:534-542, OK echo node/IncomingPacket.cpp:608-619,697-703; E2E lab M1-M5 all pass tools/pqc-lab.sh (2026-10-02; M3 68 / M4 90 large PQ datagrams on wire) |
§T (tasks)
| id | status | task | cites |
|---|---|---|---|
| T1 | x | Fix CI build smoke: build.yml runs ./zerotier-selftest, build emits backone-selftest (also tar zerotier-one → backone) |
I.build,V6 |
| T2 | x | Fix validate-linux.sh binary names: invokes ./zerotier-one, ./zerotier-cli (lines 41-43,105,111,451), builds backone* |
I.cli,V6 |
| T3 | x | Wire ZT_COVERAGE into makefiles — consumed by none, gcovr likely reports zeros ? |
I.build |
| T4 | x | make install manpage rule: expects doc/backone.8, doc/backone-cli.1, doc/backone-idtool.1; doc/ only has zerotier-*, no rename rule |
I.build |
| T5 | x | make redhat: backone.spec %install copies missing debian/backone.service + ext/installfiles/linux/backone.init.rhel6 (actual files zerotier-*) |
I.build |
| T6 | x | Rebrand make-bsd.mk/make-netbsd.mk: still emit/install zerotier-one, /var/db/zerotier-one vs code writing backone.pid |
I.build |
| T7 | x | Dockerfile.ci: copies zerotier-one from build → fails; binary is backone |
I.build |
| T8 | . | pkg/snap/snapcraft.yaml: builds upstream github.com/zerotier/zerotierone.git, expects usr/sbin/zerotier-one |
I.build |
| T9 | . | Wire orphaned java/test/StringUtilsTest.java (JUnit4) into java/build.xml target or delete |
I.jni |
| T10 | . | Replace rule-compiler/package.json npm test failing placeholder with real test of rule-compiler.js |
I.rules |
| T11 | . | ? Add #[test] coverage for rustybits/zeroidc FFI parse/exchange paths |
I.ffi,V7 |
| T12 | x | Implement Redis config TODO (service/OneService.cpp:1467 // TODO: Redis config) |
I.cfg |
| T13 | . | Implement LFDB::eraseNetwork/eraseMember TODOs (controller/LFDB.cpp:383,388) |
I.cfg |
| T14 | . | make lint (tools/lint.sh, changed-lines clang-format) + .github/workflows/lint.yml |
I.build |
| T15 | . | Fix -Wsign-compare suppression FIXME (node/Bond.cpp:23, node/Node.cpp:40) |
I.build |
| T16 | x | Federation identity-collision payload FIXME (node/IncomingPacket.cpp:211) ? scope |
I.capi,V7 |
| T17 | x | Phase 1: vendor liboqs static into ext/, wire make-linux.mk + objects.mk, pin version/tag + flags |
I.build |
| T18 | x | Phase 2: node/PQHybrid.* hybrid agree = KDF(X25519-identity-agree ‖ ML-KEM-768 encaps over peer static pub); phase-2 ephemeral-field mockup dropped |
I.pqc.wire,V13 |
| T19 | x | Phase 3: identity v2 type byte 1, append ML-KEM+ML-DSA pubs, address hash X25519-only, v1 clean reject type ≠ 0 | I.pqc.wire,V10 |
| T20 | x | Phase 4: COM type byte 2 double-sign (Ed25519+ML-DSA-65), v1 rejects cleanly | I.pqc.com,V14 |
| T21 | . | Phase 5: local.conf settings.pqcMode parse (off/hybrid/pqconly) + capability negotiation via HELLO protocol-version bytes, fallback classic. Mode now selects whether PQ material is used; which preset is still hardcoded (T24) |
I.pqc.cfg,V12,V9 |
| T22 | x | Phase 6: selftest extension — ML-KEM/ML-DSA KATs, hybrid KDF vector, identity v2 roundtrip + address stability, COM double-sign, interop hybrid↔vanilla matrix | I.build,V11,V10,V14 |
| T23 | x | Phase 6: handshake-vs-fragment-loss benchmark for PQC HELLO; cap/segment if loss breaks handshake | V15 |
| T24 | x | Plumb settings.pqcMode through the identity path: Node(…,int pqcMode) stores _pqcMode; Identity::generate(bool pq,int pqcMode) derives pq from the daemon mode; OneService reads local.conf before new Node — a fresh hybrid/pqconly node writes a type-1 identity, a classic one type-0. Fixes the G1 defect. |
I.pqc.cfg,V12 |
| T25 | . | Distinct PQ parameter sets: node/PQHybrid.* hardcodes ML-KEM-768/ML-DSA-65 (PQHybrid.cpp static_asserts OQS_KEM_ml_kem_768/OQS_SIG_ml_dsa_65), so hybrid and pqconly currently select identical material, while SPEC §C claims ML-KEM-1024/ML-DSA-87 for pqconly. Parameterize the preset (key sizes, ct len, _pendingHybridCt buffer, HELLO guard) or drop the pqconly claim. Note a pqconly HELLO with a PQ identity is 3273B → 3 frags, well under the 10024B cap, so no clean-fail path is reachable today |
I.pqc.wire,I.pqc.cfg,V15 |
T1/T2 = prerequisites — CI must invoke backone-selftest before PQC gates mean anything. T1–T16 backlog (rebrand/CI/lint leftovers); T17–T25 = PQC phases 1–6 + the pqconly preset gap.
Not tracked (low value ?): one.cpp:256 port/token cleanup, MacKextEthernetTap.cpp:480 iface status, VLAN TODOs in NetBSDEthernetTap.cpp:468/MacKextEthernetTap.cpp:685, Phy.hpp:325 unix sock type comment.
§B (bug log)
| id | date | cause | fix |
|---|---|---|---|
| B1 | 2026-10-02 | Phase 5 (T21) advertised PQC capability in HELLO but never consumed it: Peer::_key was derived classically in the constructor and the hybrid ciphertext had no place in the HELLO/OK(HELLO) layout, so hybridEligible() was dead. |
Wire hybrid static-static KEM ct into HELLO (lowest-address side writes [moon count][moons][flag(1B)][ct 1088B], crypted with the classical key) and echo it in OK(HELLO); parser order matches Peer::sendHELLO/_doHELLO; Peer::ensurePendingHybridCt() lazily encapsulates on first capability sighting so the handshake completes in one round trip; direction guard sender == lowest address on both parses. Refuse to start when the on-disk identity cannot satisfy settings.pqcMode (classic↔PQ migration changes the address). |
| B2 | 2026-10-02 | settings.pqcMode absent (or unrecognized) resolved to HYBRID, and Node's pqcMode default was HYBRID with no way to tell "unconfigured" from "configured hybrid". Every existing type-0 install would hit the B1 refusal guard at startup; the C API ZT_Node_new path would silently generate type-1 identities. |
Absent/unknown settings.pqcMode now resolves to ZT_PQC_MODE_CLASSIC (PQC is opt-in, matching the research doc); Node(…,int pqcMode = ZT_PQC_MODE_CLASSIC). Verified: classic identity + no config boots on 2.0.0 and stays 141 B; fresh node w/o config makes type-0; fresh node with "hybrid" makes type-1 (6415 B); classic + explicit "off" boots; classic + explicit "hybrid" still refuses; unknown value warns and runs classic. |