Auto: focas-f4d — password / unlock parameter

Closes #271
This commit is contained in:
Joseph Doherty
2026-04-26 05:45:13 -04:00
parent d676b4056d
commit 86f3fc2733
16 changed files with 1016 additions and 40 deletions

View File

@@ -183,3 +183,108 @@ granular kill switches are lightweight runtime toggles, not config-DB
redeploys. PMC in particular should default OFF in production and only
flip on for windows where the ladder team has signed off on the write
path.
## FOCAS password handling — issue #271 (F4-d)
Some controllers (16i + certain 30i firmwares with parameter-protect on)
gate `cnc_wrparam` and selected reads behind a connection-level password.
The driver supports this via the `Password` field on `FocasDeviceOptions`
which is emitted via `cnc_wrunlockparam` on connect and re-emitted on any
`EW_PASSWD` read/write retry path. See
[`docs/drivers/FOCAS.md`](../drivers/FOCAS.md) § "FOCAS password" for the
driver-side behaviour; this section covers the deployment side.
### Storage in `appsettings.json`
```jsonc
{
"Drivers": {
"Focas01": {
"DriverConfigJson": {
"Backend": "fwlib",
"Series": "Sixteen_i",
"Devices": [
{
"HostAddress": "focas://10.0.0.5:8193",
"Password": "1234"
}
]
}
}
}
}
```
For dev environments, the password is materialised under
`.local/focas-passwords.txt` (or whichever .local subkey the deployment
team prefers); production deployments use the same secrets-store /
KeyVault pattern the LDAP `Authentication.Ldap.Password` field follows.
**The `.local/` directory is .gitignore'd** — this is the same posture
as `.local/galaxy-host-secret.txt` and other dev secrets in this repo.
### No-log invariant
The driver guarantees the password is **never logged**:
1. **`FocasDeviceOptions` ToString redaction.** The record overrides
`PrintMembers` so any Serilog destructure of the device options renders
`Password = ***` when the field is non-null. This catches the most
common leak path — a structured-log statement that included
`{@Device}` for diagnostic context.
2. **No password in exception messages.** `FwlibFocasClient.UnlockAsync`
omits the password from its `InvalidOperationException` text — only
the FWLIB error code (`EW_PASSWD`, `EW_HANDLE`, etc.) makes it through.
3. **Driver log line uses host only.** When unlock succeeds the driver
updates `DriverHealth.StatusText` to `"FOCAS unlock applied for
{host}"` — no password.
4. **CLI flag covered by the same choke point.** The
`Driver.FOCAS.Cli --cnc-password` flag flows through
`FocasDeviceOptions.Password`, so its redaction is identical to the
server's. The PowerShell e2e harness (`scripts/e2e/test-focas.ps1
-CncPassword`) follows the same path.
Any new logging surface that touches `FocasDeviceOptions` MUST continue
to use the record's `ToString` (or otherwise omit `Password`). A code
review checklist item: "no log statement contains `device.Options.Password`
or `device.Password` directly."
### Password-rotation runbook
When the CNC password rotates (operator team flipped a parameter-protect
gate, or your security policy requires periodic rotation):
1. **Update the password on the controller** (CNC pendant or vendor's
admin tool). The exact path varies by series — Fanuc service manual
page reference depends on the MTB.
2. **Update `appsettings.json`** in place with the new value.
- Production: bump the secrets-store entry that backs the
`Devices[*].Password` config-DB column. Same workflow as rotating
the LDAP service-account password.
- Dev: update `.local/focas-passwords.txt` (or wherever the dev
deployment sources the secret).
3. **Restart the OtOpcUa server** (or trigger a config-DB bump that
forces driver reinitialise). The driver picks up the new password
on the next `EnsureConnectedAsync` call. **No need to manually
reconnect each device** — `cnc_wrunlockparam` emits on the next
wire-call boundary.
4. **Verify**. The first wire call after restart logs
`"FOCAS unlock applied for focas://{host}:{port}"` at info. A wrong
password surfaces as `BadUserAccessDenied` on the next gated read or
write.
5. **Audit.** OPC UA wrote-event entries (per
[`audit-log-rules.md`](audit-log-rules.md)) cover the
parameter/macro write paths. Password rotation itself is NOT logged
beyond "unlock applied" — same posture as LDAP service-account
rotation, where the password change is logged out-of-band by the IAM
system.
### Cross-references
- [`docs/Security.md`](../Security.md) — server-wide secrets handling +
the same `.local/` pattern used for LDAP and the Galaxy.Host pipe
secret. The FOCAS password follows the same posture.
- [`docs/drivers/FOCAS.md`](../drivers/FOCAS.md) § "FOCAS password" —
driver-side behaviour, EW_PASSWD retry semantics, status-code
surface.
- [`docs/v2/implementation/focas-wire-protocol.md`](implementation/focas-wire-protocol.md)
§ "cnc_wrunlockparam" — wire-frame layout for the password buffer.

View File

@@ -32,6 +32,7 @@ command ids (and their request/response payloads) don't drift between the
| **`0x0102`** | **`cnc_wrparam`** | **mutates per-profile parameter map; returns `EW_PASSWD` (`11`) when the profile's `unlock_state` is off (sets up F4-d's unlock workflow) — issue #269, plan PR F4-b** |
| **`0x0103`** | **`cnc_wrmacro`** | **mutates per-profile macro map; integer-only writes for now (decimalPointCount=0) — issue #269, plan PR F4-b** |
| **`0x0104`** | **`pmc_wrpmcrng`** | **mutates per-profile PMC byte tables; byte-aligned writes preserve untouched bytes; bit-level writes never reach the simulator (driver wraps with RMW) — issue #270, plan PR F4-c** |
| **`0x0105`** | **`cnc_wrunlockparam`** | **flips the per-profile `unlock_state` to true when the supplied 4-byte password buffer matches the profile's `unlock_password`; otherwise returns `EW_PASSWD`. State persists for the connection lifetime (per-session). — issue #271, plan PR F4-d** |
| **`0x0F1A`** | **`cnc_rdalmhistry`** | **dumps the per-profile alarm-history ring buffer (issue #267, plan PR F3-a)** |
## `cnc_rdalmhistry` mock behaviour
@@ -124,6 +125,12 @@ Each profile owns:
- `unlock_state: bool` — defaults `False`. When `False`, every
`cnc_wrparam` returns `EW_PASSWD` (numeric `11`) regardless of
parameter. Macro writes are NOT gated by `unlock_state`.
- `unlock_password: bytes` (4-byte buffer) — defaults to the profile's
fixture default (e.g. `b"1234"` for Series30i). Compared byte-for-byte
by the `cnc_wrunlockparam` handler; flips `unlock_state = True` on
match, leaves it untouched on mismatch (and returns `EW_PASSWD`).
Mutable via `POST /admin/mock_set_password` for tests that exercise
rotation. Issue #271, plan PR F4-d.
- `last_write: Optional[LastWrite]` — most-recent successful
`(kind, number, value, ts)` tuple, surfaced via the admin endpoint
below for audit-log assertions.
@@ -163,6 +170,37 @@ POST /admin/mock_set_unlock_state
{ "profile": "Series30i", "unlocked": true }
```
### `cnc_wrunlockparam` request decode — issue #271, plan PR F4-d
```
[byte[4] password]
```
Match `password == profile.unlock_password` byte-for-byte. On match:
flip `unlock_state = True`, return `[int16 LE 0]`. On mismatch: leave
`unlock_state` untouched, return `[int16 LE 11]` (`EW_PASSWD`).
The simulator deliberately keeps unlock state per-session (per OpenSession
handle) so a reconnect drops back to `unlock_state = False` — matching the
FWLIB lifetime semantics described in
[`focas-wire-protocol.md`](./focas-wire-protocol.md) § "cnc_wrunlockparam".
### Admin endpoint — `POST /admin/mock_set_password`
Rotates the per-profile `unlock_password` for tests that exercise the
F4-d password-rotation runbook (`docs/v2/focas-deployment.md`
§ "FOCAS password handling"). Idempotent — call again to revert.
```
POST /admin/mock_set_password
{ "profile": "Series30i", "password": "5678" }
```
The endpoint accepts the password as a UTF-8/ASCII string and applies
the same right-pad-to-4-bytes / truncate-to-4-bytes normalisation the
driver does, so simulator-side matching is byte-symmetric with the
production wire encoder.
### Admin endpoint — `GET /admin/mock_get_last_write`
Returns the simulator's view of the most-recent successful write, used by

View File

@@ -22,6 +22,7 @@ Each FOCAS-equivalent call gets a stable wire-protocol command id. Ids are
| **`0x0102`** | **`cnc_wrparam`** | **IODBPSD parameter-write packet (issue #269, plan PR F4-b)** |
| **`0x0103`** | **`cnc_wrmacro`** | **ODBM macro-write packet (issue #269, plan PR F4-b)** |
| **`0x0104`** | **`pmc_wrpmcrng`** | **IODBPMC PMC range-write packet (issue #270, plan PR F4-c)** |
| **`0x0105`** | **`cnc_wrunlockparam`** | **4-byte password buffer for the parameter-protect / read-protect unlock (issue #271, plan PR F4-d)** |
| `0x0F1A` | **`cnc_rdalmhistry`** | **ODBALMHIS alarm-history ring-buffer dump (issue #267, plan PR F3-a)** |
## ODBALMHIS — alarm history (`cnc_rdalmhistry`, command `0x0F1A`)
@@ -214,3 +215,53 @@ data buffer in the response; the write side parses all five fields plus
the data buffer from the request and emits a status int16 in the
response. Tests `FocasWritePmcTests.PMC_*` exercise the round-trip on
the fake wire client.
## cnc_wrunlockparam — connection-level password unlock (command `0x0105`)
Issue #271, plan PR F4-d. Some controllers (notably 16i + certain 30i
firmwares with parameter-protect on) gate `cnc_wrparam` and selected
reads behind a connection-level password switch. The driver emits this
frame on connect when `FocasDeviceOptions.Password` is configured, and
re-emits it on any read/write that returns `EW_PASSWD` (then retries the
gated call once).
### Request
| Offset | Width | Field |
| --- | --- | --- |
| 0 | `byte[4]` | `password[4]` — 4-byte password buffer. ASCII-encoded from `FocasDeviceOptions.Password`, right-padded with `0x00`, truncated at 4 bytes. |
The 4-byte fixed slot matches the FANUC published shape — the controller
compares byte-for-byte. Longer / shorter source strings are normalised at
the driver layer before they hit this frame so the wire surface stays
canonical.
### Response
Same single-int16 envelope as the write frames:
| Offset | Width | Field |
| --- | --- | --- |
| 0 | `int16 LE` | `ew_status``0` = success (gate now lifted for the lifetime of this FWLIB handle), `EW_PASSWD` = supplied password did not match the controller's slot, `EW_HANDLE` = handle invalid. |
### Lifetime
Unlock is bound to the FWLIB handle: it persists until the handle closes
(disconnect / reconnect). The driver reinvokes unlock on every
`EnsureConnectedAsync` reconnect path so a planned or unplanned wire
restart self-heals without operator intervention. A `BadUserAccessDenied`
on a read/write triggers a single-shot retry: re-emit unlock + redispatch
the gated call once. A second `EW_PASSWD` propagates unchanged so a
mismatched password doesn't loop forever on the wire.
### No-log invariant
The password is a secret. Wire-client implementations MUST NOT log the
password on either request or response. The current
`FwlibFocasClient.UnlockAsync` constructs an exception that includes
only the `EW_*` return code; the `FocasDeviceOptions` record overrides
its auto-generated `ToString` so any Serilog destructure renders
`Password = ***`. See
[`docs/v2/focas-deployment.md`](../focas-deployment.md)
§ "FOCAS password handling" for the deployment-side guarantees +
rotation runbook.