From 423ffe66af5469e4f26bb92b84fcc6c1df919cf7 Mon Sep 17 00:00:00 2001 From: alice Date: Wed, 9 Sep 2026 21:42:11 +0000 Subject: [PATCH 1/5] =?UTF-8?q?vms-8c2:=20DECnet=20Phase=20IV=20file=20COP?= =?UTF-8?q?Y=20(FAL/DAP)=20=E2=80=94=20object-17=20server=20+=20DAP=20code?= =?UTF-8?q?c=20+=20COPY=20client?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add the DECnet file-transfer layered product behind $ COPY node"user pw"::file: the File Access Listener (Session Control object 17) server, the DAP (Data Access Protocol) message codec, and the COPY-node client logic. - DAP codec (src/vmsdecnet/dap/dnet_dap.{c,h}): pure, bounded, self-round-tripping encode/decode of CONFIGURATION/ATTRIBUTES/ACCESS/CONTROL/DATA/STATUS/NAME/ ACCESS-COMPLETE. Clean-room (Rule 8): message set + generic framing from the public DAP spec; sequence + carried values (filename, resolved full spec, owner UIC, verbatim records) from docs/oracle/vax-copy-fal-dap.* (vms-cd3). Fuzzed 200k inputs + every truncated prefix, ASan/UBSan-clean. - FAL server + COPY client (src/vmsdecnet/fal/dnet_fal.{c,h}): a SECURITY surface. An inbound object-17 connect CARRIES username+password (oracle §1, the opposite of CTERM); FAL authenticates them through THE ONE faithful authenticator (sysuaf_lookup + sysuaf_authenticate Purdy + the disabled-account gate, the same path LOGINOUT/SSHD use) BEFORE accepting the link and REFUSES with an NSP disconnect on a bad password (INV-6 — no file served on an unauthenticated connect). File I/O is rms_textfile_* (RMS over the ODS-2 ACP), fail-honest with no /dev/vms — no userspace fallback that fakes a transfer (Rule 9). No fork/exec/openpty/dup2, no raw-fd/termios file mechanics. - Bounded FAL credential decoder (dnet_fal_access_decode in dnet_cterm.c): the FAL counterpart to the CTERM connect parse — it RETAINS the password (which FAL must authenticate) into a caller buffer the server wipes; the CTERM "never retains a password" invariant stays intact for every non-FAL path. Fuzz-clean. - DECNETD.EXE --fal-accept-test: the FULL proof (hard gate in the booted battery on real /dev/vms) — real SYSUAF auth (GUEST/GUEST accepted, wrong password + DISABLED refused) then a sequential file transferred BOTH directions through real DAP over an NSP link + real RMS, byte-verified. --fal-selftest: the honest floor (runs anywhere) — a real object-17 connect carrying the creds refused with an NSP disconnect when unauthenticated, plus the threaded DAP-over-NSP transport pump. - DCL COPY (dcl_cmd_file.c): a NODE:: spec routes to the FAL/DAP path and reports honestly (%COPY-I-NETNOTWIRED) rather than mis-copying it locally; the outbound COPY-over-datalink bridge from a DCL process is a tracked follow-on. - Compat SSOT: decnet$dap + decnet$fal added; decnet$node-filespec-syntax updated; docs/compatibility-surface.md re-rendered. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01T4csyFSMUsS8k1D2MgMxk2 --- docs/compat/facilities/decnet.yaml | 18 +- docs/compatibility-surface.md | 22 +- src/vmsdcl/dcl_cmd_file.c | 39 +++ src/vmsdecnet/CMakeLists.txt | 11 + src/vmsdecnet/cterm/dnet_cterm.c | 83 +++++ src/vmsdecnet/cterm/include/dnet_cterm.h | 28 ++ src/vmsdecnet/dap/CMakeLists.txt | 21 ++ src/vmsdecnet/dap/dnet_dap.c | 277 ++++++++++++++++ src/vmsdecnet/dap/include/dnet_dap.h | 213 ++++++++++++ src/vmsdecnet/engine/CMakeLists.txt | 14 +- src/vmsdecnet/engine/decnetd.c | 386 ++++++++++++++++++++- src/vmsdecnet/fal/CMakeLists.txt | 25 ++ src/vmsdecnet/fal/dnet_fal.c | 406 +++++++++++++++++++++++ src/vmsdecnet/fal/include/dnet_fal.h | 140 ++++++++ tests/qemu/lib/dcl_acceptance_battery.sh | 51 +++ tests/vmsdecnet/CMakeLists.txt | 29 ++ tests/vmsdecnet/test_dnet_dap.c | 243 ++++++++++++++ 17 files changed, 1986 insertions(+), 20 deletions(-) create mode 100644 src/vmsdecnet/dap/CMakeLists.txt create mode 100644 src/vmsdecnet/dap/dnet_dap.c create mode 100644 src/vmsdecnet/dap/include/dnet_dap.h create mode 100644 src/vmsdecnet/fal/CMakeLists.txt create mode 100644 src/vmsdecnet/fal/dnet_fal.c create mode 100644 src/vmsdecnet/fal/include/dnet_fal.h create mode 100644 tests/vmsdecnet/test_dnet_dap.c diff --git a/docs/compat/facilities/decnet.yaml b/docs/compat/facilities/decnet.yaml index 7dacfc752..a709f7b6d 100644 --- a/docs/compat/facilities/decnet.yaml +++ b/docs/compat/facilities/decnet.yaml @@ -14,12 +14,14 @@ summary: > adjacency state machine + hello/listen timers, and the NSP transport codec (Connect Initiate oracle-verified; other PDUs self-round-trip). A live engine/socket now drives real logical links over the datalink: inbound SET HOST - reaches an AUTHENTICATED LOGINOUT (decnet$cterm-session-auth), and the outbound - DCL SET HOST CLIENT is wired (decnet$set-host) -- it opens a CTERM terminal - session to a remote object 42 through the VMS terminal channel and returns with - %REM-S-END. Still open: general task-to-task ($QIO/FAL). NODE"acc"::file - filespec syntax parses but nothing downstream acts on it. 1.0 blocker (vms-30e). -last_reviewed: 2026-08-31 + reaches an AUTHENTICATED LOGINOUT (decnet$cterm-session-auth), the outbound + DCL SET HOST CLIENT is wired (decnet$set-host), and inbound file COPY / FAL + (object 17) authenticates the connect-carried credentials and moves a file + through DAP + RMS (decnet$fal / decnet$dap). Still open: general task-to-task + ($QIO), and the outbound COPY-over-datalink client wired into a DCL process. + NODE"acc"::file filespec syntax parses and DCL COPY now routes it to the FAL + path (reporting honestly until the outbound bridge lands). 1.0 blocker (vms-30e). +last_reviewed: 2026-09-09 items: - {id: decnet$routing-hello, kind: protocol, status: verified, authenticity: real, vms: "Phase IV Ethernet Endnode Hello codec (encode/decode)", evidence: "src/vmsdecnet/routing/dnet_hello.c", verified_against: "docs/decnet-provenance-register.md sec 4.6 specimen #1 (lab capture, rd vms-3be/PR #665) round-tripped byte-identical by tests/vmsdecnet/test_dnet_hello.c", notes: "Pure byte-layout library, no socket/allocation; field-by-field mapped to the committed capture."} - {id: decnet$router-hello, kind: protocol, status: implemented, authenticity: real, vms: "Phase IV Ethernet Router Hello codec (encode/decode)", evidence: "src/vmsdecnet/routing/dnet_router_hello.c", notes: "SPEC-DERIVED — no committed router-hello wire specimen exists, so not oracle-anchored; self round-trips in tests/vmsdecnet/test_dnet_router_hello.c (rd vms-0aba)."} @@ -33,4 +35,6 @@ items: - {id: decnet$netacp-device-face, kind: feature, status: implemented, authenticity: real, vms: "NETACP presents the DECnet device face _NET: + object-dispatch as EXECUTIVE-RESIDENT, cross-process-real state", evidence: "src/kernel-core/vms_devtab.c", notes: "P5 (rd vms-9ab, design vms-515 §3.3; vms_devtab_probe_net + src/vmsdecnet/engine/decnetd.c). The DECnet device _NET: is born in the executive I/O database at module init over the same primary NIC ETH0: rides (gated on the NIC -- no NIC, no _NET:, SS$_NOSUCHDEV, INV-6), so $ASSIGN/$GETDVI _NET: from a process that is NOT NETACP resolves a real DC$_SCOM device -- the §7.5 cross-process tell, same shape as the RTAn: proof. Asserted cross-process in tests/qemu/test_kmod_devtab.c (kmod leg) and via F$GETDVI _NET: in the shared acceptance battery. Object dispatch (42=CTERM) is executive-resident and cross-process-proven by the object-42->RTAn:->$GETDVI path (decnet$cterm-session-auth). NOT yet: a standalone NCP-style enumerable object REGISTRY query (its own executive ioctl, rd vms-9ab follow-on); the wire engine binary is not yet renamed NETACP.EXE (cosmetic, tracked)."} - {id: decnet$wire-isolation, kind: feature, status: implemented, authenticity: real, vms: "A2/A8 isolation: attacker wire-parsing runs at LOW privilege and hands NETACP's privileged control path a VALIDATED TYPED DESCRIPTOR; the privileged path parses no attacker bytes", evidence: "src/vmsdecnet/cterm/dnet_cterm_host.c", notes: "P5 (rd vms-9ab, design vms-515 §3.4; low-priv parse dnet_conn_descriptor_from_wire in src/vmsdecnet/cterm/dnet_cterm.c, privileged consumer dnet_cterm_host_open_desc here). The low-privilege bounded parse distils untrusted NSP-connect bytes into a struct dnet_conn_descriptor carrying no wire pointer, no length, and no credential material; the privileged session-creating path (mints RTAn:, $CREPRCs LOGINOUT) takes ONLY that descriptor and refuses an unvalidated or wrong-object one BEFORE any device/process exists. Proven: the low-priv seam under 200k-frame mutation fuzz in tests/vmsdecnet/test_dnet_cterm.c (no malformed frame yields a validated/steered descriptor; every rejected frame leaves it all-zero), and the privileged double-door on the shipped binary in DECNETD.EXE --isolation-test (no /dev/vms needed), run by the acceptance battery. 'OVMX never crashes a peer': every hostile field is bounded and refused cleanly."} - {id: decnet$set-host, kind: command, status: partial, authenticity: real, vms: "SET HOST — DECnet remote-node connection (outbound client)", evidence: "src/vmsdcl/dcl_cmd_set.c", notes: "DCL `SET HOST ` activates SYS$SYSTEM:DECNETD.EXE --set-host on the CALLER'S TERMINAL through the executive image activator (dcl_activate_image -> imgact_activate, with DCL's fork fallback -- the same path RUN uses, NOT a bare fork/exec). The client (src/vmsdecnet/engine/decnetd.c run_set_host_loop) opens a REAL NSP logical link to the remote's Session Control object 42, binds a CTERM terminal session, and bridges THIS process's local terminal to it. VMS-NATIVE terminal I/O (the anti-LARP core): the client does $ASSIGN SYS$INPUT/SYS$OUTPUT + $QIO IO$_SETMODE (OVMX pass-all selector IO$K_TT_PASSALL) + IO$_READVBLK/IO$_WRITEVBLK -- NEVER tcsetattr/cfmakeraw on fd 0/1; the termios that realises pass-all lives in the executive terminal driver (src/libvms/syssvc/sys_qio.c qio_terminal_setmode), below the $QIO interface. On teardown control returns with the canonical %REM-S-END (oracle docs/oracle/vax-sethost-cterm.console.txt). The remote authenticates FRESH (the carried --user is proxy only, never auto-login). The client contains NO fork/exec/openpty/dup2. Response decoders are bounded against a hostile remote (mutation fuzz of dnet_nsp_decode/dnet_cterm_rx, tests/vmsdecnet/test_dnet_cterm.c, clean under ASan/UBSan). PROVEN: the CLIENT drives a genuine Connect Initiate to object 42 over a real veth datalink and the server refuses honestly without an executive (tests/integration/decnet_set_host_live.sh reduced proof). partial (not implemented): the FULL authenticated end-to-end (client -> remote LOGINOUT challenge -> %REM-S-END) needs a CI leg with BOTH CAP_NET and a real executive (/dev/vms) plus DECNETD.EXE staged into the boot image -- the workflow_dispatch proof leg; tracked as follow-on."} - - {id: decnet$node-filespec-syntax, kind: feature, status: partial, authenticity: real, vms: "NODE\"acc\"::dev:[dir]file filespec syntax", evidence: "src/vmsrms/rms_parse.c", notes: "Syntax only — sets NAM$M_NODE, parses/reconstructs the node prefix; nothing downstream (no live DECnet transport) acts on it."} + - {id: decnet$node-filespec-syntax, kind: feature, status: partial, authenticity: real, vms: "NODE\"acc\"::dev:[dir]file filespec syntax", evidence: "src/vmsrms/rms_parse.c", notes: "Syntax sets NAM$M_NODE and parses/reconstructs the node prefix. DCL COPY now ACTS on it (rd vms-8c2): a NODE:: spec routes to the DECnet FAL/DAP path and, since the outbound COPY-over-datalink client is not yet wired into a DCL process, reports honestly (%COPY-I-NETNOTWIRED) rather than mis-copying it as a local file — src/vmsdcl/dcl_cmd_file.c copy_spec_has_node. Full outbound DCL->datalink bridge is a tracked follow-on."} + - {id: decnet$dap, kind: protocol, status: implemented, authenticity: real, vms: "DAP (Data Access Protocol) message codec — CONFIGURATION/ATTRIBUTES/ACCESS/CONTROL/DATA/STATUS/NAME/ACCESS-COMPLETE encode+decode", evidence: "src/vmsdecnet/dap/dnet_dap.c", verified_against: "docs/oracle/vax-copy-fal-dap.* (rd vms-cd3): the message SEQUENCE + carried values (filename, resolved full spec, owner UIC, verbatim records) are the real VAX<->VAX COPY's ground truth; the per-field framing is the PUBLIC DAP spec", notes: "Pure byte codec, self-round-tripping. CLEAN-ROOM (Rule 8): message set + generic framing from the public DEC DAP functional specification; sequence + credential/object/carried-value semantics from the oracle. FULLY BOUNDED against hostile input — decoder fuzzed 200k inputs + every truncated prefix, ASan/UBSan-clean (tests/vmsdecnet/test_dnet_dap.c). HONEST SCOPE (INV-6): the field sub-framing is NOT asserted byte-identical to the real-VAX DAP sub-framing (the oracle §3 does not transcribe every sub-field); two OVMX nodes interoperate over it faithfully, byte-level stock-VAX FAL wire interop is a filed follow-on."} + - {id: decnet$fal, kind: feature, status: partial, authenticity: real, vms: "FAL — File Access Listener (DECnet Session Control object 17): inbound file COPY authenticated at connect + served/stored via RMS; the COPY node:: client", evidence: "src/vmsdecnet/fal/dnet_fal.c", verified_against: "DECNETD.EXE --fal-accept-test in the booted acceptance battery (tests/qemu/lib/dcl_acceptance_battery.sh): real SYSUAF/Purdy auth (GUEST/GUEST accepted, a wrong password + DISABLED/DISUSER refused) then a sequential file transferred BOTH directions through real DAP over an NSP link + real RMS over the ACP, byte-verified", notes: "SECURITY SURFACE. An inbound object-17 connect CARRIES the username+password (oracle §1 — the OPPOSITE of CTERM's empty creds); FAL authenticates them through THE ONE faithful authenticator (sysuaf_lookup + sysuaf_authenticate Purdy + the disabled-account gate, the SAME path LOGINOUT/SSHD use) BEFORE accepting the link, and REFUSES with an NSP Disconnect on a bad password — the FAL analogue of the no-auth-CTERM hole (INV-6, no file served on an unauthenticated connect). The connect-carried creds are decoded by a bounded, fuzz-clean decoder (dnet_fal_access_decode) that never over-reads and wipes the password after the check. File I/O is rms_textfile_* (RMS over the ODS-2 ACP), fail-honest with no /dev/vms — no userspace fallback that fakes a transfer (Rule 9). No fork/exec/openpty/dup2, no raw-fd/termios file mechanics. HONEST FLOOR (no executive, runs anywhere): DECNETD.EXE --fal-selftest proves the client emits a real object-17 connect carrying the creds + the honest refusal + the DAP-over-NSP transport pump. NOT yet (filed follow-ons, never faked): the outbound COPY client wired into a DCL process over the live datalink; advanced DAP (indexed/relative files, wildcards, DIRECTORY, block mode, proxy-instead-of-password access); byte-level stock-VAX FAL wire interop; network-access-class (NETMBX) enforcement distinct from the password/DISUSER gates."} diff --git a/docs/compatibility-surface.md b/docs/compatibility-surface.md index 67955168c..27186e930 100644 --- a/docs/compatibility-surface.md +++ b/docs/compatibility-surface.md @@ -5,15 +5,15 @@ ## Inventory -**446 surfaces catalogued** across 9 domains, each with a per-surface status. +**448 surfaces catalogued** across 9 domains, each with a per-surface status. > This register is an **inventory, not a percentage.** The total VMS compatibility surface has **no known denominator** — it is not version-scoped and cannot be counted — so no "% compatible" is claimed or computable. The catalogue is **incomplete by construction** and grows as surfaces are identified. Below are absolute counts; V1 progress is tracked separately against the commitment set we define, and is never conflated with the whole surface. | Status | Count | | Authenticity | Count | |---|---|---|---|---| -| ✅ verified | 22 | | real | 302 | -| 🟢 implemented | 267 | | n/a | 93 | -| 🟡 partial | 48 | | advisory | 45 | +| ✅ verified | 22 | | real | 304 | +| 🟢 implemented | 268 | | n/a | 93 | +| 🟡 partial | 49 | | advisory | 45 | | 🟠 stub | 16 | | facade-risk | 6 | | 🔵 designed | 0 | | | | | ⬜ absent | 93 | | | | @@ -24,7 +24,7 @@ Legend: ✅ verified · 🟢 implemented · 🟡 partial · 🟠 stub · 🔵 de Of the surfaces **committed to V1** (`scope_1_0: in` — a set we define, not a measure of the whole surface): -- **402 committed** — **289 met** (implemented/verified), 47 in progress (partial), 66 not started (absent/stub/designed). +- **404 committed** — **290 met** (implemented/verified), 48 in progress (partial), 66 not started (absent/stub/designed). - ⚠ **4 of the committed surfaces carry facade-risk** — they must reach honest behaviour, not just "done". - Not in the V1 commitment set: 8 out · 27 stretch · 9 undecided (incl. the language scope calls, `vms-082`). @@ -1106,15 +1106,15 @@ Universal symbol vectors (position-bound binding), GSMATCH (ALWAYS/EQUAL/LEQUAL, _TCP/IP Services (UCX), DECnet Phase IV, LAT, SSH._ -`✅✅✅🟢🟢🟢🟢🟢🟢🟢🟢🟢🟢🟢🟢🟢🟢🟢🟡🟡🟡⬜⬜⬜` — 28 surfaces catalogued (21 met · 4 in progress · 3 not started) · V1: 26 committed, 21 met +`✅✅🟢🟢🟢🟢🟢🟢🟢🟢🟢🟢🟢🟢🟢🟢🟢🟡🟡🟡🟡⬜⬜` — 30 surfaces catalogued (22 met · 5 in progress · 3 not started) · V1: 28 committed, 22 met ### decnet — DECnet Phase IV (Routing, NSP, Task-to-Task, SET HOST) -scope: in · plan: vms-30e · ref: DECnet for OpenVMS Networking Manual; DNA Phase IV Routing + NSP specs · reviewed 2026-08-31 +scope: in · plan: vms-30e · ref: DECnet for OpenVMS Networking Manual; DNA Phase IV Routing + NSP specs · reviewed 2026-09-09 -No longer greenfield: src/vmsdecnet now carries real, clean-room Phase IV wire codecs and a routing adjacency engine, several oracle-verified byte-identical against captures committed in docs/decnet-provenance-register.md (lab specimens, rd vms-3be/PR #665). Present: the Ethernet Endnode Hello codec (oracle-verified), the Router Hello codec (spec-derived), the routing adjacency state machine + hello/listen timers, and the NSP transport codec (Connect Initiate oracle-verified; other PDUs self-round-trip). A live engine/socket now drives real logical links over the datalink: inbound SET HOST reaches an AUTHENTICATED LOGINOUT (decnet$cterm-session-auth), and the outbound DCL SET HOST CLIENT is wired (decnet$set-host) -- it opens a CTERM terminal session to a remote object 42 through the VMS terminal channel and returns with %REM-S-END. Still open: general task-to-task ($QIO/FAL). NODE"acc"::file filespec syntax parses but nothing downstream acts on it. 1.0 blocker (vms-30e). +No longer greenfield: src/vmsdecnet now carries real, clean-room Phase IV wire codecs and a routing adjacency engine, several oracle-verified byte-identical against captures committed in docs/decnet-provenance-register.md (lab specimens, rd vms-3be/PR #665). Present: the Ethernet Endnode Hello codec (oracle-verified), the Router Hello codec (spec-derived), the routing adjacency state machine + hello/listen timers, and the NSP transport codec (Connect Initiate oracle-verified; other PDUs self-round-trip). A live engine/socket now drives real logical links over the datalink: inbound SET HOST reaches an AUTHENTICATED LOGINOUT (decnet$cterm-session-auth), the outbound DCL SET HOST CLIENT is wired (decnet$set-host), and inbound file COPY / FAL (object 17) authenticates the connect-carried credentials and moves a file through DAP + RMS (decnet$fal / decnet$dap). Still open: general task-to-task ($QIO), and the outbound COPY-over-datalink client wired into a DCL process. NODE"acc"::file filespec syntax parses and DCL COPY now routes it to the FAL path (reporting honestly until the outbound bridge lands). 1.0 blocker (vms-30e). -13 items · 8 met · 4 in progress · 1 not started +15 items · 9 met · 5 in progress · 1 not started | | Surface | Kind | VMS | Status | Auth | Scope | Evidence / notes | |---|---|---|---|---|---|---|---| @@ -1130,7 +1130,9 @@ No longer greenfield: src/vmsdecnet now carries real, clean-room Phase IV wire c | 🟢 | `decnet$netacp-device-face` | feature | NETACP presents the DECnet device face _NET: + object-dispatch as EXECUTIVE-RESIDENT, cross-process-real state | implemented | real | in | `src/kernel-core/vms_devtab.c` — P5 (rd vms-9ab, design vms-515 §3.3; vms_devtab_probe_net + src/vmsdecnet/engine/decnetd.c). The DECnet device _NET: is born in the executive I/O database at module init over the same primary NIC ETH0: rides (gated on the NIC -- no NIC, no _NET:, SS$_NOSUCHDEV, INV-6), so $ASSIGN/$GETDVI _NET: from a process that is NOT NETACP resolves a real DC$_SCOM device -- the §7.5 cross-process tell, same shape as the RTAn: proof. Asserted cross-process in tests/qemu/test_kmod_devtab.c (kmod leg) and via F$GETDVI _NET: in the shared acceptance battery. Object dispatch (42=CTERM) is executive-resident and cross-process-proven by the object-42->RTAn:->$GETDVI path (decnet$cterm-session-auth). NOT yet: a standalone NCP-style enumerable object REGISTRY query (its own executive ioctl, rd vms-9ab follow-on); the wire engine binary is not yet renamed NETACP.EXE (cosmetic, tracked). | | 🟢 | `decnet$wire-isolation` | feature | A2/A8 isolation: attacker wire-parsing runs at LOW privilege and hands NETACP's privileged control path a VALIDATED TYPED DESCRIPTOR; the privileged path parses no attacker bytes | implemented | real | in | `src/vmsdecnet/cterm/dnet_cterm_host.c` — P5 (rd vms-9ab, design vms-515 §3.4; low-priv parse dnet_conn_descriptor_from_wire in src/vmsdecnet/cterm/dnet_cterm.c, privileged consumer dnet_cterm_host_open_desc here). The low-privilege bounded parse distils untrusted NSP-connect bytes into a struct dnet_conn_descriptor carrying no wire pointer, no length, and no credential material; the privileged session-creating path (mints RTAn:, $CREPRCs LOGINOUT) takes ONLY that descriptor and refuses an unvalidated or wrong-object one BEFORE any device/process exists. Proven: the low-priv seam under 200k-frame mutation fuzz in tests/vmsdecnet/test_dnet_cterm.c (no malformed frame yields a validated/steered descriptor; every rejected frame leaves it all-zero), and the privileged double-door on the shipped binary in DECNETD.EXE --isolation-test (no /dev/vms needed), run by the acceptance battery. 'OVMX never crashes a peer': every hostile field is bounded and refused cleanly. | | 🟡 | `decnet$set-host` | command | SET HOST — DECnet remote-node connection (outbound client) | partial | real | in | `src/vmsdcl/dcl_cmd_set.c` — DCL `SET HOST ` activates SYS$SYSTEM:DECNETD.EXE --set-host on the CALLER'S TERMINAL through the executive image activator (dcl_activate_image -> imgact_activate, with DCL's fork fallback -- the same path RUN uses, NOT a bare fork/exec). The client (src/vmsdecnet/engine/decnetd.c run_set_host_loop) opens a REAL NSP logical link to the remote's Session Control object 42, binds a CTERM terminal session, and bridges THIS process's local terminal to it. VMS-NATIVE terminal I/O (the anti-LARP core): the client does $ASSIGN SYS$INPUT/SYS$OUTPUT + $QIO IO$_SETMODE (OVMX pass-all selector IO$K_TT_PASSALL) + IO$_READVBLK/IO$_WRITEVBLK -- NEVER tcsetattr/cfmakeraw on fd 0/1; the termios that realises pass-all lives in the executive terminal driver (src/libvms/syssvc/sys_qio.c qio_terminal_setmode), below the $QIO interface. On teardown control returns with the canonical %REM-S-END (oracle docs/oracle/vax-sethost-cterm.console.txt). The remote authenticates FRESH (the carried --user is proxy only, never auto-login). The client contains NO fork/exec/openpty/dup2. Response decoders are bounded against a hostile remote (mutation fuzz of dnet_nsp_decode/dnet_cterm_rx, tests/vmsdecnet/test_dnet_cterm.c, clean under ASan/UBSan). PROVEN: the CLIENT drives a genuine Connect Initiate to object 42 over a real veth datalink and the server refuses honestly without an executive (tests/integration/decnet_set_host_live.sh reduced proof). partial (not implemented): the FULL authenticated end-to-end (client -> remote LOGINOUT challenge -> %REM-S-END) needs a CI leg with BOTH CAP_NET and a real executive (/dev/vms) plus DECNETD.EXE staged into the boot image -- the workflow_dispatch proof leg; tracked as follow-on. | -| 🟡 | `decnet$node-filespec-syntax` | feature | NODE"acc"::dev:[dir]file filespec syntax | partial | real | in | `src/vmsrms/rms_parse.c` — Syntax only — sets NAM$M_NODE, parses/reconstructs the node prefix; nothing downstream (no live DECnet transport) acts on it. | +| 🟡 | `decnet$node-filespec-syntax` | feature | NODE"acc"::dev:[dir]file filespec syntax | partial | real | in | `src/vmsrms/rms_parse.c` — Syntax sets NAM$M_NODE and parses/reconstructs the node prefix. DCL COPY now ACTS on it (rd vms-8c2): a NODE:: spec routes to the DECnet FAL/DAP path and, since the outbound COPY-over-datalink client is not yet wired into a DCL process, reports honestly (%COPY-I-NETNOTWIRED) rather than mis-copying it as a local file — src/vmsdcl/dcl_cmd_file.c copy_spec_has_node. Full outbound DCL->datalink bridge is a tracked follow-on. | +| 🟢 | `decnet$dap` | protocol | DAP (Data Access Protocol) message codec — CONFIGURATION/ATTRIBUTES/ACCESS/CONTROL/DATA/STATUS/NAME/ACCESS-COMPLETE encode+decode | implemented | real | in | `src/vmsdecnet/dap/dnet_dap.c` — Pure byte codec, self-round-tripping. CLEAN-ROOM (Rule 8): message set + generic framing from the public DEC DAP functional specification; sequence + credential/object/carried-value semantics from the oracle. FULLY BOUNDED against hostile input — decoder fuzzed 200k inputs + every truncated prefix, ASan/UBSan-clean (tests/vmsdecnet/test_dnet_dap.c). HONEST SCOPE (INV-6): the field sub-framing is NOT asserted byte-identical to the real-VAX DAP sub-framing (the oracle §3 does not transcribe every sub-field); two OVMX nodes interoperate over it faithfully, byte-level stock-VAX FAL wire interop is a filed follow-on. | +| 🟡 | `decnet$fal` | feature | FAL — File Access Listener (DECnet Session Control object 17): inbound file COPY authenticated at connect + served/stored via RMS; the COPY node:: client | partial | real | in | `src/vmsdecnet/fal/dnet_fal.c` — SECURITY SURFACE. An inbound object-17 connect CARRIES the username+password (oracle §1 — the OPPOSITE of CTERM's empty creds); FAL authenticates them through THE ONE faithful authenticator (sysuaf_lookup + sysuaf_authenticate Purdy + the disabled-account gate, the SAME path LOGINOUT/SSHD use) BEFORE accepting the link, and REFUSES with an NSP Disconnect on a bad password — the FAL analogue of the no-auth-CTERM hole (INV-6, no file served on an unauthenticated connect). The connect-carried creds are decoded by a bounded, fuzz-clean decoder (dnet_fal_access_decode) that never over-reads and wipes the password after the check. File I/O is rms_textfile_* (RMS over the ODS-2 ACP), fail-honest with no /dev/vms — no userspace fallback that fakes a transfer (Rule 9). No fork/exec/openpty/dup2, no raw-fd/termios file mechanics. HONEST FLOOR (no executive, runs anywhere): DECNETD.EXE --fal-selftest proves the client emits a real object-17 connect carrying the creds + the honest refusal + the DAP-over-NSP transport pump. NOT yet (filed follow-ons, never faked): the outbound COPY client wired into a DCL process over the live datalink; advanced DAP (indexed/relative files, wildcards, DIRECTORY, block mode, proxy-instead-of-password access); byte-level stock-VAX FAL wire interop; network-access-class (NETMBX) enforcement distinct from the password/DISUSER gates. | ### lat — LAT (Local Area Transport) scope: stretch · plan: vms-67f · ref: DECnet/OSI... / LAT protocol reference (DEC) · reviewed 2026-08-31 diff --git a/src/vmsdcl/dcl_cmd_file.c b/src/vmsdcl/dcl_cmd_file.c index 88a73b255..afe766631 100644 --- a/src/vmsdcl/dcl_cmd_file.c +++ b/src/vmsdcl/dcl_cmd_file.c @@ -1803,6 +1803,24 @@ static int resolve_out_version(const char *dst_dir, const char *out_name, return 1; } +/* + * copy_spec_has_node - does a filespec carry a DECnet node prefix + * (NODE::... or NODE"access"::...)? Detects a top-level "::" -- one that is not + * inside a quoted access-control string -- exactly as $FILESCAN sets FSCN$_NODE + * (src/libvms/syssvc/sys_filescan.c). Used only to route COPY down the DECnet + * FAL/DAP path vs. the local ODS-2 path; the full parse into user/password/spec + * happens in the FAL client (dnet_fal + dnet_cterm_sc_connect_build). + */ +static int copy_spec_has_node(const char *s) +{ + int in_quote = 0; + for (const char *p = s; *p; p++) { + if (*p == '"') { in_quote = !in_quote; continue; } + if (!in_quote && p[0] == ':' && p[1] == ':') return 1; + } + return 0; +} + /* * COPY - Copy file(s), with VMS wildcard source expansion and version * defaulting on the output. @@ -1824,6 +1842,27 @@ int cmd_copy(struct dcl_command *cmd) return SS$_BADPARAM; } + /* ---- DECnet file COPY (NODE"user pw"::file), rd vms-8c2 -------------- + * A node prefix on either side is a DECnet FAL/DAP transfer, NOT a local + * ODS-2 COPY. The transfer ENGINE is real and proven -- the FAL server + * (object 17) + the DAP codec + the COPY client (dnet_fal_client_put/get), + * exercised end to end over a real NSP logical link by DECNETD.EXE + * --fal-accept-test (real SYSUAF auth + real RMS both directions, + * byte-verified) and --fal-selftest (the honest floor: a real object-17 + * connect carrying the access-control creds, refused with an NSP disconnect + * when unauthenticated). What is NOT yet wired is the OUTBOUND bridge FROM a + * DCL process TO the live datalink (a DCL COPY does not yet own a DECnet + * circuit; the same gap SET HOST's outbound client has -- decnet$set-host). + * So COPY reports honestly here rather than mis-copying a NODE:: spec as a + * local file or faking a transfer (INV-6 / Rule 9). Tracked follow-on: + * wire the FAL client into DCL over the datalink (rd vms-30e child). */ + if (copy_spec_has_node(cmd->params[0]) || copy_spec_has_node(cmd->params[1])) { + printf("%%COPY-I-NETNOTWIRED, DECnet file COPY (FAL/DAP object 17) engine " + "is present and authenticated, but the outbound COPY-over-datalink " + "client is not yet wired into DCL on this system\n"); + return SS$_ABORT; + } + int do_log = dcl_has_qualifier(cmd, "LOG"); int do_confirm = dcl_has_qualifier(cmd, "CONFIRM"); int new_version = dcl_has_qualifier(cmd, "NEW_VERSION"); diff --git a/src/vmsdecnet/CMakeLists.txt b/src/vmsdecnet/CMakeLists.txt index 1002f1555..00f97e6a5 100644 --- a/src/vmsdecnet/CMakeLists.txt +++ b/src/vmsdecnet/CMakeLists.txt @@ -26,6 +26,17 @@ add_subdirectory(ncp) # entirely SPEC-DERIVED (no oracle specimen; register sec 4.7). add_subdirectory(cterm) +# DAP layer (rd vms-8c2): the Data Access Protocol message codec -- the +# presentation layer behind $ COPY node::file, riding an NSP logical link. Pure +# codec (message set + generic framing from the public DAP spec; sequence + +# carried values from docs/oracle/vax-copy-fal-dap.*, rd vms-cd3). +add_subdirectory(dap) + +# FAL layer (rd vms-8c2): the File Access Listener (Session Control object 17) +# server + the COPY node:: client. Authenticates the connect-carried credentials +# (real SYSUAF/Purdy) and moves the file through RMS over the ODS-2 ACP. +add_subdirectory(fal) + # Engine rung 1 (rd vms-449d): the userspace routing ENGINE that MOVES FRAMES # using the engine-agnostic codecs above -- AF_PACKET SOCK_RAW over the shared # src/libdatalink raw-L2 datalink (operator ruling vms-a1c, Option B), with the diff --git a/src/vmsdecnet/cterm/dnet_cterm.c b/src/vmsdecnet/cterm/dnet_cterm.c index 654091ca0..698d4a4f5 100644 --- a/src/vmsdecnet/cterm/dnet_cterm.c +++ b/src/vmsdecnet/cterm/dnet_cterm.c @@ -487,6 +487,89 @@ int dnet_cterm_sc_connect_parse(const uint8_t *buf, size_t len, return DNET_CTERM_OK; } +int dnet_fal_access_decode(const uint8_t *buf, size_t len, + char *userid, size_t useridcap, + char *password, size_t passwordcap, + char *account, size_t accountcap) +{ + /* + * THE FAL DIFFERENCE (rd vms-8c2, oracle docs/oracle/vax-copy-fal-dap.md + * §1). Unlike CTERM/SET HOST -- where the access-control fields are EMPTY + * and dnet_cterm_sc_connect_parse deliberately DROPS the password so no + * wire-supplied credential can reach a decision -- an inbound FAL (object + * 17) connect CARRIES the username AND password the server must + * authenticate. So this decoder RETAINS the password, into a CALLER-OWNED + * buffer the FAL server wipes the instant sysuaf_authenticate has consumed + * it (dnet_fal.c). It is a SEPARATE, explicitly-named entry so the CTERM + * codec's "never retains a password" invariant (and its test) stays intact: + * only FAL, which must, ever sees the bytes. + * + * FULLY BOUNDED. These are attacker-controlled bytes on an unauthenticated + * inbound connect. The walk reuses the same bounded descriptor/string + * helpers the CTERM parse trusts (sc_get_descriptor / sc_get_string): it + * never reads past buf[len-1] and refuses (does not clip) an over-long + * counted field. On ANY malformation every output buffer is left empty and + * a negative code is returned, so a caller cannot authenticate from a + * half-parsed identity. "OVMX never crashes a peer": a hostile client + * cannot fault FAL here. + * + * FIELD ORDER (oracle §1 + DNA Session Control): after the DSTNAME and + * SRCNAME descriptors and the MENUVER byte come three counted access- + * control strings -- RQSTRID (the username to authenticate), PASSWRD, and + * ACCOUNT -- exactly the "06 'SYSTEM' 06 00" the capture shows. + */ + if (!buf || !userid || !password || !account || + useridcap == 0 || passwordcap == 0 || accountcap == 0) + return DNET_CTERM_EINVAL; + + userid[0] = password[0] = account[0] = '\0'; + + uint8_t dfmt, dobj, sfmt, sobj; + uint16_t grp, usr; + char dtask[DNET_SC_MAX_STR + 1], suser[DNET_SC_MAX_STR + 1]; + size_t off = 0; + long r; + + r = sc_get_descriptor(buf, len, off, &dfmt, &dobj, NULL, NULL, + dtask, sizeof(dtask)); + if (r < 0) return (int)r; + off += (size_t)r; + + r = sc_get_descriptor(buf, len, off, &sfmt, &sobj, &grp, &usr, + suser, sizeof(suser)); + if (r < 0) return (int)r; + off += (size_t)r; + + /* MENUVER byte. A connect with no access-control area (nothing to + * authenticate WITH) is well-formed but leaves every field empty -- the + * FAL server then refuses the connect, it does not admit an empty-credential + * session (that was the CTERM hole; FAL must not repeat it). */ + if (off >= len) return DNET_CTERM_OK; + off++; /* MENUVER, not interpreted here */ + + /* RQSTRID = the username. */ + if (off >= len) return DNET_CTERM_OK; + r = sc_get_string(buf, len, off, userid, useridcap); + if (r < 0) { userid[0] = '\0'; return (int)r; } + off += (size_t)r; + + /* PASSWRD = the password -- RETAINED (the FAL difference). */ + if (off < len) { + r = sc_get_string(buf, len, off, password, passwordcap); + if (r < 0) { userid[0] = password[0] = '\0'; return (int)r; } + off += (size_t)r; + } + + /* ACCOUNT (usually empty). */ + if (off < len) { + r = sc_get_string(buf, len, off, account, accountcap); + if (r < 0) { userid[0] = password[0] = account[0] = '\0'; return (int)r; } + off += (size_t)r; + } + + return DNET_CTERM_OK; +} + int dnet_cterm_sc_connect_object(const uint8_t *buf, size_t len) { struct dnet_cterm_sc_connect sc; diff --git a/src/vmsdecnet/cterm/include/dnet_cterm.h b/src/vmsdecnet/cterm/include/dnet_cterm.h index c97df6377..4262b9872 100644 --- a/src/vmsdecnet/cterm/include/dnet_cterm.h +++ b/src/vmsdecnet/cterm/include/dnet_cterm.h @@ -347,6 +347,34 @@ int dnet_cterm_sc_connect_parse(const uint8_t *buf, size_t len, * Returns the object number (>=0) or DNET_CTERM_EINVAL on a malformed message. */ int dnet_cterm_sc_connect_object(const uint8_t *buf, size_t len); +/* + * dnet_fal_access_decode - decode the ACCESS-CONTROL username+password+account + * from an inbound Session Control connect (rd vms-8c2, FAL/object-17). + * + * THE FAL COUNTERPART to dnet_cterm_sc_connect_parse, and its DELIBERATE + * OPPOSITE on one point: FAL authenticates from connect-time credentials + * (oracle docs/oracle/vax-copy-fal-dap.md §1 -- the COPY carries username + + * password in the access-control fields), so this decoder RETAINS the password + * into the caller's `password` buffer. The caller (the FAL server) MUST wipe + * that buffer the instant sysuaf_authenticate has consumed it. CTERM's parse + * keeps dropping the password; only FAL, which must, uses this entry -- so the + * "codec never retains a password" invariant the CTERM security case rests on + * is preserved for every non-FAL path. + * + * FULLY BOUNDED against attacker-controlled bytes (this runs before anyone has + * authenticated): never reads past buf[len-1], refuses (does not clip) an + * over-long counted field, and on ANY malformation leaves every output buffer + * EMPTY and returns a negative DNET_CTERM_E* -- no half-parsed credential can + * reach the authenticator. A connect that carries no access-control area + * returns DNET_CTERM_OK with all three buffers empty (the server then refuses: + * FAL admits no empty-credential session). Each cap must be >= 1; on success + * every buffer is NUL-terminated. + */ +int dnet_fal_access_decode(const uint8_t *buf, size_t len, + char *userid, size_t useridcap, + char *password, size_t passwordcap, + char *account, size_t accountcap); + /* * dnet_cterm_remote_port_info - render the VMS "Remote Port Info" string for a * decoded connect: "::", the shape the diff --git a/src/vmsdecnet/dap/CMakeLists.txt b/src/vmsdecnet/dap/CMakeLists.txt new file mode 100644 index 000000000..4124dc8af --- /dev/null +++ b/src/vmsdecnet/dap/CMakeLists.txt @@ -0,0 +1,21 @@ +# vmsdecnet DAP layer - DECnet Phase IV DAP (Data Access Protocol) message codec +# (rd vms-8c2, epic vms-30e; north-star demo leg vms-e4dc), the presentation +# layer behind $ COPY node::file and the FAL server (object 17). +# +# dnet_dap: the DAP message codec -- encode/decode of the CONFIGURATION, +# ATTRIBUTES, ACCESS, CONTROL, DATA, STATUS, NAME, ACCESS-COMPLETE message set +# the oracle's real VAX<->VAX COPY exercised. Like the NSP and CTERM codecs this +# is a PURE byte library: no socket, no allocation, no clock -- so it links +# equally into the daemon, the FAL server and the deterministic unit test. +# +# CLEAN-ROOM (Rule 8): the message TYPES + generic framing are the PUBLIC DEC +# DAP functional specification; the SEQUENCE and the carried values (filename, +# resolved full spec, owner UIC, verbatim records) are the ground truth of +# docs/oracle/vax-copy-fal-dap.* (rd vms-cd3). The per-field sub-framing is +# spec-derived and self-round-tripping, NOT asserted byte-identical to the +# real-VAX DAP sub-framing (oracle §3) -- byte-level VAX FAL interop is a filed +# follow-on. See dnet_dap.h for the full provenance note. +add_library(vmsdecnet_dap STATIC dnet_dap.c) +target_include_directories(vmsdecnet_dap PUBLIC + ${CMAKE_CURRENT_SOURCE_DIR}/include +) diff --git a/src/vmsdecnet/dap/dnet_dap.c b/src/vmsdecnet/dap/dnet_dap.c new file mode 100644 index 000000000..cd44a57a1 --- /dev/null +++ b/src/vmsdecnet/dap/dnet_dap.c @@ -0,0 +1,277 @@ +/* + * dnet_dap.c - DECnet Phase IV DAP message codec (rd vms-8c2). See dnet_dap.h + * for the clean-room provenance (Rule 8): the message set + generic framing are + * the PUBLIC DAP spec; the SEQUENCE and the carried values (filename, resolved + * full spec, owner UIC, verbatim records) are the docs/oracle/vax-copy-fal-dap.* + * ground truth. Pure byte library: no socket, no allocation, fully bounded. + * + * WIRE FRAMING (public spec generic message, LENGTH-present form this codec + * uses so a blocked NSP segment is walkable): + * + * OPERATOR(1) FLAGS(1) LENGTH(1) + * + * FLAGS is always DNET_DAP_FLAG_LENGTH here. LENGTH counts the body bytes that + * follow it (spec: the message length field counts the remaining message). A + * counted "image" field is 1 length byte + that many bytes (spec image field). + */ +#include "dnet_dap.h" + +#include + +/* ---- bounded body readers/writers (offset into a fixed body buffer) ------- */ + +static int body_get_u8(const uint8_t *b, size_t blen, size_t *off, uint8_t *v) +{ + if (*off + 1 > blen) return DNET_DAP_ETRUNC; + *v = b[*off]; *off += 1; return DNET_DAP_OK; +} + +static int body_get_u16(const uint8_t *b, size_t blen, size_t *off, uint16_t *v) +{ + if (*off + 2 > blen) return DNET_DAP_ETRUNC; + /* DAP integers are little-endian on the wire (VAX byte order). */ + *v = (uint16_t)(b[*off] | (b[*off + 1] << 8)); + *off += 2; return DNET_DAP_OK; +} + +static int body_get_u32(const uint8_t *b, size_t blen, size_t *off, uint32_t *v) +{ + if (*off + 4 > blen) return DNET_DAP_ETRUNC; + *v = (uint32_t)b[*off] | ((uint32_t)b[*off + 1] << 8) | + ((uint32_t)b[*off + 2] << 16) | ((uint32_t)b[*off + 3] << 24); + *off += 4; return DNET_DAP_OK; +} + +/* Read a counted image field into a NUL-terminated string. Refuses (does not + * clip) a field that would exceed `dstcap-1` or run past the body. */ +static int body_get_str(const uint8_t *b, size_t blen, size_t *off, + char *dst, size_t dstcap) +{ + uint8_t n; + int rc = body_get_u8(b, blen, off, &n); + if (rc != DNET_DAP_OK) return rc; + if ((size_t)n + 1 > dstcap) return DNET_DAP_EBADLEN; + if (*off + n > blen) return DNET_DAP_ETRUNC; + if (n) memcpy(dst, b + *off, n); + dst[n] = '\0'; + *off += n; + return DNET_DAP_OK; +} + +/* Read a counted binary field (records may carry NULs). */ +static int body_get_bytes(const uint8_t *b, size_t blen, size_t *off, + uint8_t *dst, size_t dstcap, uint16_t *outlen) +{ + uint16_t n; + int rc = body_get_u16(b, blen, off, &n); + if (rc != DNET_DAP_OK) return rc; + if (n > dstcap) return DNET_DAP_EBADLEN; + if (*off + n > blen) return DNET_DAP_ETRUNC; + if (n) memcpy(dst, b + *off, n); + *off += n; + *outlen = n; + return DNET_DAP_OK; +} + +static int put_u8(uint8_t *b, size_t cap, size_t *off, uint8_t v) +{ + if (*off + 1 > cap) return DNET_DAP_ENOSPACE; + b[*off] = v; *off += 1; return DNET_DAP_OK; +} +static int put_u16(uint8_t *b, size_t cap, size_t *off, uint16_t v) +{ + if (*off + 2 > cap) return DNET_DAP_ENOSPACE; + b[*off] = (uint8_t)(v & 0xff); b[*off + 1] = (uint8_t)(v >> 8); + *off += 2; return DNET_DAP_OK; +} +static int put_u32(uint8_t *b, size_t cap, size_t *off, uint32_t v) +{ + if (*off + 4 > cap) return DNET_DAP_ENOSPACE; + b[*off] = (uint8_t)(v & 0xff); b[*off + 1] = (uint8_t)((v >> 8) & 0xff); + b[*off + 2] = (uint8_t)((v >> 16) & 0xff); b[*off + 3] = (uint8_t)((v >> 24) & 0xff); + *off += 4; return DNET_DAP_OK; +} +static int put_str(uint8_t *b, size_t cap, size_t *off, const char *s) +{ + size_t n = s ? strlen(s) : 0; + if (n > 255) return DNET_DAP_EINVAL; + if (*off + 1 + n > cap) return DNET_DAP_ENOSPACE; + b[*off] = (uint8_t)n; + if (n) memcpy(b + *off + 1, s, n); + *off += 1 + n; + return DNET_DAP_OK; +} + +/* ---- encode -------------------------------------------------------------- */ + +int dnet_dap_encode(const struct dnet_dap_msg *msg, + uint8_t *buf, size_t cap, size_t *outlen) +{ + if (!msg || !buf) return DNET_DAP_EINVAL; + + uint8_t body[DNET_DAP_MAX_MSG]; + size_t boff = 0; + int rc = DNET_DAP_OK; + + switch (msg->op) { + case DNET_DAP_CONFIG: + rc = put_u16(body, sizeof body, &boff, msg->u.config.bufsiz); + if (!rc) rc = put_u8(body, sizeof body, &boff, msg->u.config.ostype); + if (!rc) rc = put_u8(body, sizeof body, &boff, msg->u.config.filesys); + if (!rc) rc = put_u8(body, sizeof body, &boff, msg->u.config.version); + break; + case DNET_DAP_ATTRIBUTES: + rc = put_u8(body, sizeof body, &boff, msg->u.attr.org); + if (!rc) rc = put_u8(body, sizeof body, &boff, msg->u.attr.rfm); + if (!rc) rc = put_u8(body, sizeof body, &boff, msg->u.attr.rat); + if (!rc) rc = put_u16(body, sizeof body, &boff, msg->u.attr.mrs); + if (!rc) rc = put_u32(body, sizeof body, &boff, msg->u.attr.alq); + break; + case DNET_DAP_ACCESS: + rc = put_u8(body, sizeof body, &boff, msg->u.access.accfunc); + if (!rc) rc = put_str(body, sizeof body, &boff, msg->u.access.filespec); + break; + case DNET_DAP_CONTROL: + rc = put_u8(body, sizeof body, &boff, msg->u.control.ctlfunc); + break; + case DNET_DAP_NAME: + rc = put_u8(body, sizeof body, &boff, msg->u.name.nametype); + if (!rc) rc = put_str(body, sizeof body, &boff, msg->u.name.namespec); + break; + case DNET_DAP_DATA: + if (msg->u.data.reclen > DNET_DAP_MAX_REC) return DNET_DAP_EINVAL; + rc = put_u16(body, sizeof body, &boff, msg->u.data.reclen); + if (!rc) { + if (boff + msg->u.data.reclen > sizeof body) return DNET_DAP_ENOSPACE; + if (msg->u.data.reclen) memcpy(body + boff, msg->u.data.rec, msg->u.data.reclen); + boff += msg->u.data.reclen; + } + break; + case DNET_DAP_STATUS: + rc = put_u16(body, sizeof body, &boff, msg->u.status.stscode); + break; + case DNET_DAP_ACCESS_COMPLETE: + case DNET_DAP_CONTINUE: + case DNET_DAP_ACKNOWLEDGE: + rc = put_u8(body, sizeof body, &boff, msg->u.complete.func); + break; + default: + return DNET_DAP_EINVAL; /* refuse to encode an unknown operator */ + } + if (rc != DNET_DAP_OK) return rc; + + /* body length must fit the single-byte LENGTH field of this codec's frame. */ + if (boff > 255) return DNET_DAP_ENOSPACE; + + size_t off = 0; + rc = put_u8(buf, cap, &off, (uint8_t)msg->op); + if (!rc) rc = put_u8(buf, cap, &off, DNET_DAP_FLAG_LENGTH); + if (!rc) rc = put_u8(buf, cap, &off, (uint8_t)boff); + if (rc != DNET_DAP_OK) return rc; + if (off + boff > cap) return DNET_DAP_ENOSPACE; + if (boff) memcpy(buf + off, body, boff); + off += boff; + + if (outlen) *outlen = off; + return DNET_DAP_OK; +} + +/* ---- decode -------------------------------------------------------------- */ + +int dnet_dap_decode(const uint8_t *buf, size_t len, + struct dnet_dap_msg *out, size_t *consumed) +{ + if (!buf || !out) return DNET_DAP_EINVAL; + memset(out, 0, sizeof(*out)); + + /* Generic header: OPERATOR, FLAGS, LENGTH. This codec only produces (and + * therefore only accepts) the LENGTH-present shape -- any other FLAGS is + * refused rather than guessed at. */ + if (len < 3) return DNET_DAP_ETRUNC; + uint8_t operator = buf[0]; + uint8_t flags = buf[1]; + uint8_t blen = buf[2]; + if (flags != DNET_DAP_FLAG_LENGTH) return DNET_DAP_EINVAL; + if ((size_t)3 + blen > len) return DNET_DAP_ETRUNC; + + const uint8_t *body = buf + 3; + size_t boff = 0; + int rc = DNET_DAP_OK; + + switch (operator) { + case DNET_DAP_CONFIG: + out->op = DNET_DAP_CONFIG; + rc = body_get_u16(body, blen, &boff, &out->u.config.bufsiz); + if (!rc) rc = body_get_u8(body, blen, &boff, &out->u.config.ostype); + if (!rc) rc = body_get_u8(body, blen, &boff, &out->u.config.filesys); + if (!rc) rc = body_get_u8(body, blen, &boff, &out->u.config.version); + break; + case DNET_DAP_ATTRIBUTES: + out->op = DNET_DAP_ATTRIBUTES; + rc = body_get_u8(body, blen, &boff, &out->u.attr.org); + if (!rc) rc = body_get_u8(body, blen, &boff, &out->u.attr.rfm); + if (!rc) rc = body_get_u8(body, blen, &boff, &out->u.attr.rat); + if (!rc) rc = body_get_u16(body, blen, &boff, &out->u.attr.mrs); + if (!rc) rc = body_get_u32(body, blen, &boff, &out->u.attr.alq); + break; + case DNET_DAP_ACCESS: + out->op = DNET_DAP_ACCESS; + rc = body_get_u8(body, blen, &boff, &out->u.access.accfunc); + if (!rc) rc = body_get_str(body, blen, &boff, out->u.access.filespec, + sizeof out->u.access.filespec); + break; + case DNET_DAP_CONTROL: + out->op = DNET_DAP_CONTROL; + rc = body_get_u8(body, blen, &boff, &out->u.control.ctlfunc); + break; + case DNET_DAP_NAME: + out->op = DNET_DAP_NAME; + rc = body_get_u8(body, blen, &boff, &out->u.name.nametype); + if (!rc) rc = body_get_str(body, blen, &boff, out->u.name.namespec, + sizeof out->u.name.namespec); + break; + case DNET_DAP_DATA: + out->op = DNET_DAP_DATA; + rc = body_get_bytes(body, blen, &boff, out->u.data.rec, + sizeof out->u.data.rec, &out->u.data.reclen); + break; + case DNET_DAP_STATUS: + out->op = DNET_DAP_STATUS; + rc = body_get_u16(body, blen, &boff, &out->u.status.stscode); + break; + case DNET_DAP_ACCESS_COMPLETE: + case DNET_DAP_CONTINUE: + case DNET_DAP_ACKNOWLEDGE: + out->op = (enum dnet_dap_op)operator; + rc = body_get_u8(body, blen, &boff, &out->u.complete.func); + break; + default: + /* A well-formed frame of an operator this rung does not serve: report + * it honestly (op UNKNOWN) and consume it so the caller can refuse the + * transfer cleanly -- never fault on it (INV-6, never crash a peer). */ + out->op = DNET_DAP_MSG_UNKNOWN; + rc = DNET_DAP_OK; + break; + } + if (rc != DNET_DAP_OK) { memset(out, 0, sizeof(*out)); return rc; } + + if (consumed) *consumed = (size_t)3 + blen; + return DNET_DAP_OK; +} + +const char *dnet_dap_op_name(enum dnet_dap_op op) +{ + switch (op) { + case DNET_DAP_CONFIG: return "CONFIGURATION"; + case DNET_DAP_ATTRIBUTES: return "ATTRIBUTES"; + case DNET_DAP_ACCESS: return "ACCESS"; + case DNET_DAP_CONTROL: return "CONTROL"; + case DNET_DAP_CONTINUE: return "CONTINUE"; + case DNET_DAP_ACKNOWLEDGE: return "ACKNOWLEDGE"; + case DNET_DAP_ACCESS_COMPLETE: return "ACCESS-COMPLETE"; + case DNET_DAP_DATA: return "DATA"; + case DNET_DAP_STATUS: return "STATUS"; + case DNET_DAP_NAME: return "NAME"; + case DNET_DAP_MSG_UNKNOWN: default: return "UNKNOWN"; + } +} diff --git a/src/vmsdecnet/dap/include/dnet_dap.h b/src/vmsdecnet/dap/include/dnet_dap.h new file mode 100644 index 000000000..77e4e75f3 --- /dev/null +++ b/src/vmsdecnet/dap/include/dnet_dap.h @@ -0,0 +1,213 @@ +/* + * dnet_dap.h - DECnet Phase IV DAP (Data Access Protocol) message codec, the + * presentation layer that rides an established NSP logical link to move a file + * (rd vms-8c2, epic vms-30e; north-star demo leg vms-e4dc). This is the layer + * behind `$ COPY node"user pw"::file localfile` and the FAL (File Access + * Listener, DECnet object 17) server. + * + * ================== CLEAN-ROOM PROVENANCE (Rule 8) ================== + * TWO sources, and every byte here traces to one of them -- nothing is invented + * and nothing comes from a VSI disassembly: + * + * 1. docs/oracle/vax-copy-fal-dap.{md,wire.txt,hex.txt} (rd vms-cd3) -- a REAL + * OpenVMS VAX V7.3 -> V7.3 `$ COPY` over DECnet, captured on the lab. It + * FIXES the ground truth this codec is forbidden to contradict: + * - the connect names Session Control OBJECT 17 (FAL) and CARRIES the + * username + password in the access-control fields (handled by the + * FAL server + the existing dnet_cterm_sc_connect_build, not here); + * - the message SEQUENCE over the link: CONFIGURATION (both ways) -> + * ATTRIBUTES/NAME (filename, resolved full spec, owner UIC, RMS + * attributes) -> CONTROL -> DATA (the file records VERBATIM) -> + * STATUS / ACCESS-COMPLETE -> clean NSP disconnect; + * - the file records travel VERBATIM as counted records inside DATA + * messages, and the resolved full spec / owner UIC travel as counted + * strings inside the attributes/name exchange (the oracle's hex shows + * "OVMXDAP_R.TXT;", "SYS$SYSROOT:[SYSMGR]OVMXDAP_R.TXT;1" and + * "[000001,000004]" in the clear, locatable by their ASCII). + * + * 2. The PUBLIC DEC DAP (Data Access Protocol) functional specification + * (AA-K177A-TK and successors) -- the message TYPES (CONFIGURATION, + * ATTRIBUTES, ACCESS, CONTROL, CONTINUE, ACKNOWLEDGE, ACCESS COMPLETE, + * DATA, STATUS, NAME) and the generic message framing (an OPERATOR byte, a + * FLAGS byte, an optional LENGTH field, then typed fields; counted "image" + * fields for strings; extensible bitmap fields). + * + * HONEST SCOPE (INV-6). The oracle §3 states plainly that it fixes the ground- + * truth BYTES and the credential/object/SEQUENCE semantics but "does not hand- + * transcribe every DAP sub-field". So the per-field FRAMING of each message + * here is coded against the PUBLIC SPEC (source 2) and is self-round-tripping; + * it is NOT asserted byte-identical to the real-VAX DAP sub-framing. Two OVMX + * nodes interoperate over this codec faithfully (the sequence + the carried + * values are the oracle's); byte-level wire interop with a stock VAX FAL is a + * separate, harder rung (exact VAX DAP sub-field reversing beyond the oracle's + * transcription) and is a FILED follow-on, never faked as done here. + * + * PURITY / SECURITY. Like the NSP and CTERM codecs, this is a PURE byte library: + * no socket, no fd, no clock, no allocation beyond memcpy/memset. It links + * equally into the daemon, the FAL server and the deterministic unit test. + * EVERY decode path is fully BOUNDED against hostile input: DAP rides an NSP + * link that, on the inbound (FAL) side, an unauthenticated attacker can drive, + * so a malformed message must be REJECTED cleanly (a negative code the caller + * turns into an NSP disconnect), never over-read -- "OVMX never crashes a peer", + * both directions. The decoder never reads past buf[len-1] and refuses (does + * not clip) an over-long counted field. + */ +#ifndef DNET_DAP_H +#define DNET_DAP_H + +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +/* DAP message OPERATOR (type) codes -- the public DAP specification message + * set. Only the members this rung's sequential-file COPY needs are served; + * others decode to DNET_DAP_MSG_UNKNOWN honestly rather than being faked. */ +enum dnet_dap_op { + DNET_DAP_CONFIG = 1, /* CONFIGURATION: buffer size, OS/filesys, version */ + DNET_DAP_ATTRIBUTES = 2, /* ATTRIBUTES: RMS org/rfm/rat/mrs/allocation */ + DNET_DAP_ACCESS = 3, /* ACCESS: open/create a file by name */ + DNET_DAP_CONTROL = 4, /* CONTROL: initiate data transfer (GET/PUT) */ + DNET_DAP_CONTINUE = 5, /* CONTINUE-TRANSFER: resume/skip/ack */ + DNET_DAP_ACKNOWLEDGE = 6, /* ACKNOWLEDGE: positive ack of ACCESS/CONTROL */ + DNET_DAP_ACCESS_COMPLETE = 7,/* ACCESS COMPLETE: end of file access */ + DNET_DAP_DATA = 8, /* DATA: one file record, verbatim */ + DNET_DAP_STATUS = 9, /* STATUS: MACRO/MICRO condition (RMS-style) */ + DNET_DAP_NAME = 10, /* NAME: resolved full spec / owner (attribute set)*/ + DNET_DAP_MSG_UNKNOWN = 0 /* decoded a known-format frame of an unserved op */ +}; + +/* DAP FLAGS byte bits (public spec). This codec always emits LENGTH-present so + * every message is self-delimiting on the wire -- the property that makes the + * bounded decoder possible when several messages ride one NSP segment. */ +#define DNET_DAP_FLAG_STREAMID 0x01 +#define DNET_DAP_FLAG_LENGTH 0x02 +#define DNET_DAP_FLAG_LEN256 0x04 +#define DNET_DAP_FLAG_BITCNT 0x08 + +/* CONTROL CTLFUNC values (public spec: the operation the transfer performs). */ +#define DNET_DAP_CTL_GET 1 /* transfer records FROM the remote file (read) */ +#define DNET_DAP_CTL_PUT 3 /* transfer records TO the remote file (write) */ +#define DNET_DAP_CTL_CONNECT 8 /* establish the record stream */ + +/* ACCESS ACCFUNC values (public spec). */ +#define DNET_DAP_ACC_OPEN 1 /* open an existing file (a GET/read source) */ +#define DNET_DAP_ACC_CREATE 2 /* create a file (a PUT/write sink) */ + +/* RMS file-organization / record-format bytes carried in ATTRIBUTES (public + * spec ORG/RFM). This rung serves SEQUENTIAL, variable-length records -- the + * oracle's captured case. Other org/rfm decode honestly and are refused with + * DNET_DAP_EUNSUP by the server (INV-6: indexed/relative are filed follow-ons). */ +#define DNET_DAP_ORG_SEQ 0x00 +#define DNET_DAP_RFM_VAR 0x02 /* variable-length records */ +#define DNET_DAP_RFM_STMLF 0x05 /* stream-LF */ + +/* Field caps. A DAP filespec / record longer than these is REFUSED (not + * clipped) by the bounded decoder -- a clipped filespec that happens to resolve + * is exactly the class of bug this decoder must not have. */ +#define DNET_DAP_MAX_SPEC 255 /* a counted DAP image field is 1..255 bytes */ +#define DNET_DAP_MAX_REC 512 /* one sequential record this rung carries */ +#define DNET_DAP_MAX_MSG 600 /* an encoded message never exceeds this */ + +/* Decode / encode return codes. OK is 0; every error is negative so a caller + * can `if (rc < 0)` and turn it into an NSP disconnect (INV-6, never crash). */ +#define DNET_DAP_OK 0 +#define DNET_DAP_ETRUNC (-1) /* message runs past the buffer end */ +#define DNET_DAP_EBADLEN (-2) /* a counted field exceeds its cap / the msg */ +#define DNET_DAP_EINVAL (-3) /* malformed / unsupported framing */ +#define DNET_DAP_ENOSPACE (-4) /* encode: output buffer too small */ +#define DNET_DAP_EUNSUP (-5) /* well-formed but an unserved feature */ + +/* + * A decoded DAP message. Everything reachable on the FAL (inbound) side is + * UNTRUSTED input. The union is discriminated by `op`; only the members named + * for that op are meaningful. + */ +struct dnet_dap_msg { + enum dnet_dap_op op; + + union { + struct { /* CONFIGURATION */ + uint16_t bufsiz; /* buffer size the peer offers */ + uint8_t ostype; /* OS type (OVMX identifies as VMS) */ + uint8_t filesys; /* file system (RMS) */ + uint8_t version; /* DAP version (root) */ + } config; + + struct { /* ATTRIBUTES */ + uint8_t org; /* file organization (SEQ served) */ + uint8_t rfm; /* record format (VAR/STMLF served) */ + uint8_t rat; /* record attributes (CR carriage-control) */ + uint16_t mrs; /* maximum record size */ + uint32_t alq; /* allocation quantity (blocks/size hint) */ + } attr; + + struct { /* ACCESS */ + uint8_t accfunc; /* DNET_DAP_ACC_* */ + char filespec[DNET_DAP_MAX_SPEC + 1]; /* the file being accessed */ + } access; + + struct { /* CONTROL */ + uint8_t ctlfunc; /* DNET_DAP_CTL_* */ + } control; + + struct { /* NAME */ + uint8_t nametype; /* 1 = full file spec, 2 = owner UIC */ + char namespec[DNET_DAP_MAX_SPEC + 1]; /* resolved spec / [g,m] */ + } name; + + struct { /* DATA */ + uint16_t reclen; /* record length (bounded by MAX_REC) */ + uint8_t rec[DNET_DAP_MAX_REC]; /* the record bytes, VERBATIM */ + } data; + + struct { /* STATUS */ + uint16_t stscode; /* MACRO<<12 | MICRO (RMS-style condition)*/ + } status; + + struct { /* ACCESS COMPLETE / CONTINUE / ACK */ + uint8_t func; /* completion / continue function code */ + } complete; + } u; +}; + +/* DAP STATUS codes this rung uses (public spec MACRO/MICRO split). SUCCESS is + * the "operation completed" macro; the specific micro values are OVMX-chosen + * within the spec's ranges and only carried between two OVMX nodes. */ +#define DNET_DAP_STS_SUCCESS 0x0000 /* pending / normal */ +#define DNET_DAP_STS_EOF 0x0A00 /* end of file on a GET */ +#define DNET_DAP_STS_ACCFAIL 0x2800 /* access denied / could not open the file */ + +/* + * dnet_dap_encode - encode `msg` into `buf` (OPERATOR .. end of body). Writes + * the byte count to *outlen. Returns DNET_DAP_OK, DNET_DAP_ENOSPACE if `cap` is + * too small, or DNET_DAP_EINVAL on a malformed message. Every encoder emits a + * LENGTH-present frame so the decoder can walk a blocked stream. + */ +int dnet_dap_encode(const struct dnet_dap_msg *msg, + uint8_t *buf, size_t cap, size_t *outlen); + +/* + * dnet_dap_decode - decode ONE DAP message from `buf`/`len` into `out`. Fully + * bounded: never reads past buf[len-1]; refuses (does not clip) an over-long + * counted field or a LENGTH that overruns the buffer; refuses an unknown FLAGS + * shape. On success sets *consumed to the bytes this message occupied (so the + * caller can decode the next message in a blocked NSP segment) and returns + * DNET_DAP_OK. *out is zeroed first, so a failure leaves nothing half-filled. + * A well-formed message of an unserved OPERATOR decodes with op set to that + * value (or DNET_DAP_MSG_UNKNOWN) and consumed advanced -- the caller decides + * whether to serve or honestly refuse it, the decoder never faults on it. + */ +int dnet_dap_decode(const uint8_t *buf, size_t len, + struct dnet_dap_msg *out, size_t *consumed); + +/* Human name of an operator, for logs. Never NULL. */ +const char *dnet_dap_op_name(enum dnet_dap_op op); + +#ifdef __cplusplus +} +#endif + +#endif /* DNET_DAP_H */ diff --git a/src/vmsdecnet/engine/CMakeLists.txt b/src/vmsdecnet/engine/CMakeLists.txt index ec8b0f0ca..c90e15a4f 100644 --- a/src/vmsdecnet/engine/CMakeLists.txt +++ b/src/vmsdecnet/engine/CMakeLists.txt @@ -34,7 +34,9 @@ target_include_directories(decnetd_exe PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/include ${CMAKE_SOURCE_DIR}/src/libdatalink/include # scs_datalink.h ${CMAKE_SOURCE_DIR}/src/vmsdecnet/cterm/include # dnet_cterm.h (--set-host-selftest) - ${CMAKE_SOURCE_DIR}/src/libvms/include # ovmx_identity.h (INV-1 banner SSOT) + ${CMAKE_SOURCE_DIR}/src/vmsdecnet/dap/include # dnet_dap.h (--fal-* COPY presentation) + ${CMAKE_SOURCE_DIR}/src/vmsdecnet/fal/include # dnet_fal.h (--fal-* FAL server + client) + ${CMAKE_SOURCE_DIR}/src/libvms/include # ovmx_identity.h, rms_textfile.h, ssdef.h ${CMAKE_SOURCE_DIR}/src/vmsfs/include # transitive header of ovmx_identity.h ) # vmsdecnet_cterm: the CTERM terminal-service protocol (rd vms-4d2) that rides on @@ -46,8 +48,16 @@ target_include_directories(decnetd_exe PRIVATE # system services) and libvmssys (the executive interface); the daemon itself # still contains no fork/exec/openpty (gated by # tests/integration/test_creprc_session_primitive.sh). +# vmsdecnet_fal (rd vms-8c2): the FAL server (object 17) + the COPY node:: client +# behind $ COPY node"user pw"::file. It authenticates the connect-carried creds +# (real SYSUAF/Purdy, in libvms) and moves the file through RMS over the ACP; it +# pulls in vmsdecnet_dap (the DAP codec). The --fal-* proofs run the server and +# client as two blocking peers, so decnetd links Threads. +find_package(Threads REQUIRED) target_link_libraries(decnetd_exe PRIVATE vmsdecnet_engine vmsdecnet_cterm - vmsdecnet_cterm_host ovmx_datalink) + vmsdecnet_cterm_host vmsdecnet_fal + vmsdecnet_dap ovmx_datalink + Threads::Threads) target_include_directories(decnetd_exe PRIVATE ${CMAKE_SOURCE_DIR}/src/libvmssys # vms_kif.h ($GETDVI readback) ) diff --git a/src/vmsdecnet/engine/decnetd.c b/src/vmsdecnet/engine/decnetd.c index bb129a67d..d338bcc3b 100644 --- a/src/vmsdecnet/engine/decnetd.c +++ b/src/vmsdecnet/engine/decnetd.c @@ -48,6 +48,7 @@ #include #include /* if_nametoindex() */ #include /* the --cterm-server loop waits on wire + session */ +#include /* --fal-accept-test / --fal-selftest: two blocking peers */ #include #include #include @@ -60,6 +61,9 @@ #include "dnet_engine.h" #include "dnet_cterm.h" /* CTERM terminal-service protocol (--set-host-selftest) */ #include "dnet_cterm_host.h" /* CTERM HOST session: $CREPRC -> LOGINOUT on RTAn: */ +#include "dnet_dap.h" /* DAP message codec (--fal-* : COPY presentation layer) */ +#include "dnet_fal.h" /* FAL server + COPY client (object 17, rd vms-8c2) */ +#include "rms_textfile.h" /* --fal-accept-test byte-verify: RMS over the ACP */ #include "ovmx_identity.h" /* INV-1 identity SSOT: human banner = OVMX product id */ #include "scs_datalink.h" /* the shared raw-L2 datalink (src/libdatalink) */ #include "ssdef.h" /* SS$_BADPARAM (isolation-seam refusal, vms-9ab) */ @@ -1618,6 +1622,366 @@ static int run_set_host_loop(struct dnet_engine *eng, int sock, unsigned ifindex return rc; } +/* + * ============== --fal-selftest / --fal-accept-test (rd vms-8c2) ============== + * The DECnet FILE ACCESS LISTENER (object 17) + the COPY node:: client, proven + * over a real NSP logical link (Ethernet + Phase IV routing header + NSP PDU) + * moved over a socketpair(2) -- the same wire path DECNETD uses on the live + * datalink, with the two blocking peers (FAL server + COPY client) running in + * two threads so their choreography is real, not stepped by the test. + * + * WHAT EACH PROVES, AND WHERE IT IS A HARD GATE: + * --fal-selftest NO executive needed, runs anywhere (the honest floor, + * like vms-b19 for SET HOST): (A) a COPY client emits a + * real object-17 Connect Initiate CARRYING the access- + * control username+password, and FAL REFUSES it with an + * NSP Disconnect when the credentials cannot be + * authenticated (no /dev/vms -> sysuaf_lookup fails -> + * SS$_INVLOGIN -> reject); (B) the DAP-over-NSP transport + * pump itself -- CONFIGURATION exchange + ACCESS + an + * honest STATUS(access-failed) for a missing file -- + * round-trips end to end over the threaded socketpair. + * --fal-accept-test The FULL transfer, a HARD GATE wherever /dev/vms + the + * mounted ODS-2 SYSUAF are present (the booted image): + * real SYSUAF/Purdy auth (GUEST/GUEST accepted, a wrong + * password REFUSED, DISABLED refused by DISUSER), then a + * sequential file transferred BOTH directions (PUT then + * GET) through real DAP over the link and real RMS over + * the ACP, byte-verified. It FAILS honestly where the + * executive/SYSUAF is absent (INV-6) -- it does not + * degrade to a stub. + */ +struct fal_xport { + struct dnet_engine *eng; /* this end's engine (owns the one link) */ + int wfd, rfd; /* this end's socketpair descriptors */ + dnet_tick_t *tick; /* shared monotonic tick (per test run) */ +}; + +/* Ship one DAP message as an NSP data segment on the link. */ +static int fal_xport_send(void *ctx, const struct dnet_dap_msg *m) +{ + struct fal_xport *x = ctx; + uint8_t dap[DNET_DAP_MAX_MSG], frame[DNET_FRAME_MAX]; + size_t daplen = 0, flen = 0; + if (dnet_dap_encode(m, dap, sizeof dap, &daplen) != DNET_DAP_OK) return -1; + if (dnet_engine_link_send(x->eng, dap, daplen, frame, sizeof frame, &flen, + (*x->tick)++) != 0) + return -1; + if (write(x->wfd, frame, flen) != (ssize_t)flen) return -1; + return 0; +} + +/* Receive the next DAP message. Absorbs NSP acks and ships the ack owed for a + * received data segment (real NSP flow), so the caller sees only DAP messages. + * Returns 0 with *m filled, or -1 on a closed link / decode failure. */ +static int fal_xport_recv(void *ctx, struct dnet_dap_msg *m) +{ + struct fal_xport *x = ctx; + uint8_t rxbuf[DNET_FRAME_MAX], reply[DNET_FRAME_MAX]; + for (;;) { + ssize_t n = read(x->rfd, rxbuf, sizeof rxbuf); + if (n <= 0) return -1; + size_t rlen = 0; int has_reply = 0; + enum dnet_link_event ev = DNET_LINK_EV_NONE; + if (dnet_engine_link_rx(x->eng, (*x->tick)++, rxbuf, (size_t)n, + reply, sizeof reply, &rlen, &has_reply, &ev) != 0) + return -1; + if (has_reply && write(x->wfd, reply, rlen) != (ssize_t)rlen) return -1; + if (ev == DNET_LINK_EV_DATA) { + size_t consumed = 0; + if (dnet_dap_decode(x->eng->rx_data, x->eng->rx_datalen, m, &consumed) + != DNET_DAP_OK) + return -1; + return 0; + } + if (ev == DNET_LINK_EV_DISCONNECT || ev == DNET_LINK_EV_DISCONNECT_CONF) + return -1; + /* ACK / NONE / connect events: absorb and keep reading. */ + } +} + +/* + * Bring up an object-17 link L->R carrying the access-control creds, and run + * FAL's connect-time auth gate on R. Returns 0 and leaves the link UP (both + * ends) when auth PASSED and R accepted; returns 1 (link refused, R sent a + * Disconnect Initiate that L saw) when auth FAILED; -1 on a wire error. + */ +static int fal_bringup(struct dnet_engine *L, struct dnet_engine *R, + int sv0, int sv1, dnet_tick_t *tick, + const char *user, const char *pass, uint32_t *auth_out) +{ + uint8_t conn[128], frame[DNET_FRAME_MAX], rxbuf[DNET_FRAME_MAX], reply[DNET_FRAME_MAX]; + size_t clen = 0, flen = 0, rxlen = 0, rlen = 0; + int has_reply = 0; + enum dnet_link_event ev = DNET_LINK_EV_NONE; + + /* The COPY client puts the NODE"user pw":: access string on the connect in + * the oracle's tag positions (object 17, format-0 dst; format-2 src; the + * access-control userid/password/account) via the proven builder. */ + if (dnet_cterm_sc_connect_build(DNET_OBJ_FAL, "OVMXL", 0x021a, 0x2020, + user, pass, "", conn, sizeof conn, &clen) != 0) + return -1; + if (dnet_engine_link_open(L, 1, 11, 0x2001, conn, clen, 1459, 1, + DNET_NSP_VER_41, frame, sizeof frame, &flen, (*tick)++) != 0 || + move_frame(sv0, sv1, frame, flen, rxbuf, sizeof rxbuf, &rxlen) != 0 || + dnet_engine_link_rx(R, (*tick)++, rxbuf, rxlen, reply, sizeof reply, &rlen, + &has_reply, &ev) != 0 || ev != DNET_LINK_EV_CONNECT_IND) + return -1; + + /* R is FAL: authenticate the connect BEFORE accepting (INV-6 -- no file is + * served on an unauthenticated connect). Bounded credentials never wiped + * before use are the FAL analogue of the CTERM no-auth gate. */ + char who[DNET_FAL_USER_MAX + 1]; + uint32_t auth = dnet_fal_connect_auth(R->link.conn_data, R->link.conn_len, + who, sizeof who); + if (auth_out) *auth_out = auth; + + if (auth != SS$_NORMAL) { + /* Refuse: Disconnect Initiate (object rejected connect), L sees it. */ + if (dnet_engine_link_close(R, DNET_LINK_REASON_OBJREJ, reply, sizeof reply, + &rlen, (*tick)++) != 0 || + move_frame(sv1, sv0, reply, rlen, rxbuf, sizeof rxbuf, &rxlen) != 0 || + dnet_engine_link_rx(L, (*tick)++, rxbuf, rxlen, frame, sizeof frame, + &flen, &has_reply, &ev) != 0) + return -1; + return 1; /* honest refusal proven */ + } + + /* Auth OK: accept -> Connect Confirm -> L sees the link RUN. */ + if (dnet_engine_link_accept(R, 0x2002, reply, sizeof reply, &rlen, (*tick)++) != 0 || + move_frame(sv1, sv0, reply, rlen, rxbuf, sizeof rxbuf, &rxlen) != 0 || + dnet_engine_link_rx(L, (*tick)++, rxbuf, rxlen, frame, sizeof frame, &flen, + &has_reply, &ev) != 0 || ev != DNET_LINK_EV_CONNECT_CONF || + !dnet_link_is_up(&L->link) || !dnet_link_is_up(&R->link)) + return -1; + return 0; /* link UP */ +} + +/* Thread body: the FAL server side of one accepted session. */ +struct fal_server_arg { struct fal_xport xp; uint32_t status; }; +static void *fal_server_thread(void *v) +{ + struct fal_server_arg *a = v; + struct dnet_dap_transport t = { fal_xport_send, fal_xport_recv, &a->xp }; + a->status = dnet_fal_server_run(&t); + return NULL; +} + +static int run_fal_selftest(void) +{ + int sv[2]; + if (socketpair(AF_UNIX, SOCK_DGRAM, 0, sv) != 0) { + fprintf(stderr, "DECNETD-E-FALSELF, socketpair failed: %s\n", strerror(errno)); + return 1; + } + const uint8_t hwL[6] = { 0x02,0,0,0,0,0x0a }; + const uint8_t hwR[6] = { 0x02,0,0,0,0,0x0b }; + struct dnet_engine L, R; + if (dnet_engine_init(&L, 1, 10, "OVMXL", "EWA0", NULL, hwL, 0, 0, 0) != 0 || + dnet_engine_init(&R, 1, 11, "OVMXR", "EWA0", NULL, hwR, 0, 0, 0) != 0) { + fprintf(stderr, "DECNETD-E-FALSELF, engine init failed\n"); + close(sv[0]); close(sv[1]); return 1; + } + int pass = 0, fail = 0; + dnet_tick_t tick = 100; + + /* (A) THE HONEST FLOOR: a real object-17 connect carrying creds is REFUSED + * with an NSP disconnect when the credentials cannot be authenticated (no + * executive here, so sysuaf_lookup fails -> SS$_INVLOGIN). */ + uint32_t auth = 0; + int br = fal_bringup(&L, &R, sv[0], sv[1], &tick, "GUEST", "GUEST", &auth); + if (br == 1 && auth != SS$_NORMAL) { + printf("DECNETD-I-FALSELF, object-17 connect carried the access-control" + " creds and FAL REFUSED it (status %08X) with an NSP disconnect --" + " no file served on an unauthenticated connect (INV-6)\n", auth); + pass++; + } else { + printf("DECNETD-E-FALSELF, expected an honest refusal of the unauthenticated" + " connect, got bringup=%d auth=%08X\n", br, auth); + fail++; + } + + /* (B) THE TRANSPORT PUMP: bring a link UP bypassing the auth gate (this half + * proves the DAP-over-NSP threaded transport, not auth), and run a GET of a + * file the server cannot open (no ACP volume here) -- CONFIGURATION + ACCESS + * + an honest STATUS(access-failed) must round-trip end to end, and both + * peers return the honest miss. Fresh engines + a fresh socketpair: sub-test + * A left its engines with a closed (rejected) link. */ + int sv2[2]; + if (socketpair(AF_UNIX, SOCK_DGRAM, 0, sv2) != 0) { close(sv[0]); close(sv[1]); return 1; } + struct dnet_engine L2, R2; + dnet_engine_init(&L2, 1, 10, "OVMXL", "EWA0", NULL, hwL, 0, 0, 0); + dnet_engine_init(&R2, 1, 11, "OVMXR", "EWA0", NULL, hwR, 0, 0, 0); + uint8_t frame[DNET_FRAME_MAX], rxbuf[DNET_FRAME_MAX], reply[DNET_FRAME_MAX]; + size_t flen = 0, rxlen = 0, rlen = 0; int has_reply = 0; + enum dnet_link_event ev = DNET_LINK_EV_NONE; + if (dnet_engine_link_open(&L2, 1, 11, 0x2003, NULL, 0, 1459, 1, DNET_NSP_VER_41, + frame, sizeof frame, &flen, tick++) == 0 && + move_frame(sv2[0], sv2[1], frame, flen, rxbuf, sizeof rxbuf, &rxlen) == 0 && + dnet_engine_link_rx(&R2, tick++, rxbuf, rxlen, reply, sizeof reply, &rlen, + &has_reply, &ev) == 0 && ev == DNET_LINK_EV_CONNECT_IND && + dnet_engine_link_accept(&R2, 0x2004, reply, sizeof reply, &rlen, tick++) == 0 && + move_frame(sv2[1], sv2[0], reply, rlen, rxbuf, sizeof rxbuf, &rxlen) == 0 && + dnet_engine_link_rx(&L2, tick++, rxbuf, rxlen, frame, sizeof frame, &flen, + &has_reply, &ev) == 0 && dnet_link_is_up(&L2.link)) { + struct fal_server_arg sarg = { { &R2, sv2[1], sv2[1], &tick }, 0 }; + pthread_t th; + if (pthread_create(&th, NULL, fal_server_thread, &sarg) == 0) { + struct fal_xport cxp = { &L2, sv2[0], sv2[0], &tick }; + struct dnet_dap_transport ct = { fal_xport_send, fal_xport_recv, &cxp }; + uint32_t cst = dnet_fal_client_get("OVMXR::DKA0:[X]NOPE.TXT", + "DKA0:[X]LOCAL.TXT", &ct); + pthread_join(th, NULL); + if (cst == SS$_NOSUCHFILE && sarg.status == SS$_NOSUCHFILE) { + printf("DECNETD-I-FALSELF, the DAP-over-NSP transport pump round-trips" + " end to end over the threaded socketpair: CONFIGURATION +" + " ACCESS + honest STATUS(access-failed) for a missing file," + " both peers return the honest miss\n"); + pass++; + } else { + printf("DECNETD-E-FALSELF, transport pump did not return the honest" + " miss (client %08X server %08X)\n", cst, sarg.status); + fail++; + } + } else { fail++; } + } else { + printf("DECNETD-E-FALSELF, could not bring the pump-test link up\n"); + fail++; + } + close(sv2[0]); close(sv2[1]); + + close(sv[0]); close(sv[1]); + printf("DECNETD-I-FALSELF, %d passed, %d failed\n", pass, fail); + if (fail == 0 && pass == 2) { printf("DECNETD-FAL-SELFTEST: PASS\n"); return 0; } + printf("DECNETD-FAL-SELFTEST: FAIL\n"); + return 1; +} + +/* Compare a stored ODS-2 file's records to an expected multi-line body. + * Returns 1 on an exact match. Reads through RMS over the ACP (real file I/O). */ +static int fal_file_matches(const char *spec, const char *const *lines, int nlines) +{ + rms_textfile_t *tf = rms_textfile_open(spec); + if (!tf) return 0; + char buf[DNET_DAP_MAX_REC]; int too_long = 0, i = 0, ok = 1; + while (rms_textfile_getline(tf, buf, sizeof buf, &too_long)) { + if (i >= nlines || strcmp(buf, lines[i]) != 0) { ok = 0; break; } + i++; + } + rms_textfile_close(tf); + return ok && i == nlines; +} + +static int run_fal_accept_test(void) +{ + printf("DECNETD-I-FALACCEPT, inbound FAL (object 17) COPY -> real SYSUAF auth" + " -> DAP/RMS transfer both directions (rd vms-8c2; oracle" + " docs/oracle/vax-copy-fal-dap.*)\n"); + int pass = 0, fail = 0; +#define FA_CHECK(c, msg) do { if (c) { pass++; } \ + else { fail++; printf(" FAIL: %s\n", msg); } } while (0) + + /* 1) AUTH IS REAL (the security core): the same SYSUAF/Purdy path LOGINOUT + * uses. A fake would pass the wrong password; only a real Purdy verify + * against the stored quadword refuses it. Fixtures are the shipped seed + * accounts (tools/mksysuaf.c): GUEST/GUEST valid; DISABLED/DISABLED valid + * password but DISUSER. */ + FA_CHECK(dnet_fal_authenticate("GUEST", "GUEST") == SS$_NORMAL, + "GUEST with the correct password authenticates (real SYSUAF/Purdy)"); + FA_CHECK(dnet_fal_authenticate("GUEST", "WRONGPW") == SS$_INVLOGIN, + "GUEST with a WRONG password is REFUSED (SS$_INVLOGIN) -- a fake would pass it"); + FA_CHECK(dnet_fal_authenticate("NOSUCHUSER99", "x") == SS$_INVLOGIN, + "a nonexistent account is refused, indistinguishably from a bad password"); + FA_CHECK(dnet_fal_authenticate("DISABLED", "DISABLED") == SS$_NOPRIV, + "DISABLED (correct password, DISUSER) is REFUSED -- a right password is not sufficient"); + + const uint8_t hwL[6] = { 0x02,0,0,0,0,0x0a }; + const uint8_t hwR[6] = { 0x02,0,0,0,0,0x0b }; + + /* 2) A COPY with a BAD password is REFUSED at connect over a real link + * (NSP disconnect, no session, no file). */ + { + int sv[2]; socketpair(AF_UNIX, SOCK_DGRAM, 0, sv); + struct dnet_engine L, R; dnet_tick_t tick = 100; uint32_t auth = 0; + dnet_engine_init(&L, 1, 10, "OVMXL", "EWA0", NULL, hwL, 0, 0, 0); + dnet_engine_init(&R, 1, 11, "OVMXR", "EWA0", NULL, hwR, 0, 0, 0); + int br = fal_bringup(&L, &R, sv[0], sv[1], &tick, "GUEST", "WRONGPW", &auth); + FA_CHECK(br == 1 && auth == SS$_INVLOGIN, + "a COPY with a BAD password is REFUSED with an NSP disconnect (no file served)"); + close(sv[0]); close(sv[1]); + } + + /* 3) A COPY with the CORRECT creds transfers a sequential file BOTH + * directions, byte-verified through real RMS over the ACP. */ + static const char *src_lines[] = { + "Hello from OVMXL node 1.10 - DAP/FAL transfer line one", + "Second line for a multi-record DAP data transfer", + "Third and final record" + }; + const int nsrc = 3; + const char *SRC = "SYS$SYSROOT:[SYSMGR]OVMXFAL_S.TXT"; + const char *DEST = "SYS$SYSROOT:[SYSMGR]OVMXFAL_D.TXT"; + const char *BACK = "SYS$SYSROOT:[SYSMGR]OVMXFAL_B.TXT"; + + /* Lay down the source file on the ODS-2 volume via RMS. */ + int src_ok = (rms_textfile_write_line(SRC, src_lines[0]) == 0) && + (rms_textfile_append_line(SRC, src_lines[1]) == 0) && + (rms_textfile_append_line(SRC, src_lines[2]) == 0); + FA_CHECK(src_ok, "source file created on the ODS-2 volume via RMS over the ACP"); + + /* PUT: L copies SRC to the remote FAL, which stores it as DEST. */ + if (src_ok) { + int sv[2]; socketpair(AF_UNIX, SOCK_DGRAM, 0, sv); + struct dnet_engine L, R; dnet_tick_t tick = 200; uint32_t auth = 0; + dnet_engine_init(&L, 1, 10, "OVMXL", "EWA0", NULL, hwL, 0, 0, 0); + dnet_engine_init(&R, 1, 11, "OVMXR", "EWA0", NULL, hwR, 0, 0, 0); + int br = fal_bringup(&L, &R, sv[0], sv[1], &tick, "GUEST", "GUEST", &auth); + FA_CHECK(br == 0 && auth == SS$_NORMAL, "PUT: GUEST/GUEST connect accepted, link UP"); + if (br == 0) { + struct fal_server_arg sarg = { { &R, sv[1], sv[1], &tick }, 0 }; + pthread_t th; pthread_create(&th, NULL, fal_server_thread, &sarg); + struct fal_xport cxp = { &L, sv[0], sv[0], &tick }; + struct dnet_dap_transport ct = { fal_xport_send, fal_xport_recv, &cxp }; + uint32_t cst = dnet_fal_client_put(SRC, DEST, &ct); + pthread_join(th, NULL); + FA_CHECK(cst == SS$_NORMAL && sarg.status == SS$_NORMAL, + "PUT: DAP transfer completed on both peers"); + FA_CHECK(fal_file_matches(DEST, src_lines, nsrc), + "PUT: the STORED file's records BYTE-MATCH the source (real transfer)"); + } + close(sv[0]); close(sv[1]); + } + + /* GET: L copies DEST back from the remote FAL into BACK; byte-verify. */ + { + int sv[2]; socketpair(AF_UNIX, SOCK_DGRAM, 0, sv); + struct dnet_engine L, R; dnet_tick_t tick = 300; uint32_t auth = 0; + dnet_engine_init(&L, 1, 10, "OVMXL", "EWA0", NULL, hwL, 0, 0, 0); + dnet_engine_init(&R, 1, 11, "OVMXR", "EWA0", NULL, hwR, 0, 0, 0); + int br = fal_bringup(&L, &R, sv[0], sv[1], &tick, "GUEST", "GUEST", &auth); + FA_CHECK(br == 0 && auth == SS$_NORMAL, "GET: GUEST/GUEST connect accepted, link UP"); + if (br == 0) { + struct fal_server_arg sarg = { { &R, sv[1], sv[1], &tick }, 0 }; + pthread_t th; pthread_create(&th, NULL, fal_server_thread, &sarg); + struct fal_xport cxp = { &L, sv[0], sv[0], &tick }; + struct dnet_dap_transport ct = { fal_xport_send, fal_xport_recv, &cxp }; + uint32_t cst = dnet_fal_client_get(DEST, BACK, &ct); + pthread_join(th, NULL); + FA_CHECK(cst == SS$_NORMAL && sarg.status == SS$_NORMAL, + "GET: DAP transfer completed on both peers"); + FA_CHECK(fal_file_matches(BACK, src_lines, nsrc), + "GET: the FETCHED file's records BYTE-MATCH the source (real transfer)"); + } + close(sv[0]); close(sv[1]); + } + + printf("DECNETD-I-FALACCEPT, %d passed, %d failed\n", pass, fail); + if (fail == 0 && pass > 0) { printf("DECNETD-FAL-ACCEPT: PASS\n"); return 0; } + printf("DECNETD-FAL-ACCEPT: FAIL\n"); + return 1; +#undef FA_CHECK +} + static void usage(const char *argv0) { fprintf(stderr, @@ -1671,7 +2035,19 @@ static void usage(const char *argv0) " control returns with %%REM-S-END on LOGOUT.\n" " --user NAME with --set-host: CTERM access-control username\n" " (default SYSTEM; proxy/accounting only -- the\n" - " remote authenticates fresh; never from the env).\n", + " remote authenticates fresh; never from the env).\n" + " --fal-selftest run the FAL/COPY honest-floor proof and exit (no\n" + " executive needed): a COPY client emits a real\n" + " object-17 connect carrying the access-control\n" + " creds and FAL REFUSES it with an NSP disconnect\n" + " when they cannot be authenticated; and the\n" + " DAP-over-NSP transport pump round-trips over a\n" + " threaded socketpair (rd vms-8c2)\n" + " --fal-accept-test run the FULL inbound-FAL COPY proof and exit (a\n" + " HARD GATE on /dev/vms + the mounted SYSUAF): real\n" + " SYSUAF/Purdy auth (bad password REFUSED), then a\n" + " sequential file transferred BOTH directions\n" + " through real DAP + RMS over the ACP, byte-verified\n", argv0, DECNETD_DEFAULT_IFACE, (unsigned)DNET_T3_DEFAULT); } @@ -1693,6 +2069,8 @@ int main(int argc, char **argv) int cterm_server = 0; const char *set_host_to = NULL; /* --set-host A.N : CTERM terminal client */ const char *set_host_user = "SYSTEM"; /* --user : CTERM access-control name */ + int fal_self_test = 0; + int fal_accept_test = 0; for (int i = 1; i < argc; i++) { if (!strcmp(argv[i], "--address") && i + 1 < argc) addr_s = argv[++i]; @@ -1713,6 +2091,8 @@ int main(int argc, char **argv) else if (!strcmp(argv[i], "--cterm-server")) cterm_server = 1; else if (!strcmp(argv[i], "--set-host") && i + 1 < argc) set_host_to = argv[++i]; else if (!strcmp(argv[i], "--user") && i + 1 < argc) set_host_user = argv[++i]; + else if (!strcmp(argv[i], "--fal-selftest")) fal_self_test = 1; + else if (!strcmp(argv[i], "--fal-accept-test")) fal_accept_test = 1; else if (!strcmp(argv[i], "--help") || !strcmp(argv[i], "-h")) { usage(argv[0]); return 0; @@ -1733,6 +2113,10 @@ int main(int argc, char **argv) return run_cterm_accept_test(); if (isolation_test) return run_isolation_test(); + if (fal_self_test) + return run_fal_selftest(); + if (fal_accept_test) + return run_fal_accept_test(); /* --set-host CLIENT self-sources its executor address from the node's DECnet * configuration (rd vms-f54) so DCL's SET HOST wiring need not know it. When diff --git a/src/vmsdecnet/fal/CMakeLists.txt b/src/vmsdecnet/fal/CMakeLists.txt new file mode 100644 index 000000000..ed36128e1 --- /dev/null +++ b/src/vmsdecnet/fal/CMakeLists.txt @@ -0,0 +1,25 @@ +# vmsdecnet FAL layer - the DECnet FILE ACCESS LISTENER (Session Control object +# 17) server + the COPY node:: client (rd vms-8c2, epic vms-30e; north-star demo +# leg vms-e4dc). The file-transfer layered product behind +# `$ COPY node"user pw"::file localfile`. +# +# vmsdecnet_fal: the FAL server + COPY client. DELIBERATELY SEPARATE from the +# pure DAP codec (vmsdecnet_dap) and the pure NSP/CTERM codecs: this half +# AUTHENTICATES (sysuaf_lookup + sysuaf_authenticate Purdy, in libvms) and does +# real FILE I/O (rms_textfile_* -- RMS over the ODS-2 ACP, in libvms), so +# keeping it out of the codecs makes their purity a build-enforced fact. It +# reuses the bounded FAL access-control decoder dnet_fal_access_decode +# (vmsdecnet_cterm) for the connect-carried credentials. +# +# CLEAN-ROOM (Rule 8): the DAP message SEQUENCE + the credential/object +# semantics are docs/oracle/vax-copy-fal-dap.* (rd vms-cd3); the DAP field +# framing is the public spec (vmsdecnet_dap). Security: an inbound FAL connect +# is authenticated before any file is served (INV-6, the no-auth-FAL analogue of +# the CTERM hole); a bad password is refused by real Purdy. NO +# fork/exec/openpty/dup2, NO raw-fd/raw-termios file mechanics. +add_library(vmsdecnet_fal STATIC dnet_fal.c) +target_include_directories(vmsdecnet_fal PUBLIC + ${CMAKE_CURRENT_SOURCE_DIR}/include +) +target_link_libraries(vmsdecnet_fal PUBLIC + vmsdecnet_dap vmsdecnet_cterm vms vmsfs vmssys) diff --git a/src/vmsdecnet/fal/dnet_fal.c b/src/vmsdecnet/fal/dnet_fal.c new file mode 100644 index 000000000..1ab22cd7e --- /dev/null +++ b/src/vmsdecnet/fal/dnet_fal.c @@ -0,0 +1,406 @@ +/* + * dnet_fal.c - the DECnet FILE ACCESS LISTENER (object 17) server + the COPY + * node:: client (rd vms-8c2). See dnet_fal.h for the security argument and the + * layer boundary. Two facts govern every line here: + * - AUTH IS REAL: the connect-carried username+password go through + * sysuaf_lookup + sysuaf_authenticate (Purdy) + the disabled-account gate, + * the SAME path LOGINOUT/SSHD use. A bad password is refused; a fake would + * pass it. (oracle docs/oracle/vax-copy-fal-dap.md §1.) + * - FILE I/O IS REAL: records move through rms_textfile_* -- RMS over the + * ODS-2 executive ACP -- never a raw POSIX file (Rule 9 / INV-6). No + * fork/exec/openpty/dup2, no raw-termios/raw-fd file mechanics. + * + * The DAP message SEQUENCE (CONFIGURATION -> ACCESS -> ATTRIBUTES/NAME -> + * CONTROL -> DATA -> STATUS/ACCESS-COMPLETE) is the oracle's; the per-field DAP + * framing is the public spec via dnet_dap.c (clean-room, Rule 8). + */ +#include "dnet_fal.h" +#include "dnet_cterm.h" /* dnet_fal_access_decode (bounded cred decoder) */ + +#include + +#include "sysuaf.h" /* the ONE faithful authenticator (Purdy) */ +#include "rms_textfile.h" /* RMS over the ACP -- real file I/O */ +#include "ssdef.h" + +/* OVMX CONFIGURATION identity bytes (public DAP spec fields; OVMX presents as a + * VMS/RMS node). Values are OVMX-chosen within the spec and only meaningful + * between two OVMX nodes at this rung. */ +#define FAL_OSTYPE_VMS 0x01 +#define FAL_FILESYS_RMS 0x01 +#define FAL_DAP_VERSION 0x07 /* DAP root version 7 (public spec) */ +#define FAL_BUFSIZ 1459 /* matches the oracle's negotiated segsize */ + +/* ---- authentication ------------------------------------------------------ */ + +uint32_t dnet_fal_authenticate(const char *username, const char *password) +{ + if (!username || !password || username[0] == '\0') + return SS$_INVLOGIN; + + sysuaf_record_t rec; + /* No-such-user and a wrong password both surface as SS$_INVLOGIN so the + * peer cannot probe which usernames exist -- exactly as a real login does + * not distinguish them. Fail-honest: no SYSUAF / no /dev/vms -> lookup + * fails -> refuse (never fabricate a pass). */ + if (sysuaf_lookup(username, &rec) != 0) + return SS$_INVLOGIN; + if (!sysuaf_authenticate(&rec, password)) + return SS$_INVLOGIN; + /* A real account with the right password but DISUSER/DISACNT is refused -- + * the disabled-account gate, same as the interactive/SSH paths. */ + if (!sysuaf_interactive_login_permitted(&rec)) + return SS$_NOPRIV; + return SS$_NORMAL; +} + +/* ---- small DAP send/recv helpers over the caller's transport ------------- */ + +static int fal_send(struct dnet_dap_transport *t, const struct dnet_dap_msg *m) +{ + return t->send(t->ctx, m); +} +static int fal_recv(struct dnet_dap_transport *t, struct dnet_dap_msg *m) +{ + return t->recv(t->ctx, m); +} + +static int send_simple(struct dnet_dap_transport *t, enum dnet_dap_op op, uint8_t func) +{ + struct dnet_dap_msg m; + memset(&m, 0, sizeof m); + m.op = op; + m.u.complete.func = func; + return fal_send(t, &m); +} + +static int send_status(struct dnet_dap_transport *t, uint16_t code) +{ + struct dnet_dap_msg m; + memset(&m, 0, sizeof m); + m.op = DNET_DAP_STATUS; + m.u.status.stscode = code; + return fal_send(t, &m); +} + +static int send_config(struct dnet_dap_transport *t) +{ + struct dnet_dap_msg m; + memset(&m, 0, sizeof m); + m.op = DNET_DAP_CONFIG; + m.u.config.bufsiz = FAL_BUFSIZ; + m.u.config.ostype = FAL_OSTYPE_VMS; + m.u.config.filesys = FAL_FILESYS_RMS; + m.u.config.version = FAL_DAP_VERSION; + return fal_send(t, &m); +} + +/* Exchange CONFIGURATION with the peer (both ends send + receive one). The + * caller side (server vs client) sends first or receives first symmetrically; + * here every endpoint sends then receives, which the transport orders. */ +static int config_exchange(struct dnet_dap_transport *t, int send_first) +{ + struct dnet_dap_msg m; + if (send_first) { + if (send_config(t) < 0) return -1; + if (fal_recv(t, &m) < 0 || m.op != DNET_DAP_CONFIG) return -1; + } else { + if (fal_recv(t, &m) < 0 || m.op != DNET_DAP_CONFIG) return -1; + if (send_config(t) < 0) return -1; + } + return 0; +} + +/* ---- FAL SERVER ---------------------------------------------------------- */ + +/* Serve a GET: read the local file and stream its records as DATA, then EOF. */ +static uint32_t server_serve_get(struct dnet_dap_transport *t, const char *spec) +{ + rms_textfile_t *tf = rms_textfile_open(spec); + if (!tf) { + /* Honest miss -- the source is not there / not reachable over the ACP. + * Tell the client and end the access cleanly (never crash it). */ + (void)send_status(t, DNET_DAP_STS_ACCFAIL); + return SS$_NOSUCHFILE; + } + + /* ATTRIBUTES then NAME (the resolved full spec) -- the oracle's attributes + * exchange, carrying the values it fixed as ground truth. */ + struct dnet_dap_msg m; + memset(&m, 0, sizeof m); + m.op = DNET_DAP_ATTRIBUTES; + m.u.attr.org = DNET_DAP_ORG_SEQ; + m.u.attr.rfm = DNET_DAP_RFM_VAR; + m.u.attr.rat = 0; + m.u.attr.mrs = DNET_DAP_MAX_REC; + m.u.attr.alq = 0; + if (fal_send(t, &m) < 0) { rms_textfile_close(tf); return SS$_ABORT; } + + memset(&m, 0, sizeof m); + m.op = DNET_DAP_NAME; + m.u.name.nametype = 1; /* full file spec */ + strncpy(m.u.name.namespec, spec, sizeof m.u.name.namespec - 1); + if (fal_send(t, &m) < 0) { rms_textfile_close(tf); return SS$_ABORT; } + + if (send_simple(t, DNET_DAP_ACKNOWLEDGE, 0) < 0) { rms_textfile_close(tf); return SS$_ABORT; } + + /* CONTROL GET from the client. */ + if (fal_recv(t, &m) < 0 || m.op != DNET_DAP_CONTROL || + m.u.control.ctlfunc != DNET_DAP_CTL_GET) { + rms_textfile_close(tf); + return SS$_ABORT; + } + + /* Stream every record verbatim as a DATA message. */ + char line[DNET_DAP_MAX_REC]; + int too_long = 0; + while (rms_textfile_getline(tf, line, sizeof line, &too_long)) { + size_t n = strlen(line); + if (n > DNET_DAP_MAX_REC) n = DNET_DAP_MAX_REC; + memset(&m, 0, sizeof m); + m.op = DNET_DAP_DATA; + m.u.data.reclen = (uint16_t)n; + if (n) memcpy(m.u.data.rec, line, n); + if (fal_send(t, &m) < 0) { rms_textfile_close(tf); return SS$_ABORT; } + } + rms_textfile_close(tf); + + /* ACCESS-COMPLETE with an EOF indication, then the client's completion. */ + if (send_simple(t, DNET_DAP_ACCESS_COMPLETE, 1) < 0) return SS$_ABORT; + if (fal_recv(t, &m) < 0) return SS$_ABORT; /* client's ACCESS-COMPLETE */ + return SS$_NORMAL; +} + +/* Serve a PUT: receive the client's records and store them via RMS. */ +static uint32_t server_serve_put(struct dnet_dap_transport *t, const char *spec) +{ + if (send_simple(t, DNET_DAP_ACKNOWLEDGE, 0) < 0) return SS$_ABORT; + + struct dnet_dap_msg m; + int have_control = 0; + int wrote_any = 0; + + for (;;) { + if (fal_recv(t, &m) < 0) return SS$_ABORT; + if (m.op == DNET_DAP_ATTRIBUTES) { + /* Only sequential, variable/stream records are served this rung; + * anything else is refused honestly (INV-6 -- indexed/relative are + * filed follow-ons, never faked). */ + if (m.u.attr.org != DNET_DAP_ORG_SEQ) { + (void)send_status(t, DNET_DAP_STS_ACCFAIL); + return SS$_ABORT; + } + continue; + } + if (m.op == DNET_DAP_CONTROL) { + if (m.u.control.ctlfunc != DNET_DAP_CTL_PUT) { + (void)send_status(t, DNET_DAP_STS_ACCFAIL); + return SS$_ABORT; + } + have_control = 1; + if (send_simple(t, DNET_DAP_ACKNOWLEDGE, 0) < 0) return SS$_ABORT; + continue; + } + if (m.op == DNET_DAP_DATA) { + if (!have_control) { (void)send_status(t, DNET_DAP_STS_ACCFAIL); return SS$_ABORT; } + char rec[DNET_DAP_MAX_REC + 1]; + uint16_t n = m.u.data.reclen; + if (n > DNET_DAP_MAX_REC) n = DNET_DAP_MAX_REC; + if (n) memcpy(rec, m.u.data.rec, n); + rec[n] = '\0'; + /* First record creates/supersedes; the rest append -- the file + * lands on the ODS-2 volume through the ACP, record by record. */ + int st = wrote_any ? rms_textfile_append_line(spec, rec) + : rms_textfile_write_line(spec, rec); + if (st != 0) { (void)send_status(t, DNET_DAP_STS_ACCFAIL); return SS$_ABORT; } + wrote_any = 1; + continue; + } + if (m.op == DNET_DAP_ACCESS_COMPLETE) { + /* An empty source file is legal: create it now if no record came. */ + if (!wrote_any) { + if (rms_textfile_write_line(spec, "") != 0) { + (void)send_status(t, DNET_DAP_STS_ACCFAIL); + return SS$_ABORT; + } + } + return (send_status(t, DNET_DAP_STS_SUCCESS) < 0) ? SS$_ABORT : SS$_NORMAL; + } + /* Any other (or unknown) message ends the access honestly. */ + (void)send_status(t, DNET_DAP_STS_ACCFAIL); + return SS$_ABORT; + } +} + +uint32_t dnet_fal_connect_auth(const uint8_t *conn_data, size_t conn_len, + char *authed_user, size_t authed_user_cap) +{ + if (authed_user && authed_user_cap) authed_user[0] = '\0'; + + /* Decode the connect-carried credentials (bounded), authenticate, wipe. */ + char user[DNET_FAL_USER_MAX + 1]; + char pass[DNET_FAL_PASS_MAX + 1]; + char acct[DNET_FAL_USER_MAX + 1]; + int drc = dnet_fal_access_decode(conn_data, conn_len, + user, sizeof user, + pass, sizeof pass, + acct, sizeof acct); + /* A malformed connect never reaches the authenticator -- it is refused as + * an invalid login, exactly as a wrong password is. */ + uint32_t auth = (drc != 0) ? SS$_INVLOGIN : dnet_fal_authenticate(user, pass); + + /* The password never outlives the check. */ + memset(pass, 0, sizeof pass); + memset(acct, 0, sizeof acct); + + if (auth != SS$_NORMAL) { + memset(user, 0, sizeof user); + return auth; /* caller sends the NSP disconnect; no CC, no DAP, no file */ + } + if (authed_user && authed_user_cap) { + strncpy(authed_user, user, authed_user_cap - 1); + authed_user[authed_user_cap - 1] = '\0'; + } + memset(user, 0, sizeof user); + return SS$_NORMAL; +} + +uint32_t dnet_fal_server_run(struct dnet_dap_transport *t) +{ + if (!t || !t->send || !t->recv) return SS$_ABORT; + + /* CONFIGURATION exchange (server receives first). The connect-time gate + * (dnet_fal_connect_auth) has already authenticated and the caller has + * accepted the link, so no credential is handled here. */ + if (config_exchange(t, 0) < 0) return SS$_ABORT; + + /* ACCESS -> branch on the function. */ + struct dnet_dap_msg m; + if (fal_recv(t, &m) < 0 || m.op != DNET_DAP_ACCESS) + return SS$_ABORT; + + if (m.u.access.accfunc == DNET_DAP_ACC_OPEN) + return server_serve_get(t, m.u.access.filespec); + if (m.u.access.accfunc == DNET_DAP_ACC_CREATE) + return server_serve_put(t, m.u.access.filespec); + + (void)send_status(t, DNET_DAP_STS_ACCFAIL); + return SS$_ABORT; +} + +/* ---- FAL CLIENT (the COPY node:: driver) --------------------------------- */ + +uint32_t dnet_fal_client_put(const char *local_spec, const char *remote_spec, + struct dnet_dap_transport *t) +{ + if (!t || !t->send || !t->recv || !local_spec || !remote_spec) return SS$_ABORT; + + rms_textfile_t *tf = rms_textfile_open(local_spec); + if (!tf) return SS$_NOSUCHFILE; + + if (config_exchange(t, 1) < 0) { rms_textfile_close(tf); return SS$_ABORT; } + + struct dnet_dap_msg m; + + /* ACCESS CREATE the remote file. */ + memset(&m, 0, sizeof m); + m.op = DNET_DAP_ACCESS; + m.u.access.accfunc = DNET_DAP_ACC_CREATE; + strncpy(m.u.access.filespec, remote_spec, sizeof m.u.access.filespec - 1); + if (fal_send(t, &m) < 0) { rms_textfile_close(tf); return SS$_ABORT; } + if (fal_recv(t, &m) < 0 || m.op != DNET_DAP_ACKNOWLEDGE) { rms_textfile_close(tf); return SS$_ABORT; } + + /* ATTRIBUTES (sequential, variable). */ + memset(&m, 0, sizeof m); + m.op = DNET_DAP_ATTRIBUTES; + m.u.attr.org = DNET_DAP_ORG_SEQ; + m.u.attr.rfm = DNET_DAP_RFM_VAR; + m.u.attr.mrs = DNET_DAP_MAX_REC; + if (fal_send(t, &m) < 0) { rms_textfile_close(tf); return SS$_ABORT; } + + /* CONTROL PUT. */ + memset(&m, 0, sizeof m); + m.op = DNET_DAP_CONTROL; + m.u.control.ctlfunc = DNET_DAP_CTL_PUT; + if (fal_send(t, &m) < 0) { rms_textfile_close(tf); return SS$_ABORT; } + if (fal_recv(t, &m) < 0 || m.op != DNET_DAP_ACKNOWLEDGE) { rms_textfile_close(tf); return SS$_ABORT; } + + /* DATA per record. */ + char line[DNET_DAP_MAX_REC]; + int too_long = 0; + while (rms_textfile_getline(tf, line, sizeof line, &too_long)) { + size_t n = strlen(line); + if (n > DNET_DAP_MAX_REC) n = DNET_DAP_MAX_REC; + memset(&m, 0, sizeof m); + m.op = DNET_DAP_DATA; + m.u.data.reclen = (uint16_t)n; + if (n) memcpy(m.u.data.rec, line, n); + if (fal_send(t, &m) < 0) { rms_textfile_close(tf); return SS$_ABORT; } + } + rms_textfile_close(tf); + + /* ACCESS-COMPLETE, then the server's STATUS. */ + if (send_simple(t, DNET_DAP_ACCESS_COMPLETE, 0) < 0) return SS$_ABORT; + if (fal_recv(t, &m) < 0 || m.op != DNET_DAP_STATUS) return SS$_ABORT; + return (m.u.status.stscode == DNET_DAP_STS_SUCCESS) ? SS$_NORMAL : SS$_ABORT; +} + +uint32_t dnet_fal_client_get(const char *remote_spec, const char *local_spec, + struct dnet_dap_transport *t) +{ + if (!t || !t->send || !t->recv || !local_spec || !remote_spec) return SS$_ABORT; + + if (config_exchange(t, 1) < 0) return SS$_ABORT; + + struct dnet_dap_msg m; + + /* ACCESS OPEN the remote file. */ + memset(&m, 0, sizeof m); + m.op = DNET_DAP_ACCESS; + m.u.access.accfunc = DNET_DAP_ACC_OPEN; + strncpy(m.u.access.filespec, remote_spec, sizeof m.u.access.filespec - 1); + if (fal_send(t, &m) < 0) return SS$_ABORT; + + /* Server replies ATTRIBUTES (+NAME +ACK) on success, or STATUS(accfail). */ + if (fal_recv(t, &m) < 0) return SS$_ABORT; + if (m.op == DNET_DAP_STATUS) return SS$_NOSUCHFILE; + if (m.op != DNET_DAP_ATTRIBUTES) return SS$_ABORT; + /* NAME (resolved full spec), then ACK. */ + if (fal_recv(t, &m) < 0 || m.op != DNET_DAP_NAME) return SS$_ABORT; + if (fal_recv(t, &m) < 0 || m.op != DNET_DAP_ACKNOWLEDGE) return SS$_ABORT; + + /* CONTROL GET. */ + memset(&m, 0, sizeof m); + m.op = DNET_DAP_CONTROL; + m.u.control.ctlfunc = DNET_DAP_CTL_GET; + if (fal_send(t, &m) < 0) return SS$_ABORT; + + /* Receive DATA records; write each locally through RMS. */ + int wrote_any = 0; + for (;;) { + if (fal_recv(t, &m) < 0) return SS$_ABORT; + if (m.op == DNET_DAP_DATA) { + char rec[DNET_DAP_MAX_REC + 1]; + uint16_t n = m.u.data.reclen; + if (n > DNET_DAP_MAX_REC) n = DNET_DAP_MAX_REC; + if (n) memcpy(rec, m.u.data.rec, n); + rec[n] = '\0'; + int st = wrote_any ? rms_textfile_append_line(local_spec, rec) + : rms_textfile_write_line(local_spec, rec); + if (st != 0) return SS$_ABORT; + wrote_any = 1; + continue; + } + if (m.op == DNET_DAP_ACCESS_COMPLETE) { + if (!wrote_any) { + if (rms_textfile_write_line(local_spec, "") != 0) return SS$_ABORT; + } + /* Acknowledge completion back to the server. */ + if (send_simple(t, DNET_DAP_ACCESS_COMPLETE, 0) < 0) return SS$_ABORT; + return SS$_NORMAL; + } + if (m.op == DNET_DAP_STATUS) + return (m.u.status.stscode == DNET_DAP_STS_EOF) ? SS$_NORMAL : SS$_NOSUCHFILE; + return SS$_ABORT; + } +} diff --git a/src/vmsdecnet/fal/include/dnet_fal.h b/src/vmsdecnet/fal/include/dnet_fal.h new file mode 100644 index 000000000..4394b9bb1 --- /dev/null +++ b/src/vmsdecnet/fal/include/dnet_fal.h @@ -0,0 +1,140 @@ +/* + * dnet_fal.h - the DECnet FILE ACCESS LISTENER (FAL, DECnet Session Control + * object 17) server, and the COPY node:: client that drives it (rd vms-8c2, + * epic vms-30e; north-star demo leg vms-e4dc). This is the file-transfer + * layered product behind `$ COPY node"user pw"::file localfile`. + * + * ================== WHAT THIS FIXES, IN ONE PARAGRAPH ================== + * An inbound FAL connect is a SECURITY surface: the connecting peer supplies a + * username AND password IN THE CONNECT (oracle docs/oracle/vax-copy-fal-dap.md + * §1 -- the OPPOSITE of CTERM, whose access-control fields are empty), and if + * FAL served the file WITHOUT checking them it would be the file-access analogue + * of the no-auth-CTERM hole the operator was furious about. So the FAL server + * here AUTHENTICATES the connect-time credentials through THE ONE faithful + * authenticator -- the same binary-SYSUAF / Purdy path LOGINOUT and SSHD use + * (sysuaf_lookup + sysuaf_authenticate + sysuaf_interactive_login_permitted) -- + * and REFUSES the connect (the caller sends an NSP disconnect) on a bad + * password or a disabled account. A fake check would let a bad password + * through; only the real Purdy verify against the account's stored quadword + * refuses it. Then, and only then, it serves/stores the file through RMS over + * the ODS-2 executive ACP (rms_textfile_*), real file I/O -- no userspace + * fallback that fakes a transfer (Rule 9). + * ======================================================================= + * + * LAYER BOUNDARY (Rule 1 / executive boundary). This module owns the DAP + * PRESENTATION logic + the AUTH + the RMS file I/O. It does NOT own the wire: + * the caller (decnetd, over the live datalink; the selftest, over a socketpair) + * owns the NSP logical link and moves each DAP message as an NSP data segment, + * handed here through a `struct dnet_dap_transport`. That is the same discipline + * the CTERM host uses (the daemon owns the datalink; the session module owns the + * protocol). NO fork/exec/openpty/dup2 and NO raw-termios/raw-fd file mechanics + * live here -- file I/O is rms_textfile_* (RMS over the ACP), scanned by the + * standing gate. + */ +#ifndef DNET_FAL_H +#define DNET_FAL_H + +#include +#include + +#include "dnet_dap.h" + +#ifdef __cplusplus +extern "C" { +#endif + +/* DECnet Session Control object number for FAL (oracle §1: object 0x11 = 17). */ +#define DNET_FAL_OBJECT 17 + +/* Caps for the connect-carried access-control credentials. A username or + * password longer than this is refused by the bounded decoder, not clipped. */ +#define DNET_FAL_USER_MAX 64 +#define DNET_FAL_PASS_MAX 64 + +/* + * The DAP message transport the caller provides. This is the seam between the + * DAP presentation layer (this module) and the NSP session layer (the caller's + * logical link): send() ships one DAP message as an NSP data segment, recv() + * delivers the next one. Both return 0 on success and a negative value on a + * wire/protocol failure or a closed link. NEITHER is a "fake transfer": the + * bytes that cross are real DAP messages inside real NSP data segments on a + * real (veth) or socketpair datalink. + */ +struct dnet_dap_transport { + int (*send)(void *ctx, const struct dnet_dap_msg *msg); + int (*recv)(void *ctx, struct dnet_dap_msg *msg); + void *ctx; +}; + +/* + * dnet_fal_authenticate - THE ONE faithful authenticator, exposed for the + * server and its acceptance test. Looks the username up in the binary SYSUAF + * (over the ACP), Purdy-verifies `password`, and rejects a disabled account. + * + * Returns SS$_NORMAL (1) iff the credentials authenticate a login-permitted + * account; SS$_INVLOGIN on no-such-user or a wrong password (the two are NOT + * distinguished to the peer -- a real login does not leak which); SS$_NOPRIV on + * a real account that is DISUSER'd. Fail-honest: with no /dev/vms / no mounted + * SYSUAF, sysuaf_lookup returns not-found and this returns SS$_INVLOGIN -- it + * never fabricates a pass (INV-6, Rule 9). The caller wipes the password buffer + * after this returns. + */ +uint32_t dnet_fal_authenticate(const char *username, const char *password); + +/* + * dnet_fal_connect_auth - the CONNECT-TIME gate a FAL dispatcher runs the + * moment an object-17 Connect Initiate arrives, BEFORE it accepts the link. + * Decodes the access-control username+password from the UNTRUSTED connect bytes + * (dnet_fal_access_decode, fully bounded), authenticates them + * (dnet_fal_authenticate), and WIPES the password buffer. Returns SS$_NORMAL if + * the caller may accept the link and serve the session; a refusal status + * (SS$_INVLOGIN / SS$_NOPRIV) otherwise -- on which the caller sends an NSP + * Disconnect Initiate (reason OBJREJ) and NEVER accepts. This is the FAL + * analogue of the CTERM no-auth gate: no file is served on an unauthenticated + * connect. `authed_user` (may be NULL, cap >= 1) receives the username on + * success, for the accounting/log surface. + */ +uint32_t dnet_fal_connect_auth(const uint8_t *conn_data, size_t conn_len, + char *authed_user, size_t authed_user_cap); + +/* + * dnet_fal_server_run - serve one AUTHENTICATED, ACCEPTED FAL session to + * completion. The caller has already run dnet_fal_connect_auth (got SS$_NORMAL) + * and accepted the link (sent the Connect Confirm), so this runs only the DAP + * session over `t`: CONFIGURATION exchange, then an ACCESS (open for a GET / + * create for a PUT), ATTRIBUTES/NAME, CONTROL, DATA records (read from / written + * to the local file via RMS over the ACP), and STATUS / ACCESS-COMPLETE. + * + * Returns SS$_NORMAL on a completed transfer, SS$_NOSUCHFILE if the requested + * file cannot be opened for a GET, or SS$_ABORT on a transport/protocol failure + * or an unserved (non-sequential) file. No credential is handled here -- the + * connect-time gate already ran; this half only moves the file. + */ +uint32_t dnet_fal_server_run(struct dnet_dap_transport *t); + +/* + * dnet_fal_client_put - `$ COPY local remote::` : send the local sequential file + * to the remote FAL as a new file. Drives the DAP client (CONFIGURATION, + * ACCESS CREATE, CONTROL PUT, DATA per record, ACCESS-COMPLETE) over `t`; + * reads the local file via RMS over the ACP. `remote_spec` is the destination + * file spec (no node prefix). Returns SS$_NORMAL on success, SS$_NOSUCHFILE if + * the local source cannot be opened, SS$_ABORT on a transport/remote failure. + */ +uint32_t dnet_fal_client_put(const char *local_spec, const char *remote_spec, + struct dnet_dap_transport *t); + +/* + * dnet_fal_client_get - `$ COPY remote:: local` : fetch the remote sequential + * file and store it locally. Drives the DAP client (CONFIGURATION, ACCESS OPEN, + * CONTROL GET, receive DATA records, ACCESS-COMPLETE) over `t`; writes the local + * file via RMS over the ACP. Returns SS$_NORMAL on success, SS$_NOSUCHFILE if + * the remote reports the source missing, SS$_ABORT on a transport failure. + */ +uint32_t dnet_fal_client_get(const char *remote_spec, const char *local_spec, + struct dnet_dap_transport *t); + +#ifdef __cplusplus +} +#endif + +#endif /* DNET_FAL_H */ diff --git a/tests/qemu/lib/dcl_acceptance_battery.sh b/tests/qemu/lib/dcl_acceptance_battery.sh index 4515ac968..76a271f2b 100644 --- a/tests/qemu/lib/dcl_acceptance_battery.sh +++ b/tests/qemu/lib/dcl_acceptance_battery.sh @@ -1045,6 +1045,57 @@ run_dcl_acceptance_battery() { fi wait_for '$ ' 20 "$ISOL_OFF" + # ======================================================================= + # DECnet FILE COPY / FAL (vms-8c2) -- an inbound $ COPY node"user pw"::file + # authenticates the connect-carried credentials against the REAL SYSUAF and + # then moves a sequential file through DAP over the NSP link and RMS over the + # ODS-2 ACP, BOTH directions, byte-verified. Oracle + # docs/oracle/vax-copy-fal-dap.* (rd vms-cd3). + # + # WHY THIS RUNS HERE. The property is "a COPY with a BAD password is REFUSED, + # a COPY with the RIGHT password transfers the file, and the received bytes + # match the source" -- which only means anything against the REAL executive + # (/dev/vms), the REAL SYS$SYSTEM:SYSUAF.DAT (the seeded GUEST + DISABLED + # accounts) and REAL RMS on the mounted ODS-2 volume. On the build host there + # is no executive; DECNETD.EXE --fal-accept-test's auth checks then fail + # (INV-6) rather than proving anything about a stub. The honest floor -- a + # real object-17 connect carrying the creds, refused with an NSP disconnect + # when unauthenticated, plus the DAP transport pump -- is proven with no + # executive by --fal-selftest and by tests/vmsdecnet/test_dnet_dap (the DAP + # codec + FAL credential decoder, fuzzed ASan-clean). + # + # WHAT THE MODE DOES (src/vmsdecnet/engine/decnetd.c): authenticates the seed + # accounts through sysuaf_authenticate (Purdy) -- GUEST/GUEST accepted, a + # wrong password and DISABLED (DISUSER) refused; then opens a REAL object-17 + # NSP link over a socketpair carrying the access-control creds, and runs the + # FAL server + COPY client (two threads) to PUT then GET a sequential file + # through DAP + RMS, byte-verifying the transferred records. + # + # HARD GATE where DECNETD.EXE ships (x86_64 image); a LOUD note where absent + # (the VAX/Alpha staging follow-on, same as CTERM). Never green because + # nothing ran. + local FAL_OFF; FAL_OFF=$(wc -c <"$LOG") + send 'DNETACC --fal-accept-test' + if wait_for 'IVIMAGE' 15 "$FAL_OFF"; then + note "FAL COPY [vms-8c2]: SYS\$SYSTEM:DECNETD.EXE is not on THIS runtime's system disk, so the inbound-FAL COPY authentication + transfer proof DID NOT RUN here (hard gate on the rails that ship the image; staging into the VAX sysvol + Alpha boot image is tracked follow-on)" + elif wait_for 'DECNETD-FAL-ACCEPT:' 180 "$FAL_OFF"; then + local FALSEG; FALSEG=$(tail -c "+$((FAL_OFF + 1))" "$LOG" | tr -d '\r') + must_have "$FALSEG" 'DECNETD-FAL-ACCEPT: PASS' \ + "FAL COPY [vms-8c2]: inbound FAL authenticated the connect creds against the real SYSUAF and transferred a sequential file both directions through DAP + RMS, byte-verified (one PASS/FAIL line per assertion above this verdict)" + must_have "$FALSEG" 'is REFUSED (SS\$_INVLOGIN) -- a fake would pass it' \ + "FAL COPY [vms-8c2]: a wrong password is REFUSED by real SYSUAF/Purdy -- a fake auth would have admitted it" + must_have "$FALSEG" 'a right password is not sufficient' \ + "FAL COPY [vms-8c2]: DISABLED (correct password, DISUSER) is refused -- the SYSUAF login-flag rule applies to a network file access" + must_have "$FALSEG" 'BYTE-MATCH the source' \ + "FAL COPY [vms-8c2]: the transferred file's records byte-match the source (a real transfer through RMS over the ACP, both directions)" + must_not_have "$FALSEG" 'DECNETD-FAL-ACCEPT: FAIL' \ + "FAL COPY [vms-8c2]: no assertion in the inbound-FAL COPY acceptance failed" + negctl "$FALSEG" 'DECNETD-I-FALACCEPT' "DECnet FAL COPY acceptance" + else + bad "FAL COPY [vms-8c2]: DECNETD.EXE --fal-accept-test produced no verdict line within 180s -- the inbound-FAL COPY proof did not run (a missing DECNETD.EXE, an absent /dev/vms, or a hung transfer)" + fi + wait_for '$ ' 20 "$FAL_OFF" + # The DECnet device FACE _NET: is executive-resident and cross-process real # (vms-9ab, P5; design §2b/§7.5). $GETDVI it from DCL -- a process that is # NOT NETACP -- and it resolves; the deep cross-process assertions (class, diff --git a/tests/vmsdecnet/CMakeLists.txt b/tests/vmsdecnet/CMakeLists.txt index 7715cb4ef..c3451c868 100644 --- a/tests/vmsdecnet/CMakeLists.txt +++ b/tests/vmsdecnet/CMakeLists.txt @@ -66,3 +66,32 @@ add_executable(test_dnet_nodedb test_dnet_nodedb.c) target_link_libraries(test_dnet_nodedb PRIVATE vmsdecnet_nodedb) target_compile_options(test_dnet_nodedb PRIVATE -Werror) add_test(NAME vmsdecnet_nodedb_unit COMMAND test_dnet_nodedb) + +# rd vms-8c2: the DAP (Data Access Protocol) message codec + the FAL access- +# control credential decoder behind $ COPY node::file. Proves (1) every served +# DAP message round-trips encode->decode, (2) blocked messages walk in a single +# buffer, (3) the DAP decoder survives 200k fuzzed inputs + every truncated +# prefix without over-reading (ASan/UBSan), (4) the FAL access decoder pulls +# username=SYSTEM + a RETAINED password out of the EXACT oracle connect bytes +# and the builder re-emits them byte-identical, and (5) the FAL access decoder +# is fuzz-clean and leaks no credential on any malformed input. Attacker-facing +# decoders, so it is built WITH -fsanitize=address,undefined -- an over-read is +# a hard failure. Warning-clean under -Werror. +add_executable(test_dnet_dap test_dnet_dap.c) +target_link_libraries(test_dnet_dap PRIVATE vmsdecnet_dap vmsdecnet_cterm) +target_compile_options(test_dnet_dap PRIVATE -Werror) +# ASan/UBSan is where an over-read in the attacker-facing decoders becomes a +# HARD failure. Add it only where the toolchain can actually compile+LINK it +# (a musl/freestanding cross may lack the sanitizer runtime): the fuzz test's +# own bounds assertions (consumed <= n, no credential leak on failure) still +# run everywhere; the sanitizer deepens the check on the host gnu leg. +include(CheckCSourceCompiles) +set(_dap_san_save "${CMAKE_REQUIRED_FLAGS}") +set(CMAKE_REQUIRED_FLAGS "-fsanitize=address,undefined") +check_c_source_compiles("int main(void){return 0;}" OVMX_DAP_SANITIZE_OK) +set(CMAKE_REQUIRED_FLAGS "${_dap_san_save}") +if(OVMX_DAP_SANITIZE_OK) + target_compile_options(test_dnet_dap PRIVATE -fsanitize=address,undefined) + target_link_options(test_dnet_dap PRIVATE -fsanitize=address,undefined) +endif() +add_test(NAME vmsdecnet_dap_unit COMMAND test_dnet_dap) diff --git a/tests/vmsdecnet/test_dnet_dap.c b/tests/vmsdecnet/test_dnet_dap.c new file mode 100644 index 000000000..634b22106 --- /dev/null +++ b/tests/vmsdecnet/test_dnet_dap.c @@ -0,0 +1,243 @@ +/* + * test_dnet_dap.c - unit + fuzz proof for the DAP codec (dnet_dap) and the FAL + * access-control credential decoder (dnet_fal_access_decode), rd vms-8c2. + * + * Anti-LARP bar (the item's veracity rubric): + * 1. ROUND-TRIP: every DAP message this rung uses encodes and decodes back to + * an equal value -- the codec is real, not a stub. + * 2. ORACLE ANCHOR: the FAL access decoder pulls username="SYSTEM" + + * password (retained) out of the EXACT connect-data bytes the real VAX + * COPY put on the wire (docs/oracle/vax-copy-fal-dap.md §1), and the + * builder re-emits those bytes BYTE-IDENTICAL. + * 3. BOUNDED / NEVER CRASH A PEER: the DAP decoder and the FAL access decoder + * are fuzzed against truncations, over-long counts and random noise; none + * may over-read (built with ASan/UBSan in CI) and each must reject cleanly. + * + * Built -Werror; the CMake target adds -fsanitize=address,undefined so an + * over-read here is a HARD test failure, not a silent pass. + */ +#include "dnet_dap.h" +#include "dnet_cterm.h" + +#include +#include +#include + +static int g_pass = 0, g_fail = 0; +#define CHECK(c, msg) do { \ + if (c) { g_pass++; } \ + else { g_fail++; printf(" FAIL: %s\n", msg); } \ +} while (0) + +/* ---- 1. round-trip every served message ---------------------------------- */ + +static void test_roundtrip(void) +{ + uint8_t buf[DNET_DAP_MAX_MSG]; + size_t len = 0, consumed = 0; + struct dnet_dap_msg in, out; + + /* CONFIGURATION */ + memset(&in, 0, sizeof in); + in.op = DNET_DAP_CONFIG; + in.u.config.bufsiz = 1459; in.u.config.ostype = 1; + in.u.config.filesys = 1; in.u.config.version = 7; + CHECK(dnet_dap_encode(&in, buf, sizeof buf, &len) == DNET_DAP_OK, "encode CONFIG"); + CHECK(dnet_dap_decode(buf, len, &out, &consumed) == DNET_DAP_OK, "decode CONFIG"); + CHECK(out.op == DNET_DAP_CONFIG && out.u.config.bufsiz == 1459 && + out.u.config.version == 7 && consumed == len, "CONFIG round-trips"); + + /* ACCESS with a filespec */ + memset(&in, 0, sizeof in); + in.op = DNET_DAP_ACCESS; + in.u.access.accfunc = DNET_DAP_ACC_CREATE; + strcpy(in.u.access.filespec, "SYS$SYSROOT:[SYSMGR]OVMXDAP_R.TXT"); + CHECK(dnet_dap_encode(&in, buf, sizeof buf, &len) == DNET_DAP_OK, "encode ACCESS"); + CHECK(dnet_dap_decode(buf, len, &out, &consumed) == DNET_DAP_OK, "decode ACCESS"); + CHECK(out.op == DNET_DAP_ACCESS && out.u.access.accfunc == DNET_DAP_ACC_CREATE && + strcmp(out.u.access.filespec, "SYS$SYSROOT:[SYSMGR]OVMXDAP_R.TXT") == 0, + "ACCESS filespec round-trips"); + + /* NAME (the oracle's resolved full spec) */ + memset(&in, 0, sizeof in); + in.op = DNET_DAP_NAME; + in.u.name.nametype = 1; + strcpy(in.u.name.namespec, "SYS$SYSROOT:[SYSMGR]OVMXDAP_R.TXT;1"); + CHECK(dnet_dap_encode(&in, buf, sizeof buf, &len) == DNET_DAP_OK, "encode NAME"); + CHECK(dnet_dap_decode(buf, len, &out, &consumed) == DNET_DAP_OK, "decode NAME"); + CHECK(strcmp(out.u.name.namespec, "SYS$SYSROOT:[SYSMGR]OVMXDAP_R.TXT;1") == 0, + "NAME full-spec round-trips"); + + /* DATA carrying the oracle's verbatim record (with embedded no-NUL text) */ + const char *rec = "Hello from VAX1 node 1.1 - DAP/FAL oracle capture line one"; + memset(&in, 0, sizeof in); + in.op = DNET_DAP_DATA; + in.u.data.reclen = (uint16_t)strlen(rec); + memcpy(in.u.data.rec, rec, strlen(rec)); + CHECK(dnet_dap_encode(&in, buf, sizeof buf, &len) == DNET_DAP_OK, "encode DATA"); + CHECK(dnet_dap_decode(buf, len, &out, &consumed) == DNET_DAP_OK, "decode DATA"); + CHECK(out.op == DNET_DAP_DATA && out.u.data.reclen == strlen(rec) && + memcmp(out.u.data.rec, rec, strlen(rec)) == 0, "DATA record round-trips verbatim"); + + /* STATUS */ + memset(&in, 0, sizeof in); + in.op = DNET_DAP_STATUS; in.u.status.stscode = DNET_DAP_STS_EOF; + CHECK(dnet_dap_encode(&in, buf, sizeof buf, &len) == DNET_DAP_OK, "encode STATUS"); + CHECK(dnet_dap_decode(buf, len, &out, &consumed) == DNET_DAP_OK && + out.u.status.stscode == DNET_DAP_STS_EOF, "STATUS round-trips"); + + /* CONTROL / ACCESS-COMPLETE */ + memset(&in, 0, sizeof in); + in.op = DNET_DAP_CONTROL; in.u.control.ctlfunc = DNET_DAP_CTL_PUT; + CHECK(dnet_dap_encode(&in, buf, sizeof buf, &len) == DNET_DAP_OK, "encode CONTROL"); + CHECK(dnet_dap_decode(buf, len, &out, &consumed) == DNET_DAP_OK && + out.u.control.ctlfunc == DNET_DAP_CTL_PUT, "CONTROL round-trips"); +} + +/* ---- 2. blocked stream: several messages in one buffer ------------------- */ + +static void test_blocking(void) +{ + uint8_t buf[DNET_DAP_MAX_MSG * 3]; + size_t off = 0, l = 0; + struct dnet_dap_msg m; + + memset(&m, 0, sizeof m); m.op = DNET_DAP_CONTROL; m.u.control.ctlfunc = DNET_DAP_CTL_GET; + dnet_dap_encode(&m, buf + off, sizeof buf - off, &l); off += l; + memset(&m, 0, sizeof m); m.op = DNET_DAP_DATA; m.u.data.reclen = 3; memcpy(m.u.data.rec, "abc", 3); + dnet_dap_encode(&m, buf + off, sizeof buf - off, &l); off += l; + memset(&m, 0, sizeof m); m.op = DNET_DAP_ACCESS_COMPLETE; m.u.complete.func = 1; + dnet_dap_encode(&m, buf + off, sizeof buf - off, &l); off += l; + + size_t pos = 0, consumed = 0; int n = 0; + while (pos < off) { + int rc = dnet_dap_decode(buf + pos, off - pos, &m, &consumed); + if (rc != DNET_DAP_OK) break; + pos += consumed; n++; + } + CHECK(n == 3 && pos == off, "three blocked DAP messages decode in sequence"); +} + +/* ---- 3. fuzz the DAP decoder: no crash, clean reject ---------------------- */ + +static void test_dap_fuzz(void) +{ + /* Seed corpus from a valid encode, then mutate: truncate, extend the LENGTH + * beyond the buffer, flip bytes. ASan/UBSan catches any over-read. */ + unsigned bad = 0, total = 0; + struct dnet_dap_msg m; size_t consumed; + srand(1234); + for (int i = 0; i < 200000; i++) { + uint8_t f[64]; + size_t n = (size_t)(rand() % (int)sizeof f); + for (size_t k = 0; k < n; k++) f[k] = (uint8_t)rand(); + int rc = dnet_dap_decode(f, n, &m, &consumed); + total++; + /* A success MUST report consumed <= n (never claim more than we have). */ + if (rc == DNET_DAP_OK) { if (consumed > n) bad++; } + } + CHECK(bad == 0 && total == 200000, "DAP decoder: 200k random inputs, never over-consume, no crash"); + + /* Explicit truncation walk of a valid message: every prefix rejects cleanly. */ + uint8_t buf[DNET_DAP_MAX_MSG]; size_t len; + memset(&m, 0, sizeof m); m.op = DNET_DAP_ACCESS; m.u.access.accfunc = 1; + strcpy(m.u.access.filespec, "DKA0:[X]Y.TXT;1"); + dnet_dap_encode(&m, buf, sizeof buf, &len); + int all_clean = 1; + for (size_t p = 0; p < len; p++) { + struct dnet_dap_msg o; + int rc = dnet_dap_decode(buf, p, &o, &consumed); + if (rc == DNET_DAP_OK) all_clean = 0; /* a short prefix must NOT succeed */ + } + CHECK(all_clean, "DAP decoder: every truncated prefix of a valid ACCESS is rejected"); +} + +/* ---- 4. FAL access decoder against the EXACT oracle connect bytes -------- */ + +static void test_fal_access_oracle(void) +{ + /* + * docs/oracle/vax-copy-fal-dap.md §1 -- the Session Control connect DATA of + * the real COPY's Connect Initiate (object 17 = FAL), byte for byte: + * 00 11 DSTNAME = format 0, object 0x11 = 17 (FAL) + * 02 00 1a 02 20 20 06 "SYSTEM" SRCNAME = format 2, grp 0x021a usr 0x2020 + * 27 MENUVER + * 06 "SYSTEM" access-control USERID (the username FAL checks) + * 06 "cdef12" access-control PASSWORD (retained; placeholder here) + * 00 access-control ACCOUNT (empty) + * The password value in the oracle is redacted; the STRUCTURE (tag/len/ + * position) is what is fixed, so any 6-byte password exercises the decode. + */ + static const uint8_t conn[] = { + 0x00, 0x11, + 0x02, 0x00, 0x1a, 0x02, 0x20, 0x20, 0x06, 'S','Y','S','T','E','M', + 0x27, + 0x06, 'S','Y','S','T','E','M', + 0x06, 'c','d','e','f','1','2', + 0x00 + }; + char user[65], pass[65], acct[65]; + int rc = dnet_fal_access_decode(conn, sizeof conn, + user, sizeof user, pass, sizeof pass, + acct, sizeof acct); + CHECK(rc == 0, "oracle FAL connect decodes"); + CHECK(strcmp(user, "SYSTEM") == 0, "FAL access userid == SYSTEM (oracle)"); + CHECK(strcmp(pass, "cdef12") == 0, "FAL access password RETAINED (the FAL difference)"); + CHECK(acct[0] == '\0', "FAL access account empty (oracle)"); + + /* The builder re-emits those connect bytes byte-identical (proves the client + * puts the creds on the wire exactly where the real VAX did). */ + uint8_t built[64]; size_t blen = 0; + int brc = dnet_cterm_sc_connect_build(DNET_OBJ_FAL, "SYSTEM", 0x021a, 0x2020, + "SYSTEM", "cdef12", "", + built, sizeof built, &blen); + CHECK(brc == 0, "FAL connect builds"); + CHECK(blen == sizeof conn && memcmp(built, conn, sizeof conn) == 0, + "built FAL connect is BYTE-IDENTICAL to the oracle connect data"); +} + +/* ---- 5. fuzz the FAL access decoder: bounded, never retains on failure --- */ + +static void test_fal_access_fuzz(void) +{ + unsigned leaked = 0; + srand(4321); + for (int i = 0; i < 200000; i++) { + uint8_t f[48]; + size_t n = (size_t)(rand() % (int)sizeof f); + for (size_t k = 0; k < n; k++) f[k] = (uint8_t)rand(); + char user[65], pass[65], acct[65]; + int rc = dnet_fal_access_decode(f, n, user, sizeof user, + pass, sizeof pass, acct, sizeof acct); + /* On ANY failure every buffer must be empty (no half-parsed credential + * survives to reach the authenticator). NUL-termination is guaranteed. */ + if (rc != 0 && (user[0] || pass[0] || acct[0])) leaked++; + } + CHECK(leaked == 0, "FAL access decoder: 200k random inputs, no credential leaks on failure, no crash"); + + /* Truncation of the oracle connect: every prefix either decodes cleanly with + * empty tail fields or rejects -- never over-reads (ASan enforces). */ + static const uint8_t conn[] = { + 0x00, 0x11, 0x02, 0x00, 0x1a, 0x02, 0x20, 0x20, 0x06, + 'S','Y','S','T','E','M', 0x27, 0x06, 'S','Y','S','T','E','M', + 0x06, 'p','w','1','2','3','4', 0x00 + }; + for (size_t p = 0; p <= sizeof conn; p++) { + char user[65], pass[65], acct[65]; + (void)dnet_fal_access_decode(conn, p, user, sizeof user, + pass, sizeof pass, acct, sizeof acct); + } + CHECK(1, "FAL access decoder: every truncated prefix handled without over-read"); +} + +int main(void) +{ + printf("test_dnet_dap: DAP codec + FAL access decoder (rd vms-8c2)\n"); + test_roundtrip(); + test_blocking(); + test_dap_fuzz(); + test_fal_access_oracle(); + test_fal_access_fuzz(); + printf("test_dnet_dap: %d passed, %d failed\n", g_pass, g_fail); + return g_fail ? 1 : 0; +} From 6d80db9c3bdb012fccb4ecd70da13a78f6d194f8 Mon Sep 17 00:00:00 2001 From: alice Date: Wed, 9 Sep 2026 23:42:01 +0000 Subject: [PATCH 2/5] vms-8c2: force-bind RMS + binary-SYSUAF engine into DECNETD.EXE so --fal-accept-test's real auth + real file I/O run The booted --fal-accept-test heavy leg was RED: GUEST/GUEST auth, the DISABLED gate, the source-file create, and the GET link-up all FAILED on the real executive, while --fal-selftest (no executive) passed. Root cause was NOT the FAL logic: DECNETD.EXE is a static musl image whose FAL half reaches the RMS record services (rms_textfile.c) and the binary $UAFDEF SYSUAF engine (sysuaf.c) through LIBVMS's `#pragma weak` seam. Under a static link a weak-only reference does not extract the defining archive member, and DECNETD linked neither vmsrms nor any strong reference into it -- so sys$open and ovmx_sysuaf_read_user stayed NULL, rms_services_present() read FALSE, and sysuaf_lookup() returned "miss" before any ACP call. Auth and file creation then failed honestly (INV-6) even though LOGINOUT -- which carries the same anchor (loginout_rms_bind.c) -- authenticates GUEST fine on the same image. Fix: link vmsrms into DECNETD.EXE and add decnetd_rms_bind.c, a strong-reference anchor for the seven RMS services + the two SYSUAF engine readers, so the static linker extracts rms_core.o + sysuaf_live.o. This is the same weak-seam trap provision_rms_bind.c / loginout_rms_bind.c / tests/qemu/rms_acp_bind.c already close. Verified: nm now shows sys$open/$create/$get/$put/$connect/$close + ovmx_sysuaf_read_user/_uic DEFINED in DECNETD.EXE (absent before). --fal-selftest stays green (2 passed, 0 failed). No FAL logic, DAP codec, or auth check weakened (Rule 9 / INV-6); the pre-existing benign fab$b_bln -Woverflow is untouched. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01T4csyFSMUsS8k1D2MgMxk2 --- src/vmsdecnet/engine/CMakeLists.txt | 15 ++++- src/vmsdecnet/engine/decnetd_rms_bind.c | 76 +++++++++++++++++++++++++ 2 files changed, 90 insertions(+), 1 deletion(-) create mode 100644 src/vmsdecnet/engine/decnetd_rms_bind.c diff --git a/src/vmsdecnet/engine/CMakeLists.txt b/src/vmsdecnet/engine/CMakeLists.txt index c90e15a4f..aba46a861 100644 --- a/src/vmsdecnet/engine/CMakeLists.txt +++ b/src/vmsdecnet/engine/CMakeLists.txt @@ -29,7 +29,14 @@ target_link_libraries(vmsdecnet_engine PUBLIC vmsdecnet_routing vmsdecnet_nsp) # touched. It ties the socketless engine core to the live datalink + a monotonic # clock. Links ovmx_datalink for its socket/bpf open + MAC-get + send + recv # primitives. -add_executable(decnetd_exe decnetd.c) +# decnetd_rms_bind.c: force-bind anchor (vms-8c2). DECNETD.EXE's FAL half reaches +# the RMS record services + the binary SYSUAF engine through LIBVMS's `#pragma +# weak` seam; under a static link a weak-only reference does not extract the +# vmsrms archive member, so --fal-accept-test's real auth + real file I/O failed +# with sys$open/ovmx_sysuaf_read_user NULL. The anchor takes their address so the +# static linker pulls rms_core.o + sysuaf_live.o (the same weak-seam trap +# provision_rms_bind.c / loginout_rms_bind.c / tests/qemu/rms_acp_bind.c close). +add_executable(decnetd_exe decnetd.c decnetd_rms_bind.c) target_include_directories(decnetd_exe PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/include ${CMAKE_SOURCE_DIR}/src/libdatalink/include # scs_datalink.h @@ -54,9 +61,15 @@ target_include_directories(decnetd_exe PRIVATE # pulls in vmsdecnet_dap (the DAP codec). The --fal-* proofs run the server and # client as two blocking peers, so decnetd links Threads. find_package(Threads REQUIRED) +# vmsrms: the binary RMS record services + the $UAFDEF Purdy SYSUAF engine that +# the FAL half's rms_textfile_* and sysuaf_lookup weak-seam onto (vms-8c2). Its +# archive must be in DECNETD's link closure for decnetd_rms_bind.c's strong +# references to extract rms_core.o + sysuaf_live.o; PROVISION.EXE links it the +# same way. Ordered after vmsdecnet_fal so its members satisfy the anchors. target_link_libraries(decnetd_exe PRIVATE vmsdecnet_engine vmsdecnet_cterm vmsdecnet_cterm_host vmsdecnet_fal vmsdecnet_dap ovmx_datalink + vmsrms Threads::Threads) target_include_directories(decnetd_exe PRIVATE ${CMAKE_SOURCE_DIR}/src/libvmssys # vms_kif.h ($GETDVI readback) diff --git a/src/vmsdecnet/engine/decnetd_rms_bind.c b/src/vmsdecnet/engine/decnetd_rms_bind.c new file mode 100644 index 000000000..d16dcf81d --- /dev/null +++ b/src/vmsdecnet/engine/decnetd_rms_bind.c @@ -0,0 +1,76 @@ +/* + * decnetd_rms_bind.c - force the RMS record services + the binary SYSUAF engine + * into DECNETD.EXE's static link closure so FAL (--fal-accept-test) can + * authenticate an inbound object-17 connect against the REAL SYS$SYSTEM:SYSUAF + * and move the transferred file through RMS over the Files-11 ODS-2 ACP + * (rd vms-8c2, epic vms-30e). + * + * WHY THIS OBJECT EXISTS. + * + * DECNETD.EXE is a static musl image (distro/Dockerfile.bootable build-static, + * OVMX_STATIC=ON). Its FAL half authenticates through sysuaf_lookup + + * sysuaf_authenticate (LIBVMS) and does real file I/O through rms_textfile_* + * (LIBVMS) -- but BOTH of those reach the executive-backed producers through a + * `#pragma weak` seam: + * - rms_textfile.c weak-references sys$open/$close/$connect/$disconnect/$get/ + * $put/$create (defined in vmsrms, rms_core.c). + * - sysuaf.c weak-references ovmx_sysuaf_read_user/_uic (defined in vmsrms, + * sysuaf_live.o). + * LIBVMS sits BELOW RMS in the layering, so it cannot hard-reference those + * producers without inverting the layering -- hence the weak seam. + * + * THE DEFECT THIS CLOSES. Under a static link a WEAK undefined reference does + * NOT extract the defining archive member. DECNETD linked neither vmsrms nor any + * STRONG reference into it, so sys$open and ovmx_sysuaf_read_user stayed NULL, + * rms_services_present() read FALSE, sysuaf_lookup() returned "miss" before any + * ACP call, and --fal-accept-test's GUEST/GUEST auth + source-file creation both + * FAILED honestly (INV-6) on the booted image even though LOGINOUT -- which + * carries the analogous anchor (src/vmslink/loginout_rms_bind.c) -- authenticates + * GUEST fine. This is the SAME weak-seam trap provision_rms_bind.c and + * tests/qemu/rms_acp_bind.c close for their images. + * + * THE FIX. A table of STRONG references to the RMS record services rms_textfile.c + * needs AND the two binary-SYSUAF engine readers sysuaf.c needs. Compiled into + * DECNETD.EXE, it forces the static linker to extract rms_core.o + sysuaf_live.o + * from the vmsrms archive DECNETD now links, so rms_services_present() is TRUE and + * the genuine ACP $CREATE/$PUT + the genuine $UAFDEF Purdy read of SYSUAF run. It + * changes NO behaviour of its own (never called) -- `used` keeps the compiler from + * discarding the table and the address-taken `volatile const` array keeps the + * linker from folding the references away, so each stays a genuine strong + * undefined reference that pulls its producer in. + */ + +/* The seven RMS three-argument services rms_textfile.c reaches through the weak + * seam, declared with their real (void *, callback, callback) shape so the + * reference is to the exact symbols the seam names. No header needed; this is a + * link-anchor TU, not part of any library's include graph. */ +extern unsigned int sys$open(void *fab, void (*err)(void *), void (*suc)(void *)); +extern unsigned int sys$close(void *fab, void (*err)(void *), void (*suc)(void *)); +extern unsigned int sys$connect(void *rab, void (*err)(void *), void (*suc)(void *)); +extern unsigned int sys$disconnect(void *rab, void (*err)(void *), void (*suc)(void *)); +extern unsigned int sys$get(void *rab, void (*err)(void *), void (*suc)(void *)); +extern unsigned int sys$put(void *rab, void (*err)(void *), void (*suc)(void *)); +extern unsigned int sys$create(void *fab, void (*err)(void *), void (*suc)(void *)); + +/* The two engine entry points sysuaf.c weak-references. Declared only to take + * their address -- the reference is by NAME, so the exact prototype is + * immaterial (no vmsrms header needed here). Pulling these extracts + * sysuaf_live.o so the ovmx_sysuaf_* weak cells in LIBVMS bind. */ +extern unsigned int ovmx_sysuaf_read_user(const char *username, void *out); +extern unsigned int ovmx_sysuaf_read_uic(unsigned int uic, void *out); + +/* A table of the addresses. `used` keeps the compiler from discarding it and + * `volatile` keeps the linker from folding the references away, so each symbol + * stays a genuine strong undefined reference that pulls its RMS producer in. */ +void *const volatile decnetd_rms_bind_anchor[] __attribute__((used)) = { + (void *)&sys$open, + (void *)&sys$close, + (void *)&sys$connect, + (void *)&sys$disconnect, + (void *)&sys$get, + (void *)&sys$put, + (void *)&sys$create, + /* the flip's engine seam -- pull the leaf reader object too */ + (void *)&ovmx_sysuaf_read_user, + (void *)&ovmx_sysuaf_read_uic, +}; From 20bdb671c8cd96914d091771d72db82dc4e0c814 Mon Sep 17 00:00:00 2001 From: alice Date: Thu, 10 Sep 2026 00:09:04 +0000 Subject: [PATCH 3/5] vms-8c2: seed the DISABLED account into the shipped SYSUAF + label FAL PASS assertions so the booted --fal-accept-test goes green With the RMS/SYSUAF weak-seam bound (prior commit), the booted --fal-accept-test went from 3/4 to 11/1: GUEST/GUEST auth, wrong-password refusal, source-file create over the ACP, and both-direction byte-match all pass on the real executive. Two residual defects, both real: 1. The shipped distro/rootfs SYSUAF.DAT was STALE -- it carried only GUEST + SYSTEM, but tools/mksysuaf.c's seed has grown to 7 accounts including the DISABLED account (DISUSER, correct password DISABLED; rd vms-f40). So FAL's dnet_fal_authenticate("DISABLED","DISABLED") hit sysuaf_lookup MISS and returned SS$_INVLOGIN instead of SS$_NOPRIV -- the DISUSER gate was never reached. vms-f40's CTERM test did not catch this because LOGINOUT refuses DISABLED identically whether the account is DISUSER or absent (both -> "authorization failure"); FAL distinguishes them (NOPRIV vs INVLOGIN), which is what exposed the stale seed. Fix: regenerate SYSUAF.DAT with the current byte-reproducible mksysuaf (deterministic salt, no wall-clock -> stable bytes), so the on-disk seed matches the generator of record. GUEST is unchanged (same deterministic record) and no lifetime is set, so no account is password-expired. This is seeding the test account correctly -- the auth check itself is untouched (INV-6). 2. FA_CHECK printed a label only on FAILURE, so a fully-passing run omitted the very PROPERTY strings the booted battery greps for (a wrong password REFUSED, DISABLED refused, records BYTE-MATCH the source) -- making those must_have greps satisfiable only when the assertion FAILED (inverted). Emit " PASS: " on success too, matching the sibling CT_CHECK house style. This adds evidence on pass; it weakens nothing (pass/fail counts and verdict unchanged). --fal-selftest stays green (2 passed, 0 failed). Regenerated SYSUAF verified to carry DISABLED with DISUSER; the ACP byte-exact reader (Prolog-3 header) is content-agnostic, so no gate hardcodes the old bytes. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01T4csyFSMUsS8k1D2MgMxk2 --- .../vms/SYS0/SYSCOMMON/SYSEXE/SYSUAF.DAT | Bin 12288 -> 13824 bytes src/vmsdecnet/engine/decnetd.c | 7 ++++++- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/distro/rootfs/vms/SYS0/SYSCOMMON/SYSEXE/SYSUAF.DAT b/distro/rootfs/vms/SYS0/SYSCOMMON/SYSEXE/SYSUAF.DAT index f841e6839898ab33078eff90fa0c67b57d824afe..87db9e71896e0dbb9dedbebff744adc287ea3774 100644 GIT binary patch delta 218 zcmZojXvpDaW?*7qWMF0xV366!{gaz<$7EI>-N~{l3XGDQ4SCkGP7-2aET7COl)}ry z(8ACFR4dHDz`(q@P^gP_l7s+b%Vt4|&)kaUtSmrbNgxKA#SB){!oN0twjs#=NWI>7i$!pofIH01j6E`MJ)@654E;QbD_MYe*E06&YP@~1nzyad`X~xYH tRQVV;^Q-hRv2id0-61(~ DAP/RMS transfer both directions (rd vms-8c2; oracle" " docs/oracle/vax-copy-fal-dap.*)\n"); int pass = 0, fail = 0; -#define FA_CHECK(c, msg) do { if (c) { pass++; } \ +/* Emit a labelled line on BOTH outcomes (the house style, matching CT_CHECK): + * the booted battery greps each assertion's PROPERTY message (a wrong password + * REFUSED, DISABLED refused, records BYTE-MATCH) as positive evidence the + * property was exercised, so a PASS must print its label too -- a fail-only + * print left those greps satisfiable only when the assertion FAILED (inverted). */ +#define FA_CHECK(c, msg) do { if (c) { pass++; printf(" PASS: %s\n", msg); } \ else { fail++; printf(" FAIL: %s\n", msg); } } while (0) /* 1) AUTH IS REAL (the security core): the same SYSUAF/Purdy path LOGINOUT From 68cfcaea2b5a65255fdeefdd4fa94a122a80b8a3 Mon Sep 17 00:00:00 2001 From: alice Date: Thu, 10 Sep 2026 00:18:26 +0000 Subject: [PATCH 4/5] vms-8c2: fix the over-escaped SS$_INVLOGIN literal in the FAL battery must_have The booted --fal-accept-test core went fully green (DECNETD-I-FALACCEPT, 12 passed, 0 failed; DECNETD-FAL-ACCEPT: PASS), but one battery assertion still failed: the wrong-password must_have. must_have matches with `grep -qiF` (fixed string), and its pattern was single-quoted as 'is REFUSED (SS\$_INVLOGIN) -- a fake would pass it' -- inside single quotes bash does NOT process `\$`, so the literal searched for was `SS\$_INVLOGIN` (with a backslash), which cannot match the real output `SS$_INVLOGIN`. Drop the errant backslash; single quotes already prevent `$` expansion, so 'SS$_INVLOGIN' is the correct literal. Verified against the real assertion line: the fixed pattern matches, the old one did not. This corrects the assertion to match the property FAL actually prints -- it weakens nothing. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01T4csyFSMUsS8k1D2MgMxk2 --- tests/qemu/lib/dcl_acceptance_battery.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/qemu/lib/dcl_acceptance_battery.sh b/tests/qemu/lib/dcl_acceptance_battery.sh index 76a271f2b..08d9d1634 100644 --- a/tests/qemu/lib/dcl_acceptance_battery.sh +++ b/tests/qemu/lib/dcl_acceptance_battery.sh @@ -1082,7 +1082,7 @@ run_dcl_acceptance_battery() { local FALSEG; FALSEG=$(tail -c "+$((FAL_OFF + 1))" "$LOG" | tr -d '\r') must_have "$FALSEG" 'DECNETD-FAL-ACCEPT: PASS' \ "FAL COPY [vms-8c2]: inbound FAL authenticated the connect creds against the real SYSUAF and transferred a sequential file both directions through DAP + RMS, byte-verified (one PASS/FAIL line per assertion above this verdict)" - must_have "$FALSEG" 'is REFUSED (SS\$_INVLOGIN) -- a fake would pass it' \ + must_have "$FALSEG" 'is REFUSED (SS$_INVLOGIN) -- a fake would pass it' \ "FAL COPY [vms-8c2]: a wrong password is REFUSED by real SYSUAF/Purdy -- a fake auth would have admitted it" must_have "$FALSEG" 'a right password is not sufficient' \ "FAL COPY [vms-8c2]: DISABLED (correct password, DISUSER) is refused -- the SYSUAF login-flag rule applies to a network file access" From b5e1f5003f547441fb1e1364ebc2c9adaf8e65fc Mon Sep 17 00:00:00 2001 From: alice Date: Thu, 10 Sep 2026 00:28:41 +0000 Subject: [PATCH 5/5] vms-8c2: refresh the SYSUAF Prolog-3 byte-golden for the regenerated shipped seed Regenerating distro/rootfs SYSUAF.DAT to carry the full 7-account seed (adding the DISABLED fixture the FAL accept-test needs) changed one byte of the Prolog-3 prolog VBN 1 -- offset 10 went 0x19 -> 0x1c (the index field reflecting 7 records vs the stale file's 2). test_syssvc_dirlogical_acp.c holds a 16-byte golden of the shipped file's leading bytes ("kept in sync with the shipped binary file", vms-586) and its byte-exact $GET/IO$_READVBLK check (Kernel Executive shard 1/6) went RED. Update the golden's one changed byte to match the regenerated file (verified byte-for-byte). This refreshes a golden to the legitimately-changed seed; the assertion still reads VBN 1 byte-exact off the real ODS-2 volume and compares the real shipped prolog -- it is not weakened. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01T4csyFSMUsS8k1D2MgMxk2 --- tests/qemu/test_syssvc_dirlogical_acp.c | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/qemu/test_syssvc_dirlogical_acp.c b/tests/qemu/test_syssvc_dirlogical_acp.c index 6dbac3eff..e9ac371cd 100644 --- a/tests/qemu/test_syssvc_dirlogical_acp.c +++ b/tests/qemu/test_syssvc_dirlogical_acp.c @@ -72,7 +72,7 @@ #define SYSUAF_LEN 512 static const uint8_t SYSUAF_PROLOG3[] = { 0x03, 0x00, 0x02, 0x00, 0x01, 0x00, 0x03, 0x00, - 0x10, 0x00, 0x19, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x10, 0x00, 0x1c, 0x00, 0x00, 0x00, 0x00, 0x00, }; #define ODS2_FH2_M_DIRECTORY 0x2000u