# 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-format` LLVM/Stroustrup, indent 4. No reformat of untouched lines. - New `.cpp/.hpp/.h` gets 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 only `Phy::whack()`. - New compiled file → register in `objects.mk` or no link. - Platforms: Linux (primary), macOS, BSD/NetBSD, Windows (msbuild). - Header edits: no `-MMD` dep tracking → `make clean` after touching `.h`. - PQC lock: liboqs static vendored `ext/`; hybrid key = KDF(classical ‖ pq); presets `hybrid` (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.h` changes 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 (check `node/Packet.*`) - `?` liboqs version/tag + build flags for `ext/` - `?` 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/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 | . | ~~Lint gate~~ done: `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. |