Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,7 @@ jobs:
- name: shellcheck
run: |
sudo apt-get update -qq && sudo apt-get install -y -qq shellcheck
shellcheck -S warning install.sh scripts/install-docker.sh \
shellcheck -S warning install.sh scripts/install-docker.sh scripts/hardware-id.sh \
windows/provision-msys2.sh tests/integration/harness.sh

# The Windows installer ships a compiled runtime inside an MSYS2 tree, so
Expand Down
2 changes: 2 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,8 @@ State management: `core/src/plc_app/plc_state_manager.cpp`
the WebSocket side is `webserver/debug_websocket.py`
- **Watchdog**: `core/src/plc_app/utils/watchdog.c` - health monitoring
- **Image tables**: `core/src/plc_app/image_tables.cpp` - I/O buffer management
- **Hardware ID v2**: `scripts/hardware-id.sh` (canonical bash reader; installers embed its marked
block verbatim), `webserver/hardware_id.py`, `GET /api/hardware-id`. Spec: `docs/HARDWARE_ID.md`

## Code Style

Expand Down
109 changes: 109 additions & 0 deletions docs/HARDWARE_ID.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
# Hardware ID v2

The hardware ID identifies the physical machine an OpenPLC runtime licence was bought for, so a
lost licence can be recovered from the same hardware after a wipe or reinstall. Nothing is enforced
on the device: the ID only has to be the same every time it is computed on the same hardware.

The reader is `scripts/hardware-id.sh`, plain bash with no dependency beyond coreutils. The block
between its `BEGIN hardware-id v2` and `END hardware-id v2` markers is the canonical copy: the
orchestrator-agent and autonomy-node installers embed it verbatim, and their checks compare it byte
for byte with this repository.

Version 1 (`openplc-dev-v1`, used by VPP licences and debug function code 0x48) is unchanged.

## Format

```
hw2:<64 lowercase hex characters>
```

## Derivation

1. Read each element below. Every value has NUL bytes removed, surrounding whitespace trimmed and is
lower-cased.
2. Hash each element on its own: `sha256("openplc-hw-v2|<kind>|<value>")`, as lowercase hex.
3. Sort the element hashes, join them with a newline after each one, and hash the result:
`ID = "hw2:" + sha256(h1 "\n" h2 "\n" ... hn "\n")`.

| Kind | Source | Accepted when | Strong |
| --- | --- | --- | --- |
| `serial` | `/proc/device-tree/serial-number` | Not empty | Yes |
| `uuid` | `/sys/class/dmi/id/product_uuid` | Hex with dashes; not all `0`, not all `f`, not `03000200-0400-0500-0006-000700080009` | Yes |
| `board` | `/sys/class/dmi/id/board_serial` | Not empty, `to be filled by o.e.m.`, `default string`, `not specified` or `0123456789` | Yes |
| `mac` | `/sys/class/net/*/address`, one element per NIC | See NIC rules; never inside a VM | Yes |
| `cpu` | `/proc/cpuinfo`, first match of each field | x86: `<vendor_id>\|<model name>`; ARM: `<CPU implementer>\|<CPU part>` | No |

NIC rules, all of which must hold:

- the interface has a `device` link (rules out bridges, veth, tun, bonds and other virtual links);
- it is not wireless (no `wireless` or `phy80211` entry);
- `addr_assign_type` is `0` (factory address);
- the address is a well-formed MAC, not all zeros, with the locally administered bit clear;
- no directory on its device path has `removable` set to `removable`;
- it is not on USB, unless a USB port on its device path reports `connect_type` = `hardwired`.

Other rules:

- **No software state.** No element comes from the OS, kernel, architecture string, hostname or
installed packages. The same hardware gives the same ID under a 32-bit or 64-bit OS.
- **VMs.** When `/proc/cpuinfo` lists the `hypervisor` flag, or the DMI `sys_vendor` or
`product_name` names VMware, QEMU, KVM, VirtualBox (innotek), Xen, Amazon EC2, Google Compute
Engine, Parallels or Microsoft Corporation "Virtual Machine", NICs are left out.
- **Refused hosts.** When `/proc/sys/kernel/osrelease` or `/proc/version` mentions `microsoft`,
`wsl` or `linuxkit` (WSL2, Docker Desktop), no ID is produced.
- **No strong element, no ID.** The CPU alone never yields an ID.
- **Frozen.** Every rule and placeholder above is part of v2. Any change after licences are issued is
a new version, v3.

## Usage

```
scripts/hardware-id.sh # prints the ID; exit status 2 when none can be derived
scripts/hardware-id.sh --elements # prints "<kind> <element hash>" per element
scripts/hardware-id.sh --record # prints the record (below)
scripts/hardware-id.sh --store FILE # writes the record to FILE unless FILE already holds one
```

Run it as root. On x86 the DMI `product_uuid` and `board_serial` files are readable by root only,
so an unprivileged run sees only the CPU and derives no ID. Every installer runs as root.

`OPENPLC_HWID_ROOT` replaces `/` for every read. It exists for tests only.

## Record and storage

The record is the ID line followed by one `<kind> <element hash>` line per element. It is computed
once, at installation, and never replaced: `--store` (`hw2_store` in the embedded block) leaves an
existing record alone, so a reinstall on the same hardware keeps it and a wiped device recomputes
the same one.

| Install | Who computes it | Record |
| --- | --- | --- |
| Native (`install.sh --native`) | `install.sh` | `/var/lib/openplc-runtime/hardware-id` |
| Container (`scripts/install-docker.sh`) | `install-docker.sh`, on the host | `<data dir>/hardware-id`, default `/var/lib/openplc-runtime/hardware-id` |
| vPLC | orchestrator-agent's installer, on the host | `/etc/openplc-runtime/hardware-id` inside the container, read-only |

A runtime installed before the record existed computes it on start when none is stored, but only
where it sees the host hardware: natively, or in the bootloader's privileged container. A vPLC never
computes one.

The runtime advertises the record on `GET /api/hardware-id` (any authenticated user):

```json
{"hardwareId": "hw2:…", "source": "device", "elements": [{"kind": "serial", "hash": "…"}]}
```

`source` is `orchestrator` in a vPLC. Without an ID, `hardwareId` and `source` are null and
`elements` is empty.

## Test vectors

`tests/pytest/hardware_id/vectors.json` holds fake hardware trees with their element values and the
expected ID. The tests check the reader against them and recompute every hash independently in Python.

| Vector | Elements | ID |
| --- | --- | --- |
| `raspberry-pi` | serial, mac, cpu | `hw2:363598c3230281d8cc2cfcdd1b772a6957aa6b8fbd631712ba1b195be5d22318` |
| `x86-industrial-pc` | uuid, board, 2 mac, cpu | `hw2:5337b315679912a71e5f004a718544e1282e004f13acb0ac17c4c8142587a14c` |
| `vm` | uuid, cpu | `hw2:5e26377d20855444f09a7b40f13d8c4e00550166e98efbc6d1d01c05b51a5eb1` |
| `placeholders-only` | cpu | none |
| `wsl2` | none | none |
5 changes: 5 additions & 0 deletions install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -641,6 +641,11 @@ else
mkdir -p /var/lib/openplc-runtime
chmod 755 /var/lib/openplc-runtime
log_info "Created persistent data directory at /var/lib/openplc-runtime"
if bash "$OPENPLC_DIR/scripts/hardware-id.sh" --store /var/lib/openplc-runtime/hardware-id; then
log_info "Hardware ID: $(head -n 1 /var/lib/openplc-runtime/hardware-id)"
else
log_warning "No hardware ID can be derived on this device; runtime licences cannot be bound to it."
fi
fi
fi

Expand Down
214 changes: 214 additions & 0 deletions scripts/hardware-id.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,214 @@
#!/usr/bin/env bash
# SPDX-License-Identifier: GPL-3.0-only
# Copyright (c) 2026 Autonomy®

# Hardware ID v2 reader (spec: docs/HARDWARE_ID.md). Canonical copy: installers embed the
# block between the BEGIN/END markers verbatim, and their parity checks compare it byte for byte.
#
# Usage: hardware-id.sh print the ID (hw2:<64 hex>), exit 2 when none can be derived
# hardware-id.sh --elements print "<kind> <element hash>" lines
# hardware-id.sh --record print the ID line followed by the element lines
# hardware-id.sh --store FILE write the record to FILE unless FILE already holds one
# OPENPLC_HWID_ROOT replaces / for every read (tests only).

# BEGIN hardware-id v2
hw2_root() { printf '%s' "${OPENPLC_HWID_ROOT:-}"; }

hw2_sha256() {
if command -v sha256sum >/dev/null 2>&1; then
sha256sum | cut -d' ' -f1
else
shasum -a 256 | cut -d' ' -f1
fi
}

hw2_lower() { tr '[:upper:]' '[:lower:]'; }

# Reads a file, drops NUL bytes, trims surrounding whitespace and lower-cases it.
hw2_read() {
[ -r "$1" ] || return 1
tr -d '\000' <"$1" | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//' | hw2_lower
}

hw2_cpuinfo_field() {
sed -n "s/^$1[[:space:]]*:[[:space:]]*//p" "$(hw2_root)/proc/cpuinfo" 2>/dev/null |
head -n 1 | sed 's/[[:space:]]*$//' | hw2_lower
}

hw2_refused_host() {
local r f
r="$(hw2_root)"
for f in "$r/proc/sys/kernel/osrelease" "$r/proc/version"; do
if [ -r "$f" ] && grep -qi -e microsoft -e wsl -e linuxkit "$f"; then
return 0
fi
done
return 1
}

hw2_is_vm() {
local r vendor product
r="$(hw2_root)"
if grep -qiw hypervisor "$r/proc/cpuinfo" 2>/dev/null; then
return 0
fi
vendor="$(hw2_read "$r/sys/class/dmi/id/sys_vendor")"
product="$(hw2_read "$r/sys/class/dmi/id/product_name")"
case "$vendor" in
*vmware* | *qemu* | *kvm* | *innotek* | *virtualbox* | *xen* | *"amazon ec2"* | *google* | *parallels*) return 0 ;;
esac
case "$product" in
*vmware* | *qemu* | *kvm* | *virtualbox* | *xen* | *"google compute engine"* | *parallels*) return 0 ;;
esac
if [ "$vendor" = "microsoft corporation" ] && [ "$product" = "virtual machine" ]; then
return 0
fi
return 1
}

hw2_uuid_ok() {
local bare
bare="$(printf '%s' "$1" | tr -d '-')"
case "$bare" in
"" | *[!0-9a-f]*) return 1 ;;
esac
[ -n "$(printf '%s' "$bare" | tr -d '0')" ] || return 1
[ -n "$(printf '%s' "$bare" | tr -d 'f')" ] || return 1
[ "$1" != "03000200-0400-0500-0006-000700080009" ]
}

hw2_board_serial_ok() {
case "$1" in
"" | "to be filled by o.e.m." | "default string" | "not specified" | "0123456789") return 1 ;;
esac
return 0
}

# Succeeds when the NIC's device path or an ancestor is marked removable, or sits on USB
# without a hardwired port.
hw2_nic_detachable() {
local dev on_usb hardwired
dev="$(cd -P "$1/device" 2>/dev/null && pwd)" || return 0
on_usb=0
hardwired=0
while [ -n "$dev" ] && [ "$dev" != "/" ] && [ "$dev" != "$(hw2_root)" ]; do
if [ "$(hw2_read "$dev/removable")" = "removable" ]; then
return 0
fi
case "${dev##*/}" in
usb[0-9]*) on_usb=1 ;;
esac
if [ "$(hw2_read "$dev/port/connect_type")" = "hardwired" ]; then
hardwired=1
fi
dev="${dev%/*}"
done
[ "$on_usb" -eq 1 ] && [ "$hardwired" -eq 0 ]
}

hw2_macs() {
local r nic mac first
r="$(hw2_root)"
for nic in "$r"/sys/class/net/*; do
[ -e "$nic/device" ] || continue
[ -e "$nic/wireless" ] && continue
[ -e "$nic/phy80211" ] && continue
[ "$(hw2_read "$nic/addr_assign_type")" = "0" ] || continue
mac="$(hw2_read "$nic/address")"
case "$mac" in
[0-9a-f][0-9a-f]:[0-9a-f][0-9a-f]:[0-9a-f][0-9a-f]:[0-9a-f][0-9a-f]:[0-9a-f][0-9a-f]:[0-9a-f][0-9a-f]) ;;
*) continue ;;
esac
[ "$mac" = "00:00:00:00:00:00" ] && continue
first=$((16#${mac%%:*}))
[ $((first & 2)) -eq 0 ] || continue
hw2_nic_detachable "$nic" && continue
printf '%s\n' "$mac"
done | sort -u
}

hw2_element() { printf '%s %s\n' "$1" "$(printf 'openplc-hw-v2|%s|%s' "$1" "$2" | hw2_sha256)"; }

# Prints "<kind> <hash>" per element; returns 1 when the host is refused or has no strong element.
hw2_elements() {
local r v strong=0 mac vendor model
r="$(hw2_root)"
if hw2_refused_host; then
return 1
fi
v="$(hw2_read "$r/proc/device-tree/serial-number")"
if [ -n "$v" ]; then
hw2_element serial "$v"
strong=1
fi
v="$(hw2_read "$r/sys/class/dmi/id/product_uuid")"
if hw2_uuid_ok "$v"; then
hw2_element uuid "$v"
strong=1
fi
v="$(hw2_read "$r/sys/class/dmi/id/board_serial")"
if hw2_board_serial_ok "$v"; then
hw2_element board "$v"
strong=1
fi
if ! hw2_is_vm; then
for mac in $(hw2_macs); do
hw2_element mac "$mac"
strong=1
done
fi
vendor="$(hw2_cpuinfo_field vendor_id)"
model="$(hw2_cpuinfo_field 'model name')"
if [ -z "$vendor" ]; then
vendor="$(hw2_cpuinfo_field 'CPU implementer')"
model="$(hw2_cpuinfo_field 'CPU part')"
fi
if [ -n "$vendor$model" ]; then
hw2_element cpu "$vendor|$model"
fi
[ "$strong" -eq 1 ]
}

hw2_id_of() { printf 'hw2:%s\n' "$(printf '%s\n' "$1" | cut -d' ' -f2 | sort | hw2_sha256)"; }

# Prints hw2:<64 hex>; returns 1 when no ID can be derived.
hw2_id() {
local elements
elements="$(hw2_elements)" || return 1
hw2_id_of "$elements"
}

# Prints the ID line followed by the element lines; returns 1 when no ID can be derived.
hw2_record() {
local elements
elements="$(hw2_elements)" || return 1
hw2_id_of "$elements"
printf '%s\n' "$elements"
}

# Writes the record to $1 atomically; an existing record is never replaced.
hw2_store() {
local tmp
[ -s "$1" ] && return 0
tmp="$1.tmp.$$"
if hw2_record >"$tmp"; then
chmod 644 "$tmp" && mv -f "$tmp" "$1"
else
rm -f "$tmp"
return 1
fi
}
# END hardware-id v2

if [ "${BASH_SOURCE[0]}" = "$0" ]; then
set -u
case "${1:-}" in
--elements) hw2_elements || exit 2 ;;
--record) hw2_record || exit 2 ;;
--store)
[ -n "${2:-}" ] || { echo "usage: $0 --store FILE" >&2; exit 64; }
hw2_store "$2" || exit 2
;;
*) hw2_id || exit 2 ;;
esac
fi
Loading
Loading