feat(license): VPP license delivery and enforcement hardening for runtime-v4 - #169
Conversation
Checkpoint of Sub-fase B (vpp licensing): apply_vpp_plugin_conf copies conf/<plugin>.license alongside the installed plugin config (bundle delivery). New webserver/vpp_license_debug.py answers 0x48/0x49/0x4A at the Python level in debug_websocket.py, ahead of the is_connected gate, mirroring the same wire contract the Arduino firmware already speaks. Includes a path traversal guard on license writes and pytest coverage for path parity, delivery, and non-resurrection. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…ivation apply_vpp_plugin_conf's guard only checked os.path.abspath(dest_config) .startswith(runtime_root) -- a sibling directory that merely shares the root as a string prefix (e.g. runtime_root "/opt/runtime" vs. an escaping "/opt/runtime-evil/x") would wrongly pass. Anchor on runtime_root + os.sep instead, mirroring the guard vpp_license_debug.py's 0x49 handler already had. Extracted derive_license_path/resolve_license_path into vpp_license_debug.py so both the bundle-delivery path (apply_vpp_plugin_conf) and the debug-channel path (0x49) share one derivation instead of two independently-maintained copies. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…erivation Follow-up to ec995c2, from its code review. - os.path.abspath normalises lexically and does NOT follow symlinks, so the guard did not deliver what its docstring promised: any link out of the tree (a deploy link, a volume mounted under build/) let an innocent-looking relative config_path resolve outside the runtime root while still reporting success. Verified with a directory junction: abspath reports the target inside the root, realpath resolves it outside. Both write paths now share one is_inside_root() helper built on realpath + commonpath. commonpath also fixes a fail-closed edge: a root of "/" made the separator-anchored check compare against "//", which nothing starts with, silently refusing every license write for a process whose cwd is /. - Apply the same helper to safe_extract's guard, which still had the exact prefix bug ec995c2 fixed 170 lines below it. analyze_zip already rejects ".." before that point, so this is defence in depth -- but it is the same bug class in the same file. - derive_license_path now mirrors rpi_plugin.c exactly. The C strips the extension only when len > strlen(".json"), so a config_path of literally ".json" keeps it; endswith stripped it, and the runtime would have written a file the .so never reads. Empty in, empty out, as the C does; an empty derivation is refused rather than resolved against the cwd. - Regression tests for the guard. The existing traversal test used a sibling sharing no string prefix with the root, so the OLD buggy check rejected it too -- it could not tell the fixed guard from the broken one. The new cases pin what actually distinguishes them: the prefix sibling, the symlink escape, a not-yet-created nested path, and the "/" root. - The parity test now calls the real derive_license_path instead of a third hand-written copy of it, and covers the two inputs where C and Python had actually diverged. Comparing two transcriptions would stay green while the shipped function drifted.
…plugin A licensed VPP ships the closed enforcement objects (license_core.o, license_gate.o) and a link-only Makefile inside the user's upload, and scripts/compile.sh runs that Makefile as root. Nothing in that path proved where those bytes came from: adding a three-line license_gate.c that always answers "licensed" and letting the uploaded Makefile rebuild license_gate.o from it was enough to run a licensed VPP in full mode. Measured, not assumed -- against the pre-change code the stub compiles and links, and compile.sh reports success. openplc-packages already signs every .vpp with Ed25519 and emits a signature.json holding a sha256 per packaged file; the editor already carries the matching public key. The machinery existed and simply was not wired end to end. This wires it: - webserver/vpp_package_signature.py verifies the Ed25519 signature over the WHOLE canonical payload, then compares hashes only for the files that actually travelled. A filtered slice is not what was signed and could never verify. Canonicalization mirrors openplc-packages/scripts/lib/package-signing.ts byte for byte; the risk that it does not is pinned by a known-answer vector copied out of a real signed .vpp, which no self-signed fixture could catch. - webserver/app.py gates the upload between safe_extract and apply_vpp_plugin_conf. The order is the point: a refused plugin never gets a vpp_plugins.conf installed, never gets its config or license blob copied into the runtime root, and never reaches make. - scripts/compile.sh refuses to run the uploaded Makefile unless a verification seal is present AND its tree digest still matches the tree on disk. That covers a direct invocation of the script and the window between the gate and make. checksum.sha256 is documented as the recompilation cache it always was; it is self-attestation and must never be read as integrity again. - core/src/drivers/vpp_plugin_seal.c re-checks the .so's sha256 immediately before dlopen. The upload gate can only speak for the plugin's inputs -- the object is linked on the device after the package was signed -- so without this an object dropped into build/vpp/ after the compile would load with no provenance at all. - Containment for the path that actually gets dlopen'ed. vpp_plugins.conf arrives verbatim from the upload and only config_path was ever validated, so a forged conf could name any .so on the box. plcapp_management.py now requires every VPP plugin path to resolve inside build/vpp/, and plugin_config.c rejects absolute and ".." paths in the upload-supplied config so containment does not rest on Python alone. Policy for unsigned uploads lives in exactly one function, vpp_package_signature.signature_required(): an upload carrying vpp_plugin/ must be signed, a plain PLC program is untouched. Existing users and editors built before the sidecar existed keep working. Scope, written down so it is not rediscovered: this closes the licensing bypass, not the RCE. Arbitrary native code as root via core/generated/*.cpp is untouched and is a privilege question, not a signature one. The verifier also lives inside a runtime the owner can recompile, so this raises the cost of the cheap attack rather than forming a cryptographic barrier. Refs proposal #38 (option C plus the path containment of 1.7), security-audit-2026-07-28.md finding 1.
…uage test webserver/vpp_package_signature.py's tree_digest() and scripts/compile.sh's vpp_tree_digest() must agree byte for byte: compile.sh recomputes the digest from the seal python wrote and refuses to build any VPP upload where the two disagree. That agreement was checked by hand once and never fixed by test, so any future edit to either side's sort order, hash line format, or encoding could silently break every legitimate VPP upload in the field. Add tests/pytest/plugins/test_vpp_tree_digest_cross_language.py, which extracts the real sha256_hex/vpp_tree_digest function bodies out of scripts/compile.sh by source text (rather than reimplementing them) and runs them under bash against the same directory tree_digest() hashes in-process, covering nested directories, mixed case, hyphen/underscore collisions, and ASCII-vs-natural sort ordering. Verified this catches real divergence: reversing the bash sort order, and separately narrowing the python line separator to one space, both turned the new test red; reverting either restores green.
…tomically 0x4A tested the LENGTH only, so a 98-byte file that does not verify -- an SD card cloned from another Pi, corrupted flash, a torn write -- answered SUCCESS. The editor reads that as "magic + crc32 verified", reports Licensed and returns before asking the backend for a fresh license, so the one automatic repair path never runs precisely because the editor trusts the blob, while license_core refuses it and the plugin drops to demo and stops actuating 15 minutes later. The same file on an ESP32 answers 0x83/0x84 and the editor recovers by itself. Emit the same status bytes the bare-metal store already emits, with the same checks in the same order (license_store.h, license_store_esp32.cpp): wrong size -> 0x84, magic absent -> 0x83, crc mismatch -> 0x84. No new ABI: the editor already treats corrupt/empty as "recover this device". zlib.crc32 IS CRC-32/ISO-HDLC from the stdlib, so there is no second derivation to drift. 0x49 gets the same function, before it touches the filesystem: a blob that would read back as EMPTY/CORRUPT must not replace one that is already there. Signature and device binding stay where they belong -- license_core is the only verifier; the runtime only transports. Also on 0x49: tmp + fsync + os.replace. open(path, "wb") truncates before writing, so ENOSPC or a power cut mid-activation destroyed the PREVIOUS, VALID license. An activation that fails must not leave the device worse off. Anchor normalization follows the C, which is canonical because it is the side that decides whether the license verifies: stop stripping TAB (rpi_plugin.c never did, while both comments claimed byte-identity -- an anchor ending in 0x09 derived a different deviceId on each side, so the purchased license simply never worked), and refuse an anchor above the 64-byte ceiling the C reads instead of framing up to 255 bytes that derive an identity the verifier cannot reproduce. Filesystem failures map to the status bytes the store already defines (IO_ERROR -> 0x82, TOO_LARGE -> 0x81) instead of raising -- a read-only SD card is the number-one failure mode of an industrial Pi, and an exception here reached the editor as a transport error indistinguishable from a dropped link, against this function's own "never raises" contract. Document the multi-plugin behaviour truthfully and WARN on more than one candidate. Saying "multi-plugin is unsupported" would be false: candidates filters on having a config_path, NOT on being licensable, so a single licensable VPP is enough for this to bite -- one free VPP ahead of it in vpp_plugins.conf and the license lands on the free plugin's sibling. Blob validation does not catch it either: the blob is valid, just in the wrong file. Tests: the round-trip test used to hand 0x49 98 bytes with a deliberately WRONG crc and assert SUCCESS -- it pinned the defect in place instead of catching it. It now uses the real signed golden blob from license-core/test, so the runtime is asserted against an artifact another implementation produced. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…nscription The anchor is the pre-image of the licensing identity: the editor hashes the bytes 0x48 returns into the deviceId a license is signed for, and rpi_plugin.c hashes its own normalization of the same file into what license_core compares against. Both sides carried a comment asserting byte-identity with the other and neither had it. This does not re-implement the C in Python and compare that to itself, which is the weakness the delivery test admits to in its own docstring. It extracts the REAL strip set, the REAL anchor[64] declaration, the REAL read_file_bytes and the REAL normalization loop out of rpi_plugin.c by source text, then (a) asserts the Python constants equal the C ones -- no compiler needed, so a TAB creeping back into the strip set fails anywhere -- and (b) compiles the extracted C unmodified and compares its output byte for byte with _read_anchor() over an edge-case table: the measured Pi 5 shape, trailing TAB, a TAB behind a space, mixed NUL/CR/LF/space tails, interior NULs, degenerate tails, exactly-at-ceiling, and padding past the ceiling. Same approach as the tree-digest test. The one case where the two CANNOT agree is asserted as a refusal: above the C's 64-byte buffer the C truncates, so nothing goes on the wire at all. The C source lives in the sibling openplc-packages repository, so this file skips on a runtime-only checkout (OPENPLC_PACKAGES_DIR overrides the lookup). That is a real gap of the same kind as the symlink case, and the mirror of this test belongs in openplc-packages, where the source is always present. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…e FCs on role The debug channel is a trust boundary: whoever reaches it reads the hardware anchor and writes the license blob. 0x48 hands out the pre-image the licensing identity and the possession key are derived from, offline and forever -- the anchor does not rotate, so revoking an account afterwards takes nothing back. Two things did not match that status. The JWT was verified only on connect. An open socket kept answering commands after its token expired (15 minutes by default; the config sets no JWT_ACCESS_TOKEN_EXPIRES) and after /logout, because the blacklist is only consulted inside verify_jwt_in_request. The token is now captured per connection and the full pipeline -- signature, expiry, revocation, user lookup -- runs on every command. It costs an HMAC and a set lookup. The license FCs were @jwt_required() with no role check, so a plain `user` account could read the anchor of any board and overwrite its license. They now require admin, through the role mechanism the REST API already has (the User model's is_admin(), resolved by the JWT user_lookup_loader). The gate is scoped to 0x48/0x49/0x4A: a `user` can still debug variables. Not in scope, and deliberately still open: the bare-metal side of the same channel has no authentication at all, and authenticating Modbus TCP is a protocol project rather than a fix. Tests: nothing touched this file before -- the whole suite stayed green with the guard removed. Four properties are pinned now, each verified by mutation: connect requires a valid token, every command is re-authenticated (revoking mid -session cuts access off), the license FCs require admin, and the D70a ordering invariant that they resolve BEFORE the is_connected gate, which is what lets a device be activated while the PLC is stopped. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…DR-0003) The canonical C this test pins moved while the branch sat unmerged: the anchor read + strip loop left rpi_plugin.c::license_gate_bringup and now lives verbatim in the closed core's license_platform.c (__linux__ branch), with the buffer ceiling as LIC_ANCHOR_MAX in license_platform.h. The old extraction patterns matched nothing real anymore, which is exactly the failure mode the test warns about in its own docstring. Repointing it made it STRONGER: license_platform.c ships a compile-time override seam for its anchor path (LIC_LINUX_ANCHOR_PATH, put there for the packages host tests), so instead of regex-extracting code blocks into a harness, the test now compiles the REAL translation unit unmodified and drives it through that seam. The constants checks read the strip set out of the .c and the ceiling out of the .h by source text, as before. Same parity table, same refusal case; 16/16 locally, and the whole runtime suite (161) green on the rebase. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015uUH3ZL5ehreMUf2dtanWD
thiagoralves
left a comment
There was a problem hiding this comment.
Correctness review of the runtime half, read together with openplc-editor#1023 and openplc-web#681. Five findings below, ordered roughly by impact: the per-command JWT re-verification has no renewal path (breaks debugging and licensing 15 minutes after login), the empty-anchor answer feeds the editor an identity no device reproduces, and the cache-hit re-seal undoes the loader seal's stated purpose. The signature-gate design itself (Ed25519 over the canonical payload, both directions of the present/missing file comparison, the atomic license write, and the is_inside_root unification) reads correct to me.
…that seals Review 2026-08-20 (Thiago, read across #169/#1023/#681), findings R1-R5: - R1: expiry is now distinguishable (token_expired) and renewable: a 'reauth' event runs the fresh token through the FULL verification pipeline and swaps the session token — the per-command re-verify stops being a 15-minute fuse on every debug/licensing session. Editor half: openplc-editor 349919137 (candidate reads the token manager at create(); held channel gets reauth pushed on refresh). - R2: an unreadable anchor answers LIC_UNSUPPORTED, never SUCCESS/len=0 — every anchor-less host used to derive the SAME deviceId and a purchase bound to it could never validate on the .so. - R3: the object seal is written ONLY for objects this run compiled; a checksum cache-hit stands only when the existing seal vouches for the .so on disk, otherwise the tree (just signature-verified) is recompiled — a re-upload can no longer bless a swapped object, and an upgraded runtime with no seal rebuilds instead of blessing unknown bytes. - R4: vpp_tree_digest propagates hashing failures (pipefail; the old '|| exit 1' exited a pipeline-stage subshell and produced a digest of a PARTIAL listing reported as tampering) and sha256_hex strips the escape marker GNU sha256sum prefixes for exotic filenames. Empty tree hashes as empty input, matching the python side. - R5: the webserver now rejects absolute plugin paths like the C loader always did — a contained-absolute conf was accepted at upload and silently dropped at parse, the plugin never loading with no error anywhere. Full pytest 161 passed (delivery fixture moved to the relative path the editor actually emits; empty-anchor expectation flipped to 0x85). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015uUH3ZL5ehreMUf2dtanWD
|
All five findings addressed in |
…st admin The license function codes (0x48 anchor read, 0x49 blob write, 0x4A read back) were admin-gated in Autonomy-Logic#169 on the theory that they were a trust boundary. That gate protected the wrong thing: the purchase is authorized by the Edge account on the /buy page, never by the runtime role, so requiring admin here only stopped an operator from activating a licence they had already paid for (hit in a live bench test 2026-08-25). What opens is low-risk: the anchor is the board's serial (baremetal exposes it with no auth at all), the blob is node-locked and useless on another device, and a bad write is recoverable (the entitlement lives in the backend; a refresh rewrites the correct blob). JWT re-verification still runs on every command, so "any role" means any logged-in user, never anonymous. - debug_websocket.py: drop the _current_user_is_admin gate on the license FCs, plus the now-dead helper and its current_user import. - test_debug_websocket_auth.py: the parametrized test now proves a user role runs 0x48/0x49/0x4A and that "Admin privileges required" never returns. Suite: 45 passed (restapi + plugins license tests). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015uUH3ZL5ehreMUf2dtanWD
Why
This is the runtime half of licensing VPPs on runtime-v4 targets. The editor half is open as Autonomy-Logic/openplc-editor#1023 (mirror: Autonomy-Logic/openplc-web#681); the closed license-core and the key model live in openplc-packages (ADR-0004, merged). Without this branch, a paid runtime-v4 VPP compiles and uploads but can never be activated: it runs its 2-hour demo forever.
What (9 commits, in dependency order)
License transport (0x48/0x49/0x4A) at the webserver level
webserver/vpp_license_debug.pyanswers the same Modbus PDUs the Arduino firmware speaks — byte-identical wire contract, so the editor has ONE licensing flow on every medium — resolved ahead of theis_connectedgate, so a device can be activated while the PLC is stopped (the chicken-and-egg of licensing before the first run)./proc/device-tree/serial-number), normalized EXACTLY as the closed core's C does (trailing NUL/LF/CR/space; >64 bytes refused, never truncated onto the wire).os.replace) — a power cut mid-activation can no longer destroy the previous valid license. 0x4A validates on read too, so a cloned/torn 98-byte file reports CORRUPT instead of SUCCESS (which used to suppress the editor's one automatic repair path).apply_vpp_plugin_confinstallsconf/<plugin>.licenseshipped inside an upload.Package signature verification before compile
compile.shruns as root — and nothing proved where those bytes came from: a 3-linelicense_gate.cstub in the upload rebuiltlicense_gate.oand ran FULL (measured, not assumed). Now: Ed25519 verification of the package'ssignature.jsonover the canonical payload before anything is installed; a verification seal whose tree digestcompile.shre-checks before running the uploaded Makefile; andvpp_plugin_seal.cre-checking the.so's sha256 immediately beforedlopen. Unsigned plain PLC programs are untouched. Scope: this closes the licensing bypass, not the pre-existing native-code-as-root surface (a privilege question, documented in the commit).Debug channel as a trust boundary
Cross-language contract tests
license-core/src/license_platform.cunmodified (through its ownLIC_LINUX_ANCHOR_PATHseam) and compares byte-for-byte with the Python across an edge-case table. Rebase note: the canonical C moved fromrpi_plugin.cinto the closed core (ADR-0003) while this branch sat unmerged — the final commit follows it, and the repoint made the test stronger (whole TU, zero transcription). Skips on a runtime-only checkout (OPENPLC_PACKAGES_DIRoverrides).vpp_package_signature.py×compile.sh) pinned by executing the real bash functions, with mutation checks.Rebase
Rebased today onto
development(v4.1.10 + run/stop + user-management), clean — zero conflicts; only the test repoint above was needed.Verification
config_pathinvpp_plugins.conf(the PDU carries no plugin id). A free VPP installed ahead of the licensed one would receive the blob — WARNed at runtime; install the licensable VPP alone or first.🤖 Generated with Claude Code
https://claude.ai/code/session_015uUH3ZL5ehreMUf2dtanWD