Skip to content

feat(license): VPP license delivery and enforcement hardening for runtime-v4 - #169

Merged
marconetsf merged 10 commits into
developmentfrom
feat/vpp-license-delivery
Aug 21, 2026
Merged

marconetsf merged 10 commits into
developmentfrom
feat/vpp-license-delivery

Conversation

@marconetsf

Copy link
Copy Markdown
Contributor

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.py answers 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 the is_connected gate, so a device can be activated while the PLC is stopped (the chicken-and-egg of licensing before the first run).
  • 0x48 returns the hardware anchor (/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).
  • 0x49 validates the blob before touching the filesystem (size/magic/crc32, same status bytes as the bare-metal store) and writes atomically (tmp + fsync + 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).
  • Bundle delivery: apply_vpp_plugin_conf installs conf/<plugin>.license shipped inside an upload.
  • Path containment for everything an upload can name (realpath + commonpath inside the runtime root; symlink-safe), and one shared license-path derivation that mirrors the plugin C exactly.

Package signature verification before compile

  • A licensed VPP ships closed objects and a link-only Makefile that compile.sh runs as root — and nothing proved where those bytes came from: a 3-line license_gate.c stub in the upload rebuilt license_gate.o and ran FULL (measured, not assumed). Now: Ed25519 verification of the package's signature.json over the canonical payload before anything is installed; a verification seal whose tree digest compile.sh re-checks before running the uploaded Makefile; and vpp_plugin_seal.c re-checking the .so's sha256 immediately before dlopen. 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

  • Every debug command re-runs the full JWT pipeline (expiry, revocation, user lookup) instead of trusting the connect-time check; the license FCs additionally require admin — a plain user can still debug variables but cannot read the anchor or overwrite a license.

Cross-language contract tests

  • Anchor normalization pinned against the REAL C: the test compiles license-core/src/license_platform.c unmodified (through its own LIC_LINUX_ANCHOR_PATH seam) and compares byte-for-byte with the Python across an edge-case table. Rebase note: the canonical C moved from rpi_plugin.c into 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_DIR overrides).
  • The python/bash tree-digest pair (vpp_package_signature.py × compile.sh) pinned by executing the real bash functions, with mutation checks.
  • The blob round-trip asserted against the signed golden blob from license-core — an artifact another implementation produced.

Rebase

Rebased today onto development (v4.1.10 + run/stop + user-management), clean — zero conflicts; only the test repoint above was needed.

Verification

  • Full runtime pytest on the rebased branch: 161 passed (modbus_master/slave and opcua dirs skipped locally — optional deps absent from the venv, untouched by this branch).
  • VPP-focused suites: delivery, debug FCs, signature, tree digest, anchor cross-language, websocket auth — 92 passed.
  • Known limitation, documented in-code: the license FCs act on the FIRST plugin with a config_path in vpp_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.
  • Real-device (Pi) bench run still pending — everything above is pinned by tests, not by hardware.

🤖 Generated with Claude Code

https://claude.ai/code/session_015uUH3ZL5ehreMUf2dtanWD

marconetsf and others added 9 commits August 19, 2026 20:25
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 thiagoralves left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread webserver/debug_websocket.py
Comment thread webserver/vpp_license_debug.py Outdated
Comment thread scripts/compile.sh
Comment thread scripts/compile.sh Outdated
Comment thread core/src/drivers/plugin_config.c
…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
@marconetsf

Copy link
Copy Markdown
Contributor Author

All five findings addressed in c96b374 (commit message maps each): R1 gains the reauth event + distinguishable token_expired (editor half in openplc-editor#1023 349919137: candidate reads the token manager at create(), held channel gets renewals pushed); R2 answers LIC_UNSUPPORTED for an unreadable anchor; R3 seals only what this run compiled and forces a recompile when the seal cannot vouch for cached objects (your suggested variant still blessed unknown bytes in the no-seal upgrade case — rebuilding from the just-verified tree closes it fully); R4 propagates hashing failures and strips sha256sum's escape marker (empty tree = empty input, matching python); R5 rejects absolute paths webserver-side, mirroring the C for real. Full pytest 161 green.

@marconetsf
marconetsf merged commit f7dce88 into development Aug 21, 2026
@thiagoralves
thiagoralves deleted the feat/vpp-license-delivery branch August 27, 2026 13:14
gg2cc pushed a commit to gg2cc/openplc-runtime that referenced this pull request Sep 3, 2026
…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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants