Move to a requirements-first approach

Add doc/requirements/, a structured set of normative requirements
extracted from the current ocserv implementation (internal/*.md,
generated with the requirements-from-implementation protocol) and
from the OpenConnect/AnyConnect protocol sources, reconciled into
protocol/unified.md.

Update AGENTS.md so that new features and bug fixes are documented as
requirements first: find or add the relevant REQ-* entry (with
acceptance criteria) and update the implied tests before changing
code, and confirm in merge requests that existing requirements and
use-cases still hold.

This follows partially https://github.com/microsoft/PromptKit

Signed-off-by: Nikos Mavrogiannopoulos <n.mavrogiannopoulos@gmail.com>
This commit is contained in:
Nikos Mavrogiannopoulos
2026-06-15 18:54:38 +02:00
parent 8e8efd33a6
commit a3ac48c2d4
14 changed files with 5961 additions and 1 deletions
+1 -1
View File
@@ -1,4 +1,4 @@
[codespell]
skip = ./.git,aclocal.m4,./autom4te.cache,./build-aux,config.log,ccan,pcl,*.pem,./src/ocserv-fw-nftables,./tests/test-fw-script
ignore-words-list = alloced,ans,anull,bu,gir,siz,te
ignore-words-list = alloced,ans,anull,bu,gir,siz,te,chage
uri-ignore-words-list = gord,ist
+61
View File
@@ -56,6 +56,62 @@ solely because they do not work on BSD.
---
## Requirements-First Workflow
`doc/requirements/` is the normative description of what ocserv must do (see
`doc/requirements/README.md` for the document map, ID scheme, and per-requirement
format). For any change that alters observable behavior — new feature, bug fix, or
behavior-changing refactor — work in this order:
1. **Requirement first.**
- Find the requirement(s) covering the area you're changing (search
`doc/requirements/` for the relevant `REQ-*` IDs, or use the document map to
find the right file by process/subsystem).
- If your change alters what an existing requirement describes, **update that
requirement first**, re-applying the protocol in `contrib/ai/protocols/` that
generated its document so the new entry matches the rest of the file. Do not
leave a requirement describing the old behavior once the code changes — but do
not break other requirements or use-cases in the process (see below). If a
requirement is independently wrong and needs revising for reasons unrelated to
this change, do that in its own dedicated merge request instead.
- If no requirement covers the new behavior, add one in the appropriate document,
following its existing ID prefix, category tags, and per-requirement format.
- For bug fixes: if the bug is a violation of an existing requirement, cite its ID
in the commit/MR. If the bug reveals a gap in the requirements, extend or add a
requirement describing the *correct* behavior before fixing the code.
2. **Tests second.** Write or update the positive and negative tests implied by the
requirement's **Acceptance** criteria (see "Testing New Functionality" below)
*before* touching implementation code. For bug fixes, confirm the new test
reproduces the bug and fails against the unmodified code.
3. **Code last.** Implement the change so the new/updated tests pass, and so the
code, the requirement, and `doc/ocserv.8.md`/`doc/sample.config` (where
applicable) all agree.
Do not jump straight to step 3 — a code change with no corresponding requirement
update is incomplete, even if it builds and passes existing tests.
### Do not break existing requirements or use-cases
Before submitting a merge request, check that the change does not silently
invalidate requirements or use-cases other than the one you set out to change:
- Search `doc/requirements/` for `REQ-*`/`AC-*`/`OC-*` entries citing the files,
functions, or config options you touched, and confirm each still holds. If one no
longer holds, either your change is wrong, or that requirement was wrong to begin
with (mark it `REVIEW` if unsure which) — never leave code and a `DERIVED`
requirement contradicting each other.
- A requirement that's wrong for reasons unrelated to your change belongs in its own
dedicated merge request — with rationale and evidence it doesn't break other
use-cases — not bundled into this one.
- Run the full local test suite (`ninja -C build test`), not just the test for the
area you changed — requirements frequently span files (e.g. config scope/
inheritance, IPC message contracts) and a regression often shows up as a failure
in a test you didn't expect to touch.
- If your change affects config scope, inheritance, or vhost behavior, also run
`tests/check-config-scope.py` (see `doc/requirements/internal/config.md`).
---
## Build System
The project uses **meson**.
@@ -378,9 +434,14 @@ Before working on a subsystem, read the relevant doc:
### Agent-runnable — verify before declaring a change complete
- [ ] Relevant `doc/requirements/` entries added or updated *before* the code
change (see "Requirements-First Workflow" above)
- [ ] `clang-format --dry-run -Werror` passes on every modified file under `src/` and `tests/`
- [ ] `ninja -C build` succeeds
- [ ] Relevant test passes: `meson test -C build <test-name>`
- [ ] Full suite passes: `ninja -C build test` (no regressions in unrelated tests)
- [ ] Existing `doc/requirements/` entries touching the changed files/functions
still hold (none silently contradicted)
- [ ] Every commit has `Signed-off-by:`
### Human-judgment required — flag in the MR, do not decide unilaterally
+9
View File
@@ -13,6 +13,15 @@ See [`AGENTS.md`](../../AGENTS.md) in the repository root for the full agent gui
Load the appropriate file as a system prompt prefix in your AI tool before starting work.
## Requirements
The `protocols/requirements-*.md` files in this directory generate the
structured requirements documents under
[`doc/requirements/`](../../doc/requirements/README.md). When extending those
documents, re-apply the same protocol that originally generated the target
file (see the document map in `doc/requirements/README.md`) rather than
hand-writing requirements in a different style.
## Acknowledgment
The structure of this framework — composable persona and protocol files that can be
+5
View File
@@ -6,6 +6,11 @@ the worker processes. The following sections describe the purpose and tasks
assigned to each component, and the last section describes the communication
protocol between them.
For normative, testable requirements derived from this design and from the
current implementation, see [`doc/requirements/`](requirements/README.md).
This document explains *how* ocserv works; `doc/requirements/` states *what
must hold*.
# VPN overview
+134
View File
@@ -0,0 +1,134 @@
# ocserv Requirements
This directory contains structured, testable requirements derived from the
ocserv implementation and from the protocols ocserv must interoperate with
(OpenConnect, Cisco AnyConnect). It complements `doc/design.md`:
- **`doc/design.md`** — narrative description of *how* ocserv works
(process model, IPC sequences, cookie lifecycle).
- **`doc/requirements/`** — normative description of *what must hold*,
in atomic, testable statements with RFC 2119 keywords, source citations,
and acceptance criteria.
Each requirement links back to the `design.md` section that explains the
context, and forward to the test (if any) that verifies it. When code,
design.md, and a requirement disagree, treat it as a `[REVIEW]` item, not
as an automatic override — resolve by reading the cited source and, if
still unclear, ask a maintainer.
## Generation protocols
These documents are generated and maintained using the reasoning protocols
in `contrib/ai/protocols/`:
| Directory | Protocol | Produces |
|-----------|----------|----------|
| `internal/` | `requirements-from-implementation.md` | Requirements derived from current ocserv source: what each process (main, sec-mod, worker) and the IPC layer between them actually guarantee. |
| `protocol/sources/` | `requirements-elicitation.md` | Per-source requirement extractions (OpenConnect protocol draft, observed AnyConnect behavior). Working artifacts, not the final spec. |
| `protocol/unified.md` | `requirements-reconciliation.md` | A single reconciled wire-protocol spec, merging `protocol/sources/*` with the `OCSERV` implementation and relevant TLS/DTLS RFCs, classifying agreement (UNIVERSAL/MAJORITY/DIVERGENT/EXTENSION). |
When adding to or updating a document, re-apply the protocol that generated
it — do not hand-write requirements in a different style than the rest of
the file.
## Document map
| Document | ID prefix | Process(es) | Sources |
|----------|-----------|-------------|---------|
| `internal/ipc.md` | `REQ-IPC-` | all (cross-process) | `src/ipc.proto`, `src/ctl.proto`, `doc/design.md#ipc-communication*` |
| `internal/config.md` | `REQ-CONFIG-` | all (cross-process) | `src/config.c`, `src/config-ports.c`, `src/config-kkdcp.c`, `src/subconfig.c`, `src/sup-config/file.c`, `src/cfg.proto`, `src/vpn.h`, `src/vhost.h`, `doc/sample.config`, `tests/check-config-scope.py`, `tests/config-inherit.c` |
| `internal/sec-mod.md` | `REQ-SECMOD-` | sec-mod | `src/sec-mod*.c`, `src/sec-mod-auth.h`, `src/auth/*`, `src/acct/*` |
| `internal/authentication.md` | `REQ-AUTH-` | sec-mod (primary), worker, main | `src/sec-mod-auth.{c,h}`, `src/auth/*`, `src/acct/*`, `src/config.c`, `src/subconfig.c`, `src/worker-auth.c`, `doc/sample.config` |
| `internal/main.md` | `REQ-MAIN-` | main | `src/main.c`, `src/main-*.c` |
| `internal/worker.md` | `REQ-WORKER-` | worker | `src/worker.c`, `src/worker-*.c` |
| `protocol/sources/openconnect.md` | `OC-PROTO-` | n/a (external spec) | `~/projects/openconnect/protocol/draft-openconnect.xml` |
| `protocol/sources/anyconnect.md` | `AC-CLIENT-` | n/a (observed client behavior) | `doc/README-cisco-svc.md`, worker CSTP/HTTSP handling |
| `protocol/unified.md` | `REQ-PROTO-` | worker (mostly) | reconciles `OC-PROTO-*`, `AC-CLIENT-*`, RFC-TLS/RFC-DTLS, `OCSERV` (= `internal/worker.md` + code) |
`internal/ipc.md` is generated first — the other `internal/*` documents cite
its `REQ-IPC-*` entries wherever a behavior crosses a process boundary,
instead of restating the IPC contract.
## ID scheme
```
REQ-<PREFIX><CATEGORY>-<NNN>
```
- `<PREFIX>` identifies the document (table above); `IPC` has no
further category — IDs are `REQ-IPC-NNN`, grouped by message name in
the document body.
- `<CATEGORY>` for `internal/*` documents uses the tags from
`requirements-from-implementation.md`: `INIT`, `AUTH`, `ACCT`,
`SESSION`, `CFG`, `NET`, `SEC`, `ERR`, `TEARDOWN`.
- `<CATEGORY>` for `protocol/unified.md` uses the tags from
`requirements-reconciliation.md`: `CONN`, `AUTH`, `SESSION`, `DATA`,
`CTRL`, `CFG`, `COMPAT`, `SEC`, `EXT`.
- `<NNN>` is a 3-digit sequence number, unique within
`<PREFIX><CATEGORY>` and never reused (if a requirement is removed,
mark it `WITHDRAWN`, do not renumber).
Examples: `REQ-AUTH-AUTH-001`, `REQ-WORKER-NET-003`, `REQ-IPC-014`,
`REQ-PROTO-COMPAT-002`.
`protocol/sources/*.md` use their own non-normative working IDs
(`OC-PROTO-<CAT>-<NNN>`, `AC-CLIENT-<CAT>-<NNN>`) — these are inputs to
`unified.md` and are not cited from `internal/*`.
## Status legend
Every requirement carries a `Status`:
| Status | Meaning |
|--------|---------|
| `DERIVED` | Directly supported by current code/spec; no open questions. |
| `REVIEW` | Behavior observed but contradicts documentation, another requirement, or looks like a possible defect — needs a maintainer decision. |
| `AMBIGUOUS` | Cannot be classified as essential/incidental without domain knowledge; two interpretations given. |
| `UNDOCUMENTED` | Behavior exists in code with no doc, test, or evident purpose. |
| `WITHDRAWN` | Previously published requirement no longer applies; kept for ID stability, with a note explaining why. |
`protocol/unified.md` additionally carries a `Class` per
`requirements-reconciliation.md`: `UNIVERSAL`, `MAJORITY`, `DIVERGENT`,
`EXTENSION`.
## Per-requirement format
```markdown
### REQ-<PREFIX><CAT>-<NNN>
**Requirement:** <system/process> MUST/SHOULD/MAY <behavior> when
<condition>, so that <rationale>.
**Strength:** MUST | SHOULD | MAY | MUST NOT | SHOULD NOT
**Status:** DERIVED | REVIEW | AMBIGUOUS | UNDOCUMENTED | WITHDRAWN
**Source:** <file>:<line> [, ...] ; doc/design.md#<section>
**Acceptance:** <test path or description> — positive|negative|unit ;
local | CI (root/full stack)
**Links:** <other REQ-IDs this depends on or relates to>
```
For `protocol/unified.md`, add **Class** and, for non-UNIVERSAL entries,
**Divergence:** describing what differs between sources and why.
## Document frontmatter
Each requirements document opens with:
```yaml
---
title: <short title>
generator: requirements-from-implementation | requirements-elicitation | requirements-reconciliation
process: main | sec-mod | worker | ipc | n/a
id-prefix: REQ-<PREFIX>
sources:
- <file or glob>
- doc/design.md#<section>
---
```
## Conventions carried over from `contrib/ai/protocols/`
- **Negative requirements are mandatory for `SEC`, `AUTH`, and `IPC`**
categories — write the MUST NOT before the MUST.
- **Privilege boundary violations are never `[UNDOCUMENTED]` or
incidental** — they are `SEC` requirements, always essential.
- **IPC acceptance criteria must cite protobuf field names** from
`src/ipc.proto` / `src/ctl.proto`, not vague descriptions.
File diff suppressed because it is too large Load Diff
+470
View File
@@ -0,0 +1,470 @@
---
title: configuration subsystem requirements
generator: requirements-from-implementation
process: main, sec-mod, worker (shared)
id-prefix: REQ-CONFIG
sources:
- src/config.c
- src/config-ports.c
- src/config-kkdcp.c
- src/subconfig.c
- src/sup-config/file.c
- src/cfg.proto
- src/vpn.h
- src/vhost.h
- doc/sample.config
- tests/check-config-scope.py
- tests/config-inherit.c
---
# Configuration Subsystem Requirements
`src/config.c` (plus `src/config-ports.c`, `src/config-kkdcp.c`,
`src/subconfig.c`, `src/sup-config/file.c`) implements `ocserv.conf` parsing
via `inih`. It is linked into all three processes — main, sec-mod (each
sec-mod instance reloads its own copy), and the worker (which re-parses a
root-created snapshot rather than the live file, REQ-CONFIG-SEC-001) — so
this document is cross-process, like `internal/ipc.md`.
The central design event covered here is commit `139ff827` ("config:
restructure per-vhost configuration for clarity and maintainability",
resolves #705): it split per-vhost configuration into a protobuf-generated
`ReloadableConfig` (`src/cfg.proto`, SIGHUP-reloadable) and a `static_cfg_st`
(`src/vpn.h`, restart-only), and introduced named-vhost inheritance from the
default vhost via a pack/unpack round-trip for the former and explicit
field-by-field copy for the latter.
## INIT
### REQ-CONFIG-INIT-001 — `ocserv.conf` is parsed as INI with `[vhost:<name>]` sections; the unnamed/first vhost is the default
**Requirement:** `parse_cfg_file()` MUST parse the configuration file with
`ini_parse()` and `cfg_ini_handler()`. A line with no enclosing section
applies to the **default vhost** (`vhost->name == NULL`, always the list
tail per `default_vhost()`). A section header `[vhost:<name>]` MUST select
or create (`vhost_add()`) a named vhost for all subsequent lines until the
next section header; any other non-empty section name MUST be rejected with
a `skipping unknown section` warning (non-fatal, line ignored) unless
`reload` or `is_worker` is set (in which case the warning is suppressed).
Virtual host names are canonicalized via `sanitize_name()`/`idna_map()`; a
canonicalization that changes the name MUST print a `note:` (suppressed
under reload/worker).
**Strength:** MUST
**Status:** DERIVED
**Source:** src/config.c:943-1030 (`cfg_ini_handler`), src/vhost.h:115-122
(`GETVHOST`/`default_vhost`)
**Acceptance:** unit, local — a config with one `[vhost:example]` section
and no other vhost sections MUST produce exactly two `vhost_cfg_st` entries
(default + `example`); `find_vhost(head, "example")` MUST return the named
one, `find_vhost(head, NULL)` and `find_vhost(head, "unknown")` MUST both
return the default. Negative: a config containing `[bogus]` MUST NOT abort
parsing (line ignored, warning printed on first parse only).
**Links:** REQ-CONFIG-CFG-002, REQ-CONFIG-CFG-003
### REQ-CONFIG-INIT-002 — Two-tier per-vhost config: `ReloadableConfig` (SIGHUP-reloadable) vs `static_cfg_st` (restart-only)
**Requirement:** Every `vhost_cfg_st` MUST carry exactly two configuration
sub-objects with disjoint lifetimes: `config` (`ReloadableConfig *`,
protobuf-generated from `src/cfg.proto`, replaced wholesale on every
`reload_cfg_file()`) and `static_config` (`struct static_cfg_st`, `src/vpn.h`,
populated once at startup and never replaced by reload). Code MUST access
these only via `GETRCONFIG(s)` / `GETSCONFIG(s)` (`src/vhost.h:116-117`),
never by reaching into `vhost_cfg_st` fields directly, so that the
reload/no-reload distinction stays enforced at a single point. Adding a new
*reloadable* field requires only editing `cfg.proto` (regenerate with
`protoc-c` per `AGENTS.md`); adding a new *restart-only* field requires
editing `static_cfg_st` in `src/vpn.h` AND (per REQ-CONFIG-CFG-003) deciding
its inheritance behavior in `vhost_inherit_static_config()`.
**Strength:** MUST
**Status:** DERIVED
**Source:** src/vhost.h:35-78, 101-117; src/cfg.proto; src/vpn.h:224-275
**Acceptance:** `grep -rn '\->config\.\|->static_config\.' src/*.c` (outside
`config.c`/`vhost.h`) MUST find no direct struct-field access bypassing
`GETRCONFIG`/`GETSCONFIG``[OPEN]`, not run in this pass; recommended as a
lint/CI check given how easy it is to reintroduce direct access after #705.
**Links:** REQ-CONFIG-CFG-001, REQ-CONFIG-CFG-002, REQ-CONFIG-CFG-003
---
## CFG — scope, inheritance, defaults, reload
### REQ-CONFIG-CFG-001 — Every config option has exactly one of five `[scope: ...]` annotations, enforced by `tests/check-config-scope.py`
**Requirement:** Every option documented in `doc/sample.config` MUST carry a
`# [scope: ...]` annotation from a closed vocabulary of five values:
`global (non-reloadable)`, `vhost (non-reloadable)`, `global`, `vhost`,
`vhost user`. Every field of `struct cfg_st`/`struct static_cfg_st` in
`src/vpn.h` MUST carry a matching inline `[scope: ...]` comment.
`tests/check-config-scope.py` MUST pass, enforcing the following
cross-references (its checks ah):
- (a) every `sample.config` option has a scope annotation;
- (b) every reloadable `[... global]` option has a matching
`error_on_vhost()` call in `config.c` (REQ-CONFIG-CFG-004);
- (c) every `error_on_vhost()` call in `config.c` is annotated
`[... global]` in `sample.config` (modulo
`DEPRECATED_GLOBAL_ALIASES`);
- (d)/(e) every `[...user...]`-annotated option is handled in
`src/sup-config/file.c` and vice versa (REQ-CONFIG-CFG-006);
- (f) every `[...user...]` option is also `[...vhost...]` (per-user
overrides require a per-vhost base);
- (g) every `cfg_st`/`static_cfg_st` field in `src/vpn.h` has a
`[scope:]` comment;
- (h) every `[scope: vhost (non-reloadable)]` field in `static_cfg_st` is
referenced in `vhost_inherit_static_config()` or listed in
`STATIC_VHOST_INHERIT_EXCEPTIONS` (REQ-CONFIG-CFG-003).
**Strength:** MUST
**Status:** DERIVED
**Source:** tests/check-config-scope.py (full file); src/vpn.h:224-275
(`static_cfg_st` annotations)
**Acceptance:** `meson test -C build check-config-scope` (or
`tests/check-config-scope.py` directly) exits 0. Negative: adding a new
option to `sample.config` without a `[scope:]` line, or a new
`static_cfg_st` field without a `[scope:]` comment, MUST fail check (a) or
(g) respectively — this is the project's primary safeguard against
config/doc/code drift for scope semantics.
**Links:** REQ-CONFIG-CFG-002, REQ-CONFIG-CFG-003, REQ-CONFIG-CFG-004,
REQ-CONFIG-CFG-006
### REQ-CONFIG-CFG-002 — Named vhosts inherit `ReloadableConfig` from the default vhost via a protobuf pack/unpack deep copy, taken on first entry into the `[vhost:NAME]` section
**Requirement:** The first time `cfg_ini_handler()` encounters a
`[vhost:NAME]` section for a given vhost (`vhost->cfg_inherited == 0`), and
**before** parsing any option in that section, it MUST replace
`vhost->config` with `cfg_copy_from_default(defvhost->config, vhost->pool)`
— a full protobuf `pack()`/`unpack()` round-trip of the default vhost's
*current* `ReloadableConfig` (including all scalars, strings, repeated
fields, and sub-messages such as `NetworkConfig`), reallocated under
`vhost->pool` via a talloc-backed `ProtobufCAllocator` so a single
`talloc_free(vhost->config)` releases the entire copy. It MUST then set
`vhost->cfg_inherited = 1` so the copy happens at most once per
parse/reload pass. Options subsequently parsed in the `[vhost:NAME]` section
overlay this inherited snapshot field-by-field.
**Strength:** MUST
**Status:** DERIVED
**Source:** src/config.c:540-568 (`cfg_copy_from_default`), 1014-1029
(call site); tests/config-inherit.c (phases 13)
**Acceptance:** tests/config-inherit.c phase 1 — a `[vhost:named]` section
that sets only `auth`/`server-cert`/`server-key`/`ipv4-network`/
`ipv4-netmask` MUST end up with `keepalive`, `dpd`, `idle_timeout`,
`cookie_timeout`, `deny_roaming`, `banner`, `default_domain`, `rx_per_sec`,
and `default_mtu` all equal to the default vhost's values
(`check_inherited()`). Phase 2 — if the named vhost additionally sets
`keepalive = 55555`, only `keepalive` differs; all other inherited fields
are unchanged. Phase 3 — both properties survive `reload_cfg_file()`.
**Divergence / ordering caveat `[REVIEW]`**: the snapshot is of the default
vhost's `ReloadableConfig` **as parsed up to that point in the file** — if
`doc/sample.config`'s convention of "default vhost options first, named
`[vhost:...]` sections last" is violated (a default-vhost option appears
*after* a `[vhost:NAME]` section in the file), that option will **not** be
inherited by vhosts whose `[vhost:NAME]` section already triggered the
copy. This ordering dependency is not validated by `check_cfg()` or
documented in `doc/ocserv.8.md`/`doc/sample.config`. `[CANDIDATE for doc
addition]`: document the required section ordering, or `[OPEN]`: consider
deferring the inheritance copy to the post-parse loop
(`src/config.c:1700-1799`, where `vhost_inherit_static_config()` already
runs) so it is order-independent like REQ-CONFIG-CFG-003.
**Links:** REQ-CONFIG-CFG-001, REQ-CONFIG-CFG-003, REQ-CONFIG-CFG-005,
REQ-CONFIG-CFG-007
### REQ-CONFIG-CFG-003 — Named vhosts inherit `static_cfg_st` fields via `vhost_inherit_static_config()`, run once after the full file is parsed
**Requirement:** After `ini_parse()` completes, `parse_cfg_file()` MUST walk
all vhosts in reverse-add order (default vhost first, per
`list_for_each_rev`) and, for every named vhost, call
`vhost_inherit_static_config(vhost, defvhost)`. This function MUST:
- **unconditionally** copy every `[scope: global (non-reloadable)]` field
(`port`, `udp_port`, `uid`, `gid`, `sec_mod_scale`, `stats_reset_time`,
`socket_file_prefix`, `occtl_socket_file`, `chroot_dir`) from the default
vhost — named vhosts cannot override these regardless of what they set;
- **conditionally** copy every `[scope: vhost (non-reloadable)]` field
(`cert`/`cert_size`, `key`/`key_size`, `ca`, `dh_params_file`,
`cert_hash` (`ANYCONNECT_CLIENT_COMPAT`), `pin_file`, `srk_pin_file`,
`key_pin`, `srk_pin`, `auth`/`auth_methods`, `acct`) **only if** the
named vhost left that field unset (NULL / zero-size / zero-count) —
"inherit if not set, else keep the vhost's own value";
- **exclude** `sup_config_type`: `cfg_alloc_vhost()` pre-sets it to
`SUP_CONFIG_FILE` (non-zero) for every vhost, so there is no zero
sentinel to distinguish "not set" from "explicitly set to the default" —
a named vhost needing a different `sup-config` MUST set it explicitly.
This exclusion is enumerated in `STATIC_VHOST_INHERIT_EXCEPTIONS`
(REQ-CONFIG-CFG-001(h)).
**Strength:** MUST
**Status:** DERIVED
**Source:** src/config.c:1766-1768 (call site, post-parse loop),
1820-1915 (`vhost_inherit_static_config`, `VHOST_INHERIT*` macros);
tests/config-inherit.c phase 4
**Acceptance:** tests/config-inherit.c phase 4 — a `[vhost:named]` section
that sets only `ipv4-network`/`ipv4-netmask` (omitting `auth`,
`server-cert`, `server-key` entirely) MUST end up with
`named->static_config.cert`/`cert_size`, `key`/`key_size`, and
`auth`/`auth_methods` all equal to the default vhost's (deep-copied
strings, not shared pointers, except `acct`/`auth[].auth_ctx`/`dl_ctx` which
are intentionally shared read-only module state per the function's
docstring). Negative: a named vhost that DOES set its own `auth`/cert/key
MUST keep them — `vhost_inherit_static_config` must not clobber
explicitly-set values.
**Links:** REQ-CONFIG-CFG-001, REQ-CONFIG-CFG-002
### REQ-CONFIG-CFG-004 — `[scope: ... global]` reloadable options MUST be rejected inside `[vhost:NAME]` sections via `error_on_vhost()`
**Requirement:** When `cfg_ini_handler()` parses an option annotated
`[scope: global]` or `[scope: global (non-reloadable)]` in `sample.config`
(e.g. `listen-host`, `udp-listen-host`, `tcp-port`, `udp-port`,
`run-as-user`, `run-as-group`, `socket-file`, `occtl-socket-file`,
`chroot-dir`, `server-stats-reset-time` and others — see
`tests/check-config-scope.py` checks (b)/(c) for the full set) while
`vhost->name != NULL` (i.e. inside a `[vhost:NAME]` section), it MUST call
`error_on_vhost(vhost->name, "<option-name>")`, which prints `'<option-name>'
cannot be set inside a virtual host section` to stderr and returns `true`;
the calling code path MUST then skip applying the value (the option is a
no-op inside the vhost section, not a fatal error — parsing continues).
**Strength:** MUST
**Status:** DERIVED
**Source:** src/config.c:893-904 (`error_on_vhost`), 1047-1320 (call sites)
**Acceptance:** [SEC/CFG] negative — a config with `tcp-port = 9000` inside
a `[vhost:example]` section MUST print the `error_on_vhost` message, MUST
NOT change `example`'s `static_config.port` (it remains inherited from the
default per REQ-CONFIG-CFG-003), and parsing MUST continue (not
`exit(EXIT_FAILURE)`) — confirm via `check-config-scope.py` test (b)/(c) plus
a targeted parse test.
**Links:** REQ-CONFIG-CFG-001, REQ-CONFIG-CFG-003
### REQ-CONFIG-CFG-005 — Reloadable scalar defaults are applied via `apply_default_conf()` with explicit `has_<field>=1`, before parsing and before any inheritance copy
**Requirement:** `cfg_new()` (called for every vhost at `vhost_add()` time,
and again per-vhost on every reload via `reload_cfg_file()`) MUST call
`apply_default_conf()` immediately after `reloadable_config__init()`. For
every `ReloadableConfig` optional scalar with a non-zero/non-empty default
(`rekey_time`, `cookie_timeout`, `auth_timeout`, `ban_time`,
`ban_reset_time`, `max_ban_score`, `ban_points_wrong_password`,
`ban_points_connect`, `ban_points_kkdcp`, `dpd`, `mobile_dpd`, `keepalive`,
`switch_to_tcp_timeout`, `mobile_idle_timeout`,
`network->ipv6_subnet_prefix`, plus boolean-with-default-true fields
`dtls_legacy`, `dtls_psk`, `predictable_ips`, `use_utmp`, and (if
`ENABLE_COMPRESSION`) `no_compress_limit`), `apply_default_conf()` MUST set
both the value AND `has_<field> = 1`. Setting `has_<field>` is what makes
the default participate in protobuf pack/unpack (REQ-CONFIG-CFG-002): a
named vhost that never overlays one of these fields inherits the **default
vhost's effective value** (its own default, or whatever the default vhost
section explicitly set), not a protobuf-zero/absent value. Permanent
(`static_cfg_st`) defaults (`stats_reset_time = 1 week`, `log_level`,
`syslog_facility = LOG_DAEMON`, `occtl_socket_file`) are applied only when
`!reload`.
**Strength:** MUST
**Status:** DERIVED
**Source:** src/config.c:766-824 (`apply_default_conf`), 826-845 (`cfg_new`)
**Acceptance:** unit, local — a minimal config with no `keepalive`/`dpd`/
`rekey-time`/etc. directives MUST produce `DEFAULT_KEEPALIVE_TIME` /
`DEFAULT_DPD_TIME` / `DEFAULT_REKEY_TIME` (etc., from `defaults.h` or
equivalent) in the default vhost's `ReloadableConfig`, with the
corresponding `has_*` flags set to 1; a named vhost in the same config MUST
inherit these exact values (REQ-CONFIG-CFG-002), not protobuf defaults
(0/NULL).
**Links:** REQ-CONFIG-CFG-002
### REQ-CONFIG-CFG-006 — `[scope: vhost user]` options are additionally parsed per-user/per-group via `src/sup-config/file.c`, transit IPC as `GroupCfgSt`, and are merged onto the vhost default by main in `apply_default_config()`
**Requirement:** Options annotated `[scope: vhost user]` in
`sample.config` MUST be both (a) settable at vhost level (subject to
REQ-CONFIG-CFG-002/003 inheritance) as a default, AND (b) individually
overridable per-user or per-group via the supplemental-config mechanism
(`sup-config = file`, `per-user-dir`/`per-group-dir`), parsed by
`src/sup-config/file.c`'s INI handler using the `READ_RAW_*`/`READ_TF`
macro family. These macros write directly into a `GroupCfgSt` (`config`
field of `SecmSessionReplyMsg`, `src/ipc.proto`) — a real protobuf message
— setting its generated `has_<field>=1` flags (e.g.
`msg->config->has_no_udp`, src/sup-config/file.c:112) for every field
present in the per-user/per-group file. This is the **same** `has_<field>`
mechanism as REQ-CONFIG-CFG-005, not a distinct `is_set` convention: a
`GroupCfgSt` with no per-user overrides is `GROUP_CFG_ST__INIT`-initialized
(all `has_*` false, all pointers NULL) by `handle_secm_session_open_cmd()`
(src/sec-mod-auth.c:504-505), and `get_sup_config()`
(src/sec-mod-auth.c:593-594) only sets the fields the per-user/group file
actually overrides. `iroute` directives are additionally collected across
all per-user config files into `config->known_iroutes` via
`load_iroutes()`/`append_iroutes_from_file()` when `expose_iroutes` is set,
for `occtl show iroutes`.
The populated `GroupCfgSt` travels from sec-mod to main as
`SecmSessionReplyMsg.config` over `CMD_SECM_SESSION_REPLY`
(src/main-sec-mod-cmd.c:537/recv at 537-538) — i.e. per-user overrides DO
cross the sec-mod->main IPC boundary as part of this message, same as the
rest of the session-open reply. The merge with the vhost-level default
happens entirely in **main**: `session_open()`
(src/main-sec-mod-cmd.c:494) sets `proc->config = msg->config` (line ~593)
and then calls `apply_default_config(sec_mod_instance, proc, proc->config)`
(src/main-sec-mod-cmd.c:336-491, called at line 597). For each
`[scope: vhost user]` field, `apply_default_config()` checks
`!gc->has_<field>` (scalars/bools) or `gc-><field> == NULL`
(strings/repeated fields); if unset — meaning no per-user/per-group override
was found — it copies the vhost-level value (`vhost->config->...`) into
`gc` and, for the `has_*` cases, sets `has_<field> = 1`. If the field was
already set by `get_sup_config()`, the per-user/group value is left
untouched. The resulting merged `GroupCfgSt` (`proc->config`) is what main
forwards to the worker as the session's effective configuration.
**Strength:** MUST
**Status:** DERIVED
**Source:** src/sup-config/file.c:44-80 (`READ_RAW_*`/`READ_TF` macros),
112 (`has_no_udp` example); src/ipc.proto:30-69 (`group_cfg_st`), 342-347
(`secm_session_reply_msg`); src/sec-mod-auth.c:474-505, 593-594
(`GROUP_CFG_ST__INIT`, `get_sup_config()`); src/main-sec-mod-cmd.c:336-491
(`apply_default_config`), 494-598 (`session_open`); src/config.c:686-764
(`iroutes_handler`, `append_iroutes_from_file`, `load_iroutes`);
tests/check-config-scope.py checks (d)/(e)/(f)
**Acceptance:** `tests/check-config-scope.py` checks (d)(f) pass (every
`[...user...]` option is handled in `sup-config/file.c` and vice versa,
modulo `PER_USER_ONLY`; every `[...user...]` option is also
`[...vhost...]`). Functional: a per-user config file setting `rx-data-per-sec`
for user `alice` MUST override the vhost-level `rx-data-per-sec` for
`alice`'s session only — confirm by setting different vhost-default and
per-user values, and observing `apply_default_config()` leaves
`gc->has_rx_per_sec`/`gc->rx_per_sec` at the per-user value (does not
overwrite it from `vhost->config`), while a second user with no per-user
override receives the vhost default.
`[LIMITATION (gitlab#265/!154): there is no per-RADIUS-class sup-config
source — config-per-group/config-per-user and `radius_get_sup_config()`'s
attribute mapping (REQ-AUTH-AUTH-026) are the only contributors to
`GroupCfgSt`. gitlab!154 (closed, unmerged) sketched a `per-radius-class-dir`
source for this; see the related limitation note on REQ-AUTH-AUTH-029.]`
**Links:** REQ-CONFIG-CFG-001, REQ-CONFIG-CFG-005, REQ-AUTH-AUTH-026,
REQ-AUTH-AUTH-029
### REQ-CONFIG-CFG-007 — SIGHUP reload replaces each vhost's `ReloadableConfig` via archive-and-reparse (main) or immediate-free-and-reparse (sec-mod); in-flight references are kept alive via an attic + usage-count until drained
**Requirement:** `reload_cfg_file()` MUST, for each vhost: (a) if called for
**main** (`sec_mod == 0`), call `archive_cfg()`, which moves the vhost's
current `config` and `usage_count` into a new `attic_entry_st`
(`talloc_steal`'d so they survive the parent's lifetime), adds the entry to
`vhost->attic` **only if** `*usage_count != 0` (otherwise frees it
immediately), sets `vhost->config = NULL` and `vhost->usage_count = NULL`,
and resets `vhost->cfg_inherited = 0` so the next parse re-triggers
REQ-CONFIG-CFG-002's inheritance copy against the *new* default-vhost
config; (b) if called for **sec-mod** (`sec_mod == 1`), call `clear_cfg()`,
which immediately `talloc_free()`s the old `config` (sec-mod holds no
long-lived references that survive a reload, unlike main's in-flight
`proc_st` workers) and likewise resets `cfg_inherited = 0`; (c) for every
vhost with `config == NULL`, call `cfg_new()` (re-applying defaults, REQ-
CONFIG-CFG-005); (d) re-run `parse_cfg_file()` with `CFG_FLAG_RELOAD` (and
`CFG_FLAG_SECMOD` if `sec_mod`). Separately, `clear_old_configs()` MUST be
called periodically (or after reload) to walk each vhost's `attic` and
`talloc_free()` any entry whose `*usage_count` has dropped to 0, releasing
the old `ReloadableConfig` once no in-flight worker still references it.
**Strength:** MUST
**Status:** DERIVED
**Source:** src/config.c:2384-2432 (`archive_cfg`, `clear_cfg`),
2501-2525 (`reload_cfg_file`), 2579-2595 (`clear_old_configs`);
src/vhost.h:41-43 (`usage_count`, `attic`); tests/config-inherit.c phase 3
**Acceptance:** tests/config-inherit.c phase 3 — after `reload_cfg_file()`
with an unchanged config, `check_inherited(named)` MUST still pass (inherited
fields survive reload because `cfg_inherited` was reset and the inheritance
copy re-ran against the freshly-reparsed default vhost). `[OPEN]`: this pass
did not add a test for the attic/usage_count drain path itself (a worker
holding a reference across a reload, then exiting, should cause its vhost's
attic entry to be freed by a subsequent `clear_old_configs()`).
**Links:** REQ-CONFIG-CFG-002, REQ-CONFIG-CFG-003, REQ-CONFIG-CFG-005,
REQ-MAIN-CFG-001 (cross-process ordering: main calls `reload_cfg_file()`
only after all sec-mod instances have completed their own reload)
---
## SEC
### REQ-CONFIG-SEC-001 — The worker process parses configuration from a root-created snapshot, never the live config file path
**Requirement:** When `parse_cfg_file()` is called with `CFG_FLAG_WORKER`
set (`PROC_FS_SUPPORTED` builds), it MUST NOT call `ini_parse()` on the
configured file path directly. Instead it MUST resolve the path via
`snapshot_lookup_filename(config_snapshot, file, ...)` (falling back to
`OLD_DEFAULT_CFG_FILE`'s snapshot), parse that snapshot file, and then
rewrite `dh_params_file`, `ocsp_response`, and every `cert[]` entry to their
snapshot equivalents via `replace_file_with_snapshot()`. The non-worker path
(main/sec-mod) parses the live file directly and additionally calls
`snapshot_create()` for the config file itself plus the same
cert/DH-params/OCSP-response files, so the snapshots the worker later reads
are root-controlled copies taken at main/sec-mod's privilege level, not
paths the unprivileged worker resolves itself.
**Strength:** MUST
**Status:** DERIVED
**Source:** src/config.c:1592-1673 (`PROC_FS_SUPPORTED` branch of
`parse_cfg_file`)
**Divergence**: on non-`PROC_FS_SUPPORTED` (BSD) builds, this snapshot
indirection does not exist — `src/config.c:1674-1694` has the worker parse
the live file path directly, same as main/sec-mod. Per `AGENTS.md`'s BSD
best-effort policy, this is an accepted platform difference, not a defect,
but it does mean the SEC property above is Linux-only — `[REVIEW]`: confirm
whether worker-side TLS/cert paths on BSD builds are otherwise protected by
`chroot`/seccomp such that direct file access doesn't reintroduce a
privilege-boundary concern (`AGENTS.md`'s "no direct filesystem access
outside its seccomp profile" rule).
**Acceptance:** [SEC] — on Linux, confirm the worker process's open file
descriptors for cert/key/CRL/config files resolve under the snapshot
directory (e.g. `/proc/<pid>/fd`), not the configured `server-cert`/
`ocserv.conf` paths.
**Links:**
---
## ERR
### REQ-CONFIG-ERR-001 — `check_cfg()` enforces mandatory options and fails closed (`exit(EXIT_FAILURE)`) at parse/reload time, per-vhost
**Requirement:** After inheritance (REQ-CONFIG-CFG-002/003) and auth/acct
setup, `check_cfg(vhost, defvhost, silent)` MUST `exit(EXIT_FAILURE)` (after
printing an `error:` message, prefixed with the vhost name if named) if, for
that vhost: no authentication method was configured
(`static_config.auth[0].enabled == 0`); `socket_file_prefix` is unset
(default vhost only — "the 'socket-file' configuration option must be
specified"); `static_config.port == 0` ("the tcp-port option is mandatory");
`cert_size == 0 || key_size == 0`; or neither `ipv4-network` nor
`ipv6-network` is set, or either is set without its corresponding
netmask/prefix. These checks run for **every** vhost (default and named)
after inheritance has had a chance to fill in missing values from the
default vhost — i.e. a named vhost satisfies these checks via inheritance,
not by repeating every mandatory option.
**Strength:** MUST
**Status:** DERIVED
**Source:** src/config.c:1917-1976 (`check_cfg`)
**Acceptance:** [CFG] negative — a `[vhost:example]` section that sets
`ipv4-network` without `ipv4-netmask`, with no default-vhost
`ipv4-netmask` to inherit, MUST cause `ocserv -c <file> -t` (or worker/main
startup) to print `no mask found for IPv4 network` (prefixed with
`example: `) and `exit(EXIT_FAILURE)` — process does not start with a
partially-valid config. Positive: the same config with the netmask present
(either set directly or inherited from the default vhost) MUST pass.
**Links:** REQ-CONFIG-CFG-002, REQ-CONFIG-CFG-003
---
## Completeness notes
- **`src/config-ports.c` / `src/config-kkdcp.c`**: port-range and KKDCP
(Kerberos KDC proxy, `HAVE_GSSAPI`) parsing helpers are included by
`config.c` but not separately analyzed in this pass —
`[UNDOCUMENTED: candidate REQ-CONFIG-CFG-* for port-list/range syntax and
KKDCP URL-forwarding config (parse_kkdcp, REQ-CONFIG-CFG-005's
`urlfw`/`urlfw_size` handling), if found to have non-obvious validation
rules.]`
- **`src/subconfig.c`** (`auth = pam[...]`/`radius[...]`/etc. bracketed
sub-option syntax, `expand_brackets_string`, `MAX_SUBOPTIONS`): the
bracket-expansion mechanism itself is generic INI-within-INI parsing with
no MUST/MUST NOT beyond "the configured module's `get_brackets_string`
receives the expanded suboptions" — per-module option semantics belong to
`internal/sec-mod.md` (auth/acct module config), not here.
`[UNDOCUMENTED: candidate REQ-CONFIG-CFG-* if a malformed `[...]` bracket
(unterminated, nested) is found to have a non-obvious failure mode rather
than a parse error.]`
- **`camouflage`/`camouflage_secret`/`camouflage_realm`**: these are
`[scope: vhost]` `ReloadableConfig` fields parsed by the ordinary
mechanisms covered by REQ-CONFIG-CFG-002/004; their *behavioral* contract
(URL-secret gate, 401/404/405 responses) is fully specified in
`protocol/unified.md` REQ-PROTO-COMPAT-006 and `internal/worker.md`
REQ-WORKER-AUTH-004 — not duplicated here.
- **`occtl reload`** (`CMD_SECM_RELOAD` / SIGHUP from `occtl`): triggers the
same `reload_cfg_file()` path as `SIGHUP`; the IPC framing is covered by
`internal/ipc.md`, the cross-process ordering by `REQ-MAIN-CFG-001`. Not
re-derived here.
- **Command-line flags** (`-c`, `-f`/`--foreground`, `-d`/`--debug`, `-t`/
`--test`, `--pid-file`, etc., `src/config.c` `getopt` handling): out of
scope for this pass — these select *which* file is parsed and how the
process behaves around parsing (foreground vs daemonize, test-and-exit),
not configuration *content* semantics.
+596
View File
@@ -0,0 +1,596 @@
---
title: IPC requirements
generator: requirements-from-implementation
process: ipc
id-prefix: REQ-IPC
sources:
- src/ipc.proto
- src/ctl.proto
- src/defs.h
- src/sec-mod.c
- src/sec-mod-auth.c
- src/main-auth.c
- src/main-worker-cmd.c
- src/main-sec-mod-cmd.c
- src/worker-auth.c
- src/tlslib.c
- doc/design.md#ipc-communication
- doc/design.md#ipc-communication-for-sid-assignment
- doc/design.md#ipc-communication-for-session-termination
---
# IPC Requirements
This document covers the Unix-socket, protobuf-c (`src/ipc.proto`,
`src/ctl.proto`) messages exchanged between the three ocserv processes
(main, sec-mod, worker) and `occtl`. It is the spine cited by
`internal/main.md`, `internal/sec-mod.md`, and `internal/worker.md` for any
behavior that crosses a process boundary.
`internal/*` documents MUST cite these IDs instead of restating the IPC
contract.
## REQ-IPC-001 — General framing
**Requirement:** All cross-process communication MUST use the
`cmd_request_t` framing (`src/defs.h`) over Unix domain sockets, with the
payload (if any) serialized as the protobuf-c message named in the comment
preceding each `cmd_request_t` value in `src/ipc.proto`.
**Strength:** MUST
**Status:** DERIVED
**Source:** src/defs.h:60-110; src/ipc.proto (per-message comments)
**Acceptance:** unit — `tests/` IPC round-trip tests pack/unpack each
message type and confirm `cmd_request_to_str()` covers every enum value.
**Links:**
### REQ-IPC-002 — Unknown command rejection
**Requirement:** A process receiving a `cmd_request_t` value it does not
recognize for its role MUST log the value and return an error without
acting on the payload.
**Strength:** MUST
**Status:** DERIVED
**Source:** src/sec-mod.c:468-470 (`default: ... return -1`)
**Acceptance:** negative, local — send an out-of-range `cmd_request_t` to
sec-mod's socket and confirm it logs `unknown type` and closes/errors
without crashing.
**Links:** REQ-IPC-001
---
## SEC_AUTH_INIT / SEC_AUTH_REP / SEC_AUTH_CONT (worker <-> sec-mod)
```
worker -> sec-mod: SEC_AUTH_INIT (sec_auth_init_msg)
sec-mod -> worker: SEC_AUTH_REP (sec_auth_reply_msg, new SID)
worker -> sec-mod: SEC_AUTH_CONT (sec_auth_cont_msg, SID + password)
sec-mod -> worker: SEC_AUTH_REP (OK or FAILED)
```
### REQ-IPC-010 — SEC_AUTH_INIT HMAC anti-replay
**Requirement:** sec-mod MUST reject a `sec_auth_init_msg` whose `hmac`
field is not exactly `HMAC_DIGEST_SIZE` bytes, or whose value does not equal
`HMAC(orig_remote_ip || our_ip || session_start_time)` computed with
`sec->hmac_key`.
**Strength:** MUST
**Status:** DERIVED
**Source:** src/sec-mod-auth.c:886-914
**Acceptance:** negative, local — send `SEC_AUTH_INIT` with a wrong-length
or mismatched `hmac` field and confirm sec-mod logs `hmac is the wrong
size` / `hmac presented by client doesn't match parameters provided` and
does not create a `client_entry_st`.
**Links:** REQ-IPC-011, REQ-WORKER-AUTH-* (worker side: HMAC is computed by
main at `WORKER_STARTUP` time, see REQ-IPC-040)
### REQ-IPC-011 — SEC_AUTH_INIT replay window
**Requirement:** sec-mod MUST reject a `sec_auth_init_msg` whose
`session_start_time` is older than `vhost->config->auth_timeout` seconds
relative to sec-mod's current time, even if the HMAC is valid.
**Strength:** MUST
**Status:** DERIVED
**Source:** src/sec-mod-auth.c:916-922
**Acceptance:** negative, local — replay a captured, HMAC-valid
`SEC_AUTH_INIT` after `auth-timeout` has elapsed; confirm sec-mod logs `hmac
presented by client expired - possible replay` and rejects.
**Links:** REQ-IPC-010
### REQ-IPC-012 — orig_remote_ip is required
**Requirement:** sec-mod MUST reject a `sec_auth_init_msg` with no
`orig_remote_ip` field, before computing the HMAC.
**Strength:** MUST
**Status:** DERIVED
**Source:** src/sec-mod-auth.c:895-898
**Acceptance:** negative, local — confirm sec-mod logs `missing remote IP in
auth init` and rejects a message that omits `orig_remote_ip`.
**Links:** REQ-IPC-010
### REQ-IPC-013 — auth_type determines auth module selection
**Requirement:** sec-mod MUST select the first enabled auth module on the
target vhost whose configured `type` bitmask contains all bits set in
`sec_auth_init_msg.auth_type`, and MUST reject the request (without creating
a session) if no such module exists.
**Strength:** MUST
**Status:** DERIVED
**Source:** src/sec-mod-auth.c:841-871 (`set_module`), 930-935
**Acceptance:** negative, local — send `SEC_AUTH_INIT` with `auth_type=0` or
a type not configured on the vhost; confirm sec-mod logs `no module found
for auth type` and returns failure.
**Links:** REQ-IPC-010
### REQ-IPC-014 — confidential username types are not echoed from SEC_AUTH_INIT
**Requirement:** When `sec_auth_init_msg.auth_type` has any bit set in
`CONFIDENTIAL_USER_NAME_AUTH_TYPES`, sec-mod MUST NOT copy
`sec_auth_init_msg.user_name` into the session's accounting username — the
real username is established only after authentication completes.
**Strength:** MUST NOT
**Status:** DERIVED
**Source:** src/sec-mod-auth.c:971-977
**Acceptance:** unit, local — for a cert-based auth type, send `SEC_AUTH_INIT`
with a `user_name` set and confirm `e->acct_info.username` remains empty (or
is set only after `auth_init`/`auth_pass` completes) rather than the
submitted value.
**Links:** REQ-SECMOD-AUTH-*
### REQ-IPC-015 — SEC_AUTH_CONT requires valid SID and matching state
**Requirement:** sec-mod MUST reject a `sec_auth_cont_msg` if `sid.len !=
SID_SIZE`, if no `client_entry_st` exists for that SID, or if the entry's
state is not `PS_AUTH_INIT` or `PS_AUTH_CONT`.
**Strength:** MUST
**Status:** DERIVED
**Source:** src/sec-mod-auth.c:784-805
**Acceptance:** negative, local — (a) send `SEC_AUTH_CONT` with a
wrong-length SID; (b) send it with a random 8-byte SID that was never issued
by `SEC_AUTH_REP`; (c) send a second `SEC_AUTH_CONT` after the session
reached `PS_AUTH_COMPLETED`. Each MUST be rejected.
**Links:** REQ-IPC-010
### REQ-IPC-016 — SEC_AUTH_CONT requires a password
**Requirement:** sec-mod MUST reject a `sec_auth_cont_msg` whose `password`
field is absent.
**Strength:** MUST
**Status:** DERIVED
**Source:** src/sec-mod-auth.c:810-816
**Acceptance:** negative, local — send `SEC_AUTH_CONT` without `password`
set; confirm rejection with `no password given in auth cont`.
**Links:** REQ-IPC-015
### REQ-IPC-017 — SID is assigned only by sec-mod
**Requirement:** A worker MUST NOT construct its own SID; the SID used in
`SEC_AUTH_CONT`, `AUTH_COOKIE_REQ`, `SECM_SESSION_OPEN/CLOSE`, and
`SEC_CLI_STATS` MUST be the `sid` value sec-mod returned in `SEC_AUTH_REP`
for that authentication attempt.
**Strength:** MUST NOT
**Status:** DERIVED
**Source:** doc/design.md#ipc-communication-for-sid-assignment;
src/sec-mod-auth.c:174-217 (sid assigned in `handle_sec_auth_res` /
`SEC_AUTH_REP` reply path)
**Acceptance:** [REVIEW: no automated negative test found — add one that
sends `AUTH_COOKIE_REQ`/`SEC_AUTH_CONT` with a SID the worker fabricated
rather than one received via `SEC_AUTH_REP`, and confirm
`find_client_entry()` rejects it (covered structurally by REQ-IPC-015 /
REQ-IPC-021, but no test isolates "worker-fabricated SID" specifically).]
**Links:** REQ-IPC-015, REQ-IPC-021
---
## AUTH_COOKIE_REQ / AUTH_COOKIE_REP (worker <-> main, main <-> sec-mod)
```
worker -> main: AUTH_COOKIE_REQ (auth_cookie_request_msg: cookie)
main -> sec-mod: SECM_SESSION_OPEN (secm_session_open_msg: sid)
sec-mod -> main: SECM_SESSION_REPLY (secm_session_reply_msg: config, OK/FAILED)
main -> worker: AUTH_COOKIE_REP (auth_cookie_reply_msg: OK + tun device, or FAILED)
```
### REQ-IPC-020 — AUTH_COOKIE_REQ only from PS_AUTH_INACTIVE
**Requirement:** main MUST reject an `AUTH_COOKIE_REQ` received from a
worker whose `proc->status != PS_AUTH_INACTIVE`.
**Strength:** MUST
**Status:** DERIVED
**Source:** src/main-worker-cmd.c:421-427
**Acceptance:** negative, local — after a worker has already completed
`AUTH_COOKIE_REQ` once (status advanced past `PS_AUTH_INACTIVE`), send a
second `AUTH_COOKIE_REQ` on the same connection and confirm main returns
`ERR_BAD_COMMAND`.
**Links:** REQ-IPC-021
### REQ-IPC-021 — SECM_SESSION_OPEN requires SID of a completed, unexpired session
**Requirement:** sec-mod MUST reply `FAILED` to `SECM_SESSION_OPEN` if
`sid.len != SID_SIZE`, if no `client_entry_st` exists for that SID, if the
entry's status is not `PS_AUTH_COMPLETED`, or if the entry is expired
(`IS_CLIENT_ENTRY_EXPIRED`).
**Strength:** MUST
**Status:** DERIVED
**Source:** src/sec-mod-auth.c:508-536
**Acceptance:** negative, local — (a) send `SECM_SESSION_OPEN` with a
wrong-length or unknown SID; (b) send it for a SID still in
`PS_AUTH_INIT`/`PS_AUTH_CONT`; (c) send it after the cookie's expiry time
has passed. Each MUST yield `secm_session_reply_msg.reply = FAILED` (via
`send_failed_session_open_reply`).
**Links:** REQ-IPC-015, REQ-IPC-022
### REQ-IPC-022 — SECM_SESSION_OPEN refreshes cookie expiry
**Requirement:** On a successful `SECM_SESSION_OPEN`, sec-mod MUST update
the client entry's `exptime` to `now + cookie_timeout + AUTH_SLACK_TIME` and
increment `e->in_use`.
**Strength:** MUST
**Status:** DERIVED
**Source:** src/sec-mod-auth.c:617-620
**Acceptance:** unit, local — open a session via `SECM_SESSION_OPEN`,
inspect `e->exptime` before/after, confirm it advances by
`cookie_timeout + AUTH_SLACK_TIME`. Cross-reference `doc/sample.config`
`cookie-timeout` documentation.
**Links:** REQ-IPC-021
### REQ-IPC-023 — AUTH_COOKIE_REP carries the TUN device only on OK
**Requirement:** main MUST send `auth_cookie_reply_msg.reply = OK` together
with the allocated TUN device fd (via `send_socket_msg_to_worker`) only when
`proc->tun_lease.name` is set; otherwise it MUST send `reply = FAILED` with
no fd, via `send_msg_to_worker` (no socket transfer).
**Strength:** MUST
**Status:** DERIVED
**Source:** src/main-auth.c:48-115
**Acceptance:** negative, local — force `SECM_SESSION_OPEN` to fail (e.g.
expired cookie) and confirm the worker receives `AUTH_COOKIE_REP` with
`reply = FAILED` and no fd is transferred (worker does not gain a TUN
device).
**Links:** REQ-IPC-021, REQ-MAIN-NET-* (TUN lease allocation)
### REQ-IPC-024 — Worker MUST NOT have a TUN device before AUTH_COOKIE_REP OK
**Requirement:** A worker process MUST NOT possess an open TUN device file
descriptor before it has received `AUTH_COOKIE_REP` with `reply = OK`.
**Strength:** MUST NOT
**Status:** DERIVED
**Source:** src/main-auth.c:53,103-106; doc/design.md#the-worker-processes
(TUN device "forwarded from main to worker after successful
authentication")
**Acceptance:** [REVIEW: this is a privilege-boundary invariant
(`AGENTS.md` "Architecture" table) but no test directly observes the
worker's fd table pre-/post-`AUTH_COOKIE_REP`. Add a negative test, e.g. via
seccomp audit or `/proc/<worker-pid>/fd` inspection in a CI harness, that
confirms the TUN fd does not exist before `AUTH_COOKIE_REP` OK.]
**Links:** REQ-IPC-023, REQ-SEC-* (privilege boundary)
---
## SECM_SESSION_CLOSE / SECM_CLI_STATS, SEC_CLI_STATS (session teardown)
```
worker -> sec-mod: SEC_CLI_STATS (cli_stats_msg, SID)
main -> sec-mod: SECM_SESSION_CLOSE (secm_session_close_msg, SID)
sec-mod -> main: SECM_CLI_STATS (cli_stats_msg)
```
### REQ-IPC-030 — SECM_SESSION_CLOSE requires a valid SID
**Requirement:** sec-mod MUST reject `SECM_SESSION_CLOSE` with
`ERR_BAD_COMMAND` if `sid.len != SID_SIZE`.
**Strength:** MUST
**Status:** DERIVED
**Source:** src/sec-mod-auth.c:632-637
**Acceptance:** negative, local — send `SECM_SESSION_CLOSE` with a
malformed `sid`; confirm `ERR_BAD_COMMAND`.
**Links:** REQ-IPC-015
### REQ-IPC-031 — SECM_SESSION_CLOSE for unknown or unauthenticated SID still replies CLI_STATS
**Requirement:** If `SECM_SESSION_CLOSE` references a SID with no
`client_entry_st`, or one whose status is `< PS_AUTH_COMPLETED`, sec-mod
MUST still reply with `CMD_SECM_CLI_STATS` (a zeroed `cli_stats_msg`) rather
than an error, so main's session-close path does not block.
**Strength:** MUST
**Status:** DERIVED
**Source:** src/sec-mod-auth.c:639-656
**Acceptance:** negative, local — send `SECM_SESSION_CLOSE` for a SID that
was never opened (or only reached `PS_AUTH_INIT`); confirm sec-mod replies
with `CMD_SECM_CLI_STATS` and main does not hang waiting for a reply.
**Links:** REQ-IPC-030
### REQ-IPC-032 — Stats counters are monotonic, taking the maximum
**Requirement:** On `SECM_SESSION_CLOSE`, sec-mod MUST update
`e->stats.{uptime,bytes_in,bytes_out}` only if the incoming value is greater
than the stored value (`>` comparison), never overwriting with a smaller
number.
**Strength:** MUST
**Status:** DERIVED
**Source:** src/sec-mod-auth.c:658-666
**Acceptance:** unit, local — send `SECM_SESSION_CLOSE` twice for the same
SID, second time with smaller `bytes_in`/`bytes_out`/`uptime`; confirm
`e->stats` values are unchanged after the second message.
**Links:** REQ-IPC-031
### REQ-IPC-033 — server_disconnected sets discon_reason
**Requirement:** When `secm_session_close_msg.server_disconnected` is true,
sec-mod MUST set `e->discon_reason = REASON_SERVER_DISCONNECT` and report it
in the `cli_stats_msg.discon_reason` reply field.
**Strength:** MUST
**Status:** DERIVED
**Source:** src/sec-mod-auth.c:668-676
**Acceptance:** unit, local — send `SECM_SESSION_CLOSE` with
`server_disconnected = true`; confirm `CMD_SECM_CLI_STATS` reply has
`discon_reason = REASON_SERVER_DISCONNECT`. Cross-check `occtl show events`
output, which surfaces `discon_reason`.
**Links:** REQ-IPC-031
---
## WORKER_STARTUP (main -> worker)
### REQ-IPC-040 — sec_auth_init_hmac is computed by main, not the worker
**Requirement:** The `sec_auth_init_hmac` field used by the worker in
`SEC_AUTH_INIT` MUST be computed by main (using `sec->hmac_key`, which the
worker does not have) and forwarded to the worker via
`worker_startup_msg.sec_auth_init_hmac`; the worker MUST treat this value as
opaque and copy it verbatim into `sec_auth_init_msg.hmac`.
**Strength:** MUST
**Status:** DERIVED
**Source:** src/main.c:1857-1858 (main computes and sends);
src/worker.c:289-307 (worker copies into `ws->sec_auth_init_hmac`,
bounds-checked against `sizeof(ws->sec_auth_init_hmac)`);
src/worker-auth.c:1762-1763, src/worker-svc.c:127-128 (worker forwards
verbatim in `SEC_AUTH_INIT`)
**Acceptance:** unit, local — confirm `worker_startup_msg.sec_auth_init_hmac`
has length `HMAC_DIGEST_SIZE`; confirm worker rejects/truncates a
`WORKER_STARTUP` message whose `sec_auth_init_hmac` exceeds
`sizeof(ws->sec_auth_init_hmac)` (src/worker.c:289-290).
**Links:** REQ-IPC-010, REQ-SEC-001 (worker has no access to `sec->hmac_key`)
### REQ-IPC-041 — WORKER_STARTUP transfers pre-opened fds, not raw keys
**Requirement:** `worker_startup_msg` MUST transfer only file descriptors
(`cmd_fd`, `conn_fd`, `snapshot_entries[].file_descriptor`) and
non-cryptographic identifiers (`secmod_addr`, IPs, group lists); it MUST NOT
contain private key material or HMAC secrets.
**Strength:** MUST NOT
**Status:** DERIVED
**Source:** src/ipc.proto (`worker_startup_msg` field list)
**Acceptance:** [REVIEW: this is a privilege-boundary assertion derivable
from the schema (no `bytes` field plausibly sized/named for key material),
but there is no automated check that a future field addition to
`worker_startup_msg` doesn't violate it. Consider a schema-diff check or
explicit comment in `ipc.proto` flagging this constraint for reviewers of
future `.proto` edits.]
**Links:** REQ-SEC-001
---
## SEC_SIGN / SEC_SIGN_DATA / SEC_SIGN_HASH / SEC_DECRYPT / SEC_GET_PK (worker <-> sec-mod, via main relay)
### REQ-IPC-050 — Private key operations are delegated, never performed by the worker
**Requirement:** All TLS private-key operations (signing, hash-signing,
decryption, and public-key retrieval) for a vhost's certificate key MUST be
performed by sec-mod in response to `CMD_SEC_SIGN`, `CMD_SEC_SIGN_DATA`,
`CMD_SEC_SIGN_HASH`, `CMD_SEC_DECRYPT`, and `CMD_SEC_GET_PK`; the worker's
GnuTLS `gnutls_privkey_t` callbacks (`key_cb_sign_data_func`,
`key_cb_sign_hash_func`, `key_cb_decrypt_func`) MUST only marshal requests
to sec-mod and return its response — they MUST NOT hold or derive key
material locally.
**Strength:** MUST NOT
**Status:** DERIVED
**Source:** src/tlslib.c:793-832 (`key_cb_*_func` -> `key_cb_common_func` ->
`CMD_SEC_*`); src/sec-mod.c:219-338 (sec-mod performs
`gnutls_privkey_sign_data`/`sign_hash`/`decrypt_data2` using
`vhost->key[i]`)
**Acceptance:** [SEC] negative, local — this is the core
worker/private-key privilege boundary from `AGENTS.md`. Verify (a) the
worker process never opens the vhost private key file (seccomp file-open
denial, or `strace`/audit showing no `open()` of the key path from the
worker PID), and (b) `sec_op_msg` / `sec_get_pk_msg` round trips correctly
for each of `CMD_SEC_SIGN`, `CMD_SEC_SIGN_DATA`, `CMD_SEC_SIGN_HASH`,
`CMD_SEC_DECRYPT`, `CMD_SEC_GET_PK`.
**Links:** REQ-SEC-001 (architecture invariant in AGENTS.md)
### REQ-IPC-051 — sec_op_msg.key_idx and vhost select the key
**Requirement:** sec-mod MUST select the private key for a `sec_op_msg` /
`sec_get_pk_msg` using the requesting worker's `vhost` field and
`key_idx`/`pk` fields, and MUST NOT use a key belonging to a different vhost
than the one the requesting worker was started for.
**Strength:** MUST NOT
**Status:** REVIEW
**Source:** src/sec-mod.c:219-338 (`vhost->key[i]` indexed by `key_idx`);
src/sec-mod.c:1160-1201 (accept loop — `check_upeer_id()` validates only the
peer process's uid/gid/pid, recording no vhost binding for `cfd`);
src/vhost.h:137-152 (`find_vhost()` — case-insensitive string match over
*all* configured vhosts on the message-supplied `name`, falling back to
`default_vhost()`, never failing)
**Acceptance:** [REVIEW: confirmed interpretation (b) — sec-mod's
connection-accept path (`accept()` + `check_upeer_id()` in
`src/sec-mod.c`'s main loop) establishes no per-connection vhost binding; it
checks only that the peer is a legitimate ocserv worker process (uid/gid/pid),
not which vhost it was started for. `process_worker_packet()` then resolves
the vhost for `CMD_SEC_GET_PK`/`CMD_SEC_SIGN_DATA`/`CMD_SEC_SIGN_HASH`/
`CMD_SEC_SIGN`/`CMD_SEC_DECRYPT` purely via `find_vhost(sec->vconfig,
op->vhost)` — a string supplied in the message itself — and then indexes
`vhost->key[key_idx]` (bounds-checked against that *resolved* vhost's
`key_size`, but not against the requesting worker's own vhost). A worker
that supplies a different, validly-configured vhost's name in
`op->vhost`/`pkm->vhost` causes sec-mod to perform the signing/decryption
operation with that other vhost's private key and return the result to the
requester. This appears to violate the stated MUST NOT and needs maintainer
review: either (i) sec-mod must record the vhost a worker was started for
(e.g. at `WORKER_STARTUP`/first request) and reject `sec_op_msg`/
`sec_get_pk_msg` whose `vhost` field does not match it, or (ii) the
requirement's premise that vhosts are mutually-distrusting boundaries needs
revising if cross-vhost key use is intentional in this deployment model.]
**Links:** REQ-IPC-050
---
## RESUME_STORE_REQ / RESUME_FETCH_REQ / RESUME_DELETE_REQ / RESUME_FETCH_REP (worker <-> sec-mod)
### REQ-IPC-060 — Resumption data is zeroized after use
**Requirement:** sec-mod MUST zero the raw IPC buffer and the unpacked
`session_resume_store_req_msg.session_data` with `safe_memset()` after
processing `RESUME_STORE_REQ`, regardless of success or failure.
**Strength:** MUST
**Status:** DERIVED
**Source:** src/sec-mod.c:393-397
**Acceptance:** unit, local — after `handle_resume_store_req()` returns,
inspect the buffer passed to `recv_msg` and `smsg->session_data.data`;
confirm both are zero-filled. This protects TLS session resumption secrets
from being left in process memory longer than necessary.
**Links:**
### REQ-IPC-061 — RESUME_FETCH_REQ always replies, OK or FAILED
**Requirement:** sec-mod MUST always send a `RESUME_FETCH_REP`
(`session_resume_reply_msg`) in response to `RESUME_FETCH_REQ`, with
`reply = OK` and `session_data` populated on success, or `reply = FAILED`
(no `session_data`) if `handle_resume_fetch_req()` returns an error.
**Strength:** MUST
**Status:** DERIVED
**Source:** src/sec-mod.c:428-463
**Acceptance:** negative, local — request resumption for a `session_id`
that was never stored (or was deleted via `RESUME_DELETE_REQ`); confirm the
worker receives `RESUME_FETCH_REP` with `reply = FAILED` rather than no
reply or a connection error.
**Links:**
---
## SECM_TERMINATE_USER_SESSIONS / SECM_TERMINATE_SESSION (occtl -> main -> sec-mod)
```
occtl -> main: CTL_CMD_LIST_COOKIES (resolve short SID -> safe_id)
main -> occtl: cookie list
occtl -> main: CTL_CMD_TERMINATE_USER / ID / SESSION
main: disconnect worker process(es)
main -> sec-mod: SECM_TERMINATE_USER_SESSIONS (username) | SECM_TERMINATE_SESSION (safe_id)
sec-mod -> main: SECM_TERMINATE_SESSION_REPLY
main -> occtl: CTL_CMD_TERMINATE_*_REP
```
### REQ-IPC-070 — Session ID prefix ambiguity is rejected, not guessed
**Requirement:** When `occtl terminate session` is given a session ID
prefix that matches more than one entry in the cookie list returned by
`CTL_CMD_LIST_COOKIES`, main/occtl MUST refuse the operation and list the
matching sessions rather than acting on the first or any arbitrary match.
**Strength:** MUST
**Status:** DERIVED
**Source:** doc/design.md#ipc-communication-for-session-termination
("If the prefix is ambiguous (matches multiple sessions), occtl refuses the
operation and lists the matching sessions.")
**Acceptance:** negative, local — create two sessions whose `safe_id`
values share a common prefix; run `occtl terminate session <prefix>` and
confirm it refuses with a list of matches, and that neither session is
terminated. Scripts MUST instead use the full session ID from `occtl --json
show sessions valid` (`Full session` field).
**Links:** REQ-IPC-071
### REQ-IPC-071 — SECM_TERMINATE_* always elicits SECM_TERMINATE_SESSION_REPLY
**Requirement:** sec-mod MUST reply to both `SECM_TERMINATE_USER_SESSIONS`
and `SECM_TERMINATE_SESSION` with `secm_terminate_session_reply_msg`, with
`result = true` only if at least one matching session's cookie was
invalidated.
**Strength:** MUST
**Status:** DERIVED
**Source:** src/ipc.proto (`secm_terminate_session_reply_msg`); src/sec-mod.c
(CMD_SECM_TERMINATE_* handlers near line 549+)
**Acceptance:** negative, local — issue `SECM_TERMINATE_USER_SESSIONS` for a
username with no active sessions; confirm `result = false` is returned (not
an error/timeout), and confirm main relays this via `CTL_CMD_TERMINATE_*_REP`
to occtl.
**Links:** REQ-IPC-070
### REQ-IPC-072 — Termination invalidates the cookie, not just the live connection
**Requirement:** A successful `SECM_TERMINATE_USER_SESSIONS` /
`SECM_TERMINATE_SESSION` MUST invalidate the session's cookie in sec-mod
(so a subsequent `AUTH_COOKIE_REQ` / `SECM_SESSION_OPEN` with that SID fails
per REQ-IPC-021), in addition to main disconnecting any live worker for that
session.
**Strength:** MUST
**Status:** DERIVED
**Source:** doc/design.md#ipc-communication-for-session-termination ("main
process disconnects the active worker (if any) and forwards the request to
sec-mod to invalidate the session cookie, preventing automatic
reconnection")
**Acceptance:** negative, local — terminate a session via `occtl`, then
attempt to reconnect a client using the previously-issued cookie; confirm
`AUTH_COOKIE_REQ` is rejected (`AUTH_COOKIE_REP` `reply = FAILED`) rather
than resuming the terminated session.
**Links:** REQ-IPC-021, REQ-IPC-071
---
## WORKER_BAN_IP / SECM_BAN_IP (worker -> main, sec-mod -> main)
### REQ-IPC-080 — ban_ip_reply_msg.sid is only meaningful from sec-mod
**Requirement:** The `sid` field of `ban_ip_reply_msg` (used by main to tell
sec-mod whether to disconnect a user, per the `ban_ip_msg` comment "sec-mod
sends it") MUST be populated only on the sec-mod -> main direction; main
MUST NOT depend on a worker-supplied `ban_ip_msg.sid` to disconnect a
different session than the one reporting the ban.
**Strength:** MUST NOT
**Status:** DERIVED
**Source:** src/ipc.proto (`ban_ip_msg.sid` comment "sec-mod sends it";
`ban_ip_reply_msg.sid` comment "sec-mod needs it");
src/worker-vpn.c:499-509 (`CMD_BAN_IP` send, `has_sid` never set);
src/main-worker-cmd.c:282-322 (`CMD_BAN_IP` handler, never reads `tmsg->sid`,
replies with `BAN_IP_REPLY_MSG__INIT` so `has_sid == 0`);
src/sec-mod-auth.c:103-105 (`CMD_SECM_BAN_IP` send, `sid = e->sid`,
`has_sid = 1`); src/main-sec-mod-cmd.c:143-181 (`CMD_SECM_BAN_IP` handler
echoes `tmsg->sid` into the reply); src/sec-mod.c:506-517
(`CMD_SECM_BAN_IP_REPLY` dispatches to `handle_sec_auth_ban_ip_reply`)
**Acceptance:** positive, local — confirm `BanIpMsg msg = BAN_IP_MSG__INIT`
in `src/worker-vpn.c` is never followed by an assignment to `msg.sid` or
`msg.has_sid`, so every `CMD_BAN_IP` message from a worker has `has_sid ==
0`. Confirm the `CMD_BAN_IP` case in `src/main-worker-cmd.c` has no
reference to `tmsg->sid` and constructs its reply from
`BAN_IP_REPLY_MSG__INIT` without setting `reply.sid`/`reply.has_sid`.
Negative — confirm `handle_sec_auth_ban_ip_reply()` (the only consumer of
`ban_ip_reply_msg.sid` for session lookup, via `find_client_entry`) is
reachable only from the `CMD_SECM_BAN_IP_REPLY` case in `src/sec-mod.c`,
never from `CMD_BAN_IP_REPLY` (the worker-facing reply, handled in
`src/worker-vpn.c` without inspecting `reply->sid`). Together these confirm
that even a compromised worker sending a crafted `ban_ip_msg` with
`has_sid=1` and an arbitrary `sid` would have that field silently ignored by
main — `sid` is meaningful only on the sec-mod <-> main `CMD_SECM_BAN_IP` /
`CMD_SECM_BAN_IP_REPLY` round trip.
**Links:** REQ-SEC-*
---
## Completeness notes
- **Coverage**: every `cmd_request_t` value in `src/defs.h` with a
corresponding message in `src/ipc.proto` has at least one requirement
above, except the latency-stats path (`CMD_LATENCY_STATS_DELTA`,
`latency_stats_delta`) and `CMD_SECM_STATS`/`CMD_SECM_RELOAD`, which are
periodic/administrative and have no security-relevant validation beyond
REQ-IPC-001/002. `[UNDOCUMENTED: if these messages gain validation logic
in the future, add requirements here.]`
- **ctl.proto**: only the termination flow (REQ-IPC-070..072) is covered in
detail, since it is the security-relevant occtl path (it can end a user's
VPN session). Read-only `occtl` queries (`status_rep`, `user_list_rep`,
`ban_list_rep`, etc.) are reporting-only and have no MUST/MUST NOT
contract beyond "reflect server state accurately" —
`[UNDOCUMENTED: candidate for a future REQ-IPC-LOG-* style requirement if
occtl output is found to diverge from server state]`.
- **Field coverage gap**: `group_cfg_st` (the per-user/group supplemental
config forwarded in `auth_cookie_reply_msg.config` and
`secm_session_reply_msg.config`) is not covered field-by-field here; its
contract is primarily a `CFG` concern and is covered in
`internal/sec-mod.md` (population) and `internal/worker.md` (consumption).
+468
View File
@@ -0,0 +1,468 @@
---
title: main process requirements
generator: requirements-from-implementation
process: main
id-prefix: REQ-MAIN
sources:
- src/main.c
- src/main-ban.c
- src/main-auth.c
- src/main-worker-cmd.c
- src/main-sec-mod-cmd.c
- src/main-proc.c
- src/ip-lease.c
- src/vpn.h
- doc/design.md#the-main-process
- doc/requirements/internal/ipc.md
---
# main Process Requirements
main runs as root and owns the TCP/UDP listeners, TUN device allocation, IP
leasing, the per-connection ban list, and worker process lifecycle. It
delegates authentication and credential handling to sec-mod entirely (see
`internal/ipc.md` and `internal/sec-mod.md`). This document covers main's
own state: listener setup, the fork/worker-spawn sequence, IP lease
allocation, and the ban-score database (`src/main-ban.c`).
## INIT
### REQ-MAIN-INIT-001 — Worker fork clears parent listener and sec-mod fds before exec-equivalent setup
**Requirement:** Immediately after `fork()` for a new worker, the child
MUST: reset signal mask to `sig_default_set`; close the unused end of the
command socketpair (`cmd_fd[0]`); call `clear_lists(s)` to release the
parent's listener/proc lists; close `s->top_fd` if open; and close every
`sec_mod_instances[i].sec_mod_fd` / `sec_mod_fd_sync` — so the worker
inherits no listening sockets and no direct sec-mod control channel.
**Strength:** MUST
**Status:** DERIVED
**Source:** src/main.c:1197-1211
**Acceptance:** [SEC] negative, local — after a worker is spawned, inspect
its open file descriptors (`/proc/<pid>/fd`); confirm no listener socket fds
and no `sec_mod_fd`/`sec_mod_fd_sync` fds are present, only `cmd_fd[1]` and
the accepted client connection.
**Links:** REQ-IPC-040, REQ-SEC-001
### REQ-MAIN-INIT-002 — sec_auth_init_hmac is computed by main and the HMAC key is zeroized in the child
**Requirement:** The worker child MUST receive `ws->sec_auth_init_hmac`
computed by main from `s->hmac_key` plus the worker's
`remote_ip_str`/`our_ip_str`/`session_start_time` (REQ-IPC-040), and main
MUST `safe_memset()` its copy of `s->hmac_key` to zero in the child
immediately after computing this HMAC, so the worker process never holds
the key used to validate `SEC_AUTH_INIT` HMACs.
**Strength:** MUST
**Status:** DERIVED
**Source:** src/main.c:1254-1269
**Acceptance:** [SEC] negative, local — after worker spawn, confirm
`ws->sec_auth_init_hmac` is set (`HMAC_DIGEST_SIZE` bytes, non-zero) but
`s->hmac_key` is all-zero in the worker's memory image (e.g. via core dump
inspection in a test harness, or a debug build assertion). A worker that
recovered `hmac_key` could forge `SEC_AUTH_INIT` HMACs for sec-mod
(REQ-IPC-010).
**Links:** REQ-IPC-010, REQ-IPC-040, REQ-MAIN-SEC-001
### REQ-MAIN-INIT-003 — sec-mod instance for a session is selected by client IP hash, fixed at fork time
**Requirement:** The worker's `sec_mod_instance_index` (and thus which
sec-mod instance will own its `client_entry_st`/SID) MUST be computed once,
at fork time, as `hash(remote_addr) % sec_mod_instance_count`, and copied
into `ws->secmod_addr`/`secmod_addr_len`. This selection MUST NOT change for
the lifetime of the worker, because "each cookie is valid for its IP address
and when resuming it must reach the same sec-mod process that contains the
corresponding session information under the SID."
**Strength:** MUST
**Status:** DERIVED
**Source:** src/main.c:1217-1234 (comment explains rationale)
**Acceptance:** unit, local — for `sec_mod_instance_count > 1`, confirm two
connections from the same client IP always select the same
`sec_mod_instance_index`, and that a resumed cookie (REQ-IPC-021) is routed
to the same instance that issued it (`AUTH_COOKIE_REQ` ->
`secmod_addr`/`secmod_addr_len` from `worker_startup_msg`, not
recomputed).
**Links:** REQ-IPC-017, REQ-IPC-021
### REQ-MAIN-INIT-004 — Connection limit and TCP-wrapper/ban checks precede fork
**Requirement:** main MUST reject a new connection — closing the accepted
fd without forking a worker — if (a) `max_clients > 0` and
`s->stats.active_clients >= max_clients`, (b) `check_tcp_wrapper(fd) < 0`
(`/etc/hosts.{allow,deny}`), or (c) for non-Unix, non-proxy-protocol
listeners, `check_if_banned()` is true for `ws->remote_addr`. These checks
MUST run before `socketpair()`/`fork()`.
**Strength:** MUST
**Status:** DERIVED
**Source:** src/main.c:1157-1186
**Acceptance:** negative, local — (a) set `max-clients` to current active
count and attempt a new connection, confirm it is closed and no worker
process appears; (b) configure `hosts.deny` to block the test client IP,
confirm rejection; (c) drive `check_if_banned` true (REQ-MAIN-SEC-002),
confirm rejection. In each case confirm no new PID is added to
`s->proc_list`.
**Links:** REQ-MAIN-SEC-002, REQ-MAIN-SEC-003
### REQ-MAIN-INIT-005 — Proxy-protocol listeners skip the connect-time ban check
**Requirement:** When `listen_proxy_proto` is enabled, main MUST NOT call
`check_if_banned()` using the immediate peer address at accept time (that
address is the load balancer's, not the client's) — ban enforcement for
proxy-protocol connections happens later once the real client address is
known from the PROXY protocol header.
**Strength:** MUST NOT
**Status:** DERIVED
**Source:** src/main.c:1173-1186 (`if (ws->conn_type != SOCK_TYPE_UNIX &&
!GETRCONFIG(s)->listen_proxy_proto)`)
**Acceptance:** [SEC] Confirmed — the post-PROXY-header ban check exists at
src/main-worker-cmd.c:387-405, in the `CMD_SESSION_INFO` handler. Sequencing:
the worker calls `parse_proxy_proto_header()` (src/worker-vpn.c:895) to
populate `ws->remote_addr` with the real client address *before* the TLS
handshake, then calls `session_info_send()` (src/worker-vpn.c:968, right
after the handshake completes) which, when `listen_proxy_proto` is set,
includes `remote_addr`/`our_addr` in `SessionInfoMsg` (src/worker-vpn.c:1146-1153).
Main's `CMD_SESSION_INFO` handler, when `GETRCONFIG(s)->listen_proxy_proto`
and `tmsg->has_remote_addr`, updates `proc->remote_addr` via
`proc_table_update_ip()` and calls `check_if_banned(s, &proc->remote_addr,
proc->remote_addr_len)` (main-worker-cmd.c:391-400) — which itself adds
`ban_points_connect` per REQ-MAIN-SEC-005 — and if the result is non-zero,
calls `kill_proc(proc)` (`SIGTERM`, main.h:189-193) to tear down the worker.
Ban enforcement is therefore **deferred, not bypassed**, for proxy-protocol
connections: a banned client's connection is terminated after the TLS
handshake rather than at accept time. Positive test: configure
`listen-proxy-proto`, ban an IP, send a PROXY-protocol-prefixed connection
from that IP; confirm the worker completes the TLS handshake but is then
`SIGTERM`'d (no CONNECT response). `[NOTE: unlike the non-proxy path (banned
before accept-time work begins), a banned client behind a proxy still
consumes one TLS handshake's worth of work before being killed — a minor
resource-amplification difference, not a bypass. Not flagged as
`[SEC-RISK]`; informational only.]`
**Links:** REQ-MAIN-SEC-002, REQ-MAIN-SEC-005, doc/design.md#load-balancer-integration
---
## NET — IP lease allocation
### REQ-MAIN-NET-001 — Leased IPs must not collide with existing leases, the TUN address, network, or broadcast address
**Requirement:** `get_ipv4_lease()` MUST reject a candidate address if (a)
an identical `/32` lease already exists (`ip_lease_exists`), (b) the
candidate equals the network address, or (c) the candidate equals the
computed broadcast address (`network | ~mask`). `get_ipv6_lease()` MUST
additionally reject a candidate `/prefix` subnet if it equals the TUN
device's own subnet (`ip_cmp(subnet, tun) == 0`) or if that subnet is
already leased.
**Strength:** MUST
**Status:** DERIVED
**Source:** src/ip-lease.c:150-184 (`is_ipv6_ok`, `is_ipv4_ok`)
**Acceptance:** unit, local — exhaust the configured IPv4 pool to 2 free
addresses: the network and broadcast addresses of the subnet; confirm
`get_ipv4_lease` does not return either and instead fails with no lease
available after `MAX_IP_TRIES`. For IPv6, configure a lease subnet equal to
the TUN device's subnet and confirm `get_ipv6_lease` rejects it.
**Links:**
### REQ-MAIN-NET-002 — IP lease allocation gives up after MAX_IP_TRIES random attempts
**Requirement:** `get_ipv4_lease()`/`get_ipv6_lease()` MUST attempt at most
`MAX_IP_TRIES` (16) randomly-generated candidate addresses before reporting
allocation failure; it MUST NOT loop indefinitely searching for a free
address in an exhausted pool.
**Strength:** MUST
**Status:** DERIVED
**Source:** src/ip-lease.c:186-193 (`#define MAX_IP_TRIES 16`,
`max_loops = MAX_IP_TRIES`)
**Acceptance:** unit, local — configure an address pool with 0 free
addresses; confirm `get_ipv4_lease` returns an error within `MAX_IP_TRIES`
iterations (bounded time), not a hang. With `predictable-ips` enabled,
`proc->ipv4_seed` is set once at session creation to
`hash_any(username, ...)` (src/sec-mod-auth.c:571-573) and consumed only on
the *first* loop iteration (`max_loops == MAX_IP_TRIES`,
src/ip-lease.c:300-301). If that single deterministic candidate is rejected
by `is_ipv4_ok()` (already leased, or equal to the network/broadcast
address), the function does **not** retry other deterministic addresses
derived from the seed — it falls through to the same candidates used in the
non-predictable case: up to 5 further attempts via `gnutls_rnd()`
(src/ip-lease.c:303-313, true random) followed by up to 10 attempts via
`ip_from_seed()` chained off the last random value (src/ip-lease.c:314-319),
all under the same `max_loops`/`MAX_IP_TRIES` counter. Fallback behavior: a
colliding predictable seed silently degrades to random IP assignment for
that session, matching the "IP stays the same for the same user when
possible" wording in `doc/sample.config` (predictable-ips); after all 16
attempts fail, `ERR_NO_IP` is returned regardless of `predictable_ips`.
Positive test: with `predictable-ips = true` and the hashed candidate for a
test username already leased to another session, confirm the new session
receives a different (non-deterministic) IP within `MAX_IP_TRIES`
iterations rather than hanging or erroring.
**Links:** REQ-SECMOD-SESSION (predictable_ips / ipv4_seed, see
src/sec-mod-auth.c:570-579)
### REQ-MAIN-NET-003 — Reconnecting client (steal) transfers IP leases without re-fetching from the pool
**Requirement:** `steal_ip_leases(proc, thief)` (used when a client
reconnects and supersedes an existing session for the same SID/user) MUST
`talloc_move` the existing `ipv4`/`ipv6` lease structures from `proc` to
`thief`, MUST call `reset_tun(proc)` on the old process's TUN device (since
its fd is only valid in the old worker), and MUST leave `proc` with its own
*copy* of the lease addressing info (for disconnect scripts/accounting) —
not a shared pointer — by allocating a fresh `ip_lease_st` and `memcpy`-ing
`rip`/`lip`/`sig` fields.
**Strength:** MUST
**Status:** DERIVED
**Source:** src/ip-lease.c:105-148
**Acceptance:** unit, local — simulate a reconnect (second `AUTH_COOKIE_REQ`
for a SID with an existing `proc_st`); confirm the new worker's `proc_st`
gets the same IPv4/IPv6 addresses as the old one, the old worker's TUN
device is reset, and disconnect accounting for the *old* session still
reports the correct (copied) IP — i.e., freeing `thief->ipv4` does not
corrupt `proc->ipv4`.
**Links:**
---
## SEC — ban list
### REQ-MAIN-SEC-001 — hmac_key is generated once at startup, never traverses worker IPC, and is zeroed in worker children
**Requirement:** `s->hmac_key` MUST be generated exactly once, at main
startup, via `hmac_init_key()` (src/common/hmac.c:34-37 —
`gnutls_rnd(GNUTLS_RND_RANDOM, ...)`), called from src/main.c:1520 — before
`run_sec_mod()` forks any sec-mod instance (src/main.c:1658) and long before
any worker is forked per connection (src/main.c:1197). `s->hmac_key` MUST
NOT appear in any `*.proto` message exchanged over a worker's
`cmd_fd`/`cmd_fd_sync` sockets (confirmed by `internal/ipc.md` REQ-IPC-041:
no key material in `worker_startup_msg` or any other IPC message). The only
processes that ever hold a copy of `hmac_key` are main itself and each
sec-mod instance — sec-mod receives it as a direct function argument across
`fork()` (src/main-sec-mod-cmd.c:908-909 -> `sec_mod_server()` ->
`memcpy` into `sec->hmac_key` at src/sec-mod.c:1029), which it needs to
validate `SEC_AUTH_INIT` HMACs via `generate_hmac()`
(src/sec-mod-auth.c:906). For each worker, immediately after main computes
that worker's `ws->sec_auth_init_hmac = HMAC(s->hmac_key, remote_ip_str ||
our_ip_str || session_start_time)` (src/main.c:1262-1266), main MUST
`safe_memset()` its own `s->hmac_key` to zero **in the worker child
process** (src/main.c:1268-1269), so the unprivileged worker never observes
the key itself — only the single derived per-session HMAC.
**Strength:** MUST
**Status:** DERIVED
**Source:** src/common/hmac.c:34-37; src/main.c:1520, 1197-1269, 1658;
src/main-sec-mod-cmd.c:908-909; src/sec-mod.c:1029; src/sec-mod-auth.c:906;
REQ-IPC-041
**Acceptance:** [SEC] (a) unit, local — confirm `hmac_init_key()` is called
exactly once per main process lifetime, using `GNUTLS_RND_RANDOM`, before
`run_sec_mod()`; (b) static, local — grep-confirm no `*.proto` message field
carries `hmac_key` or raw key bytes (REQ-IPC-041); (c) negative, local —
after a worker is spawned, inspect its memory image (debug build / core
dump) and confirm `s->hmac_key` is all-zero while `ws->sec_auth_init_hmac`
is a non-zero `HMAC_DIGEST_SIZE`-byte digest (REQ-MAIN-INIT-002); (d) unit,
local — confirm each sec-mod instance's `sec->hmac_key` equals main's
original (pre-zeroing) `hmac_key`, since sec-mod uses it to validate
`SEC_AUTH_INIT` HMACs.
**Links:** REQ-MAIN-INIT-002, REQ-IPC-040, REQ-IPC-041
### REQ-MAIN-SEC-002 — Ban score is additive, saturating, and IP-prefix-aware for IPv6
**Requirement:** `add_ip_to_ban_list()` MUST: treat an IPv6 address as its
`/64` prefix (zero the low 8 bytes via `massage_ipv6_address`) for banning
purposes; add `score` to the existing entry's score using saturating
arithmetic (`(e->score + score) > e->score ? ... : e->score` — i.e. never
wrap around on overflow); and MUST NOT extend `e->expires` on repeated
violations once the entry is already banned (`e->score >= max_ban_score`),
"or the user will never be unbanned if he periodically polls the server."
**Strength:** MUST
**Status:** DERIVED
**Source:** src/main-ban.c:118-224
**Acceptance:** unit, local — (a) two IPv6 addresses differing only in the
low 64 bits MUST share one ban entry; (b) drive `e->score` to
`UINT_MAX - 1` then add more points, confirm `e->score` does not wrap to a
small value; (c) once banned, send further violations and confirm
`e->expires` does not move further into the future.
**Links:** REQ-SECMOD-SEC-003
### REQ-MAIN-SEC-003 — Ban score reset rules: time-based reset only while not currently banned, or after a completed ban expires
**Requirement:** `add_ip_to_ban_list()` MUST reset `e->score = 0` and
`e->last_reset = now` if EITHER (a) the entry's previous ban has expired
(`now > e->expires`) while it was still marked banned (`IS_BANNED`), OR (b)
`ban_reset_time` has elapsed since `last_reset` AND the entry is currently
*not* banned. It MUST NOT reset the score for an entry that is currently
banned and not yet expired, even if `ban_reset_time` has elapsed — an active
ban cannot be prematurely lifted by the reset-time check.
**Strength:** MUST
**Status:** DERIVED
**Source:** src/main-ban.c:164-176 (comment: "to avoid prematurely lifting
an active ban")
**Acceptance:** unit, local — (1) accumulate score to reach `max_ban_score`
(now banned, `expires = now + ban_time`); (2) advance time past
`ban_reset_time` but before `expires`; send another violation; confirm
`e->score` is NOT reset (still >= `max_ban_score`, ban continues). (3)
advance time past `expires`; send another violation; confirm `e->score` IS
reset to 0 (+ the new violation's points).
**Links:** REQ-MAIN-SEC-002
### REQ-MAIN-SEC-004 — Local interface addresses are exempt from ban checks
**Requirement:** `check_if_banned()` MUST return 0 (not banned) without
consulting `s->ban_db` for any address that matches a local interface
address/netmask in `s->if_addresses` (`if_address_test_local`).
**Strength:** MUST
**Status:** DERIVED
**Source:** src/main-ban.c:287-306
**Acceptance:** unit, local — add a local interface's address to the ban
list directly (e.g. via repeated `add_str_ip_to_ban_list`); confirm
`check_if_banned()` for that address still returns 0. This prevents a
misconfiguration (or an attacker spoofing a server-local source address)
from causing the server to ban itself / a trusted load-balancer IP.
**Links:** REQ-MAIN-INIT-005
### REQ-MAIN-SEC-005 — check_if_banned itself contributes ban_points_connect
**Requirement:** Every call to `check_if_banned()` for a non-exempt,
non-malformed address MUST itself call `add_ip_to_ban_list(...,
ban_points_connect)` — i.e., merely attempting a connection contributes
points toward a future ban, independent of whether this particular
connection is accepted or rejected.
**Strength:** MUST
**Status:** DERIVED
**Source:** src/main-ban.c:322-325
**Acceptance:** unit, local — make `ban_points_connect` repeated connection
attempts (each individually allowed); confirm the `(connect_count *
ban_points_connect) >= max_ban_score` threshold results in the next
connection being rejected by `check_if_banned`, even though each prior
connection was itself accepted.
**Links:** REQ-MAIN-SEC-002, REQ-IPC-080
### REQ-MAIN-SEC-006 — Unban clears score and expiry but does not delete the entry
**Requirement:** `remove_ip_from_ban_list()` MUST set `e->score = 0` and
`e->expires = 0` for a matching entry (after applying the same `/64`
IPv6-prefix massaging as REQ-MAIN-SEC-002), and return non-zero, but MUST
NOT remove the entry from `s->ban_db` — the entry remains for
`last_reset`/future scoring bookkeeping. Returns 0 if no entry matches or
`size` is not 4 or 16.
**Strength:** MUST
**Status:** DERIVED
**Source:** src/main-ban.c:253-285
**Acceptance:** unit, local — `occtl unban <ip>` on a banned IP; confirm
`check_if_banned` now returns 0 for that IP, but the entry still exists in
`s->ban_db` (e.g. confirmed via a subsequent `occtl show ban points` if
such exists, or by re-violating and confirming `last_reset` carries over
rather than being treated as a brand-new entry).
**Links:** REQ-MAIN-SEC-002, REQ-MAIN-SEC-003
### REQ-MAIN-SEC-007 — cleanup_banned_entries removes only fully-expired-and-stale entries
**Requirement:** `cleanup_banned_entries()` MUST delete a ban entry only if
BOTH `now >= e->expires` AND `now > e->last_reset + ban_reset_time` — an
entry whose ban has expired but whose reset window has not yet elapsed MUST
be retained (so its score history is not lost prematurely).
**Strength:** MUST
**Status:** DERIVED
**Source:** src/main-ban.c:344-363
**Acceptance:** unit, local — create an entry with `expires` in the past but
`last_reset + ban_reset_time` in the future; run
`cleanup_banned_entries()`; confirm the entry is retained. Advance time past
`last_reset + ban_reset_time`; confirm it is now removed.
**Links:** REQ-MAIN-SEC-003
---
## TEARDOWN
### REQ-MAIN-TEARDOWN-001 — Workers exceeding auth_timeout without completing authentication are killed
**Requirement:** `kill_children_auth_timeout()` MUST terminate
(`remove_proc(..., RPROC_KILL)`) any `proc_st` with `status <
PS_AUTH_COMPLETED` whose `conn_time` is older than `now - auth_timeout`,
during periodic maintenance.
**Strength:** MUST
**Status:** DERIVED
**Source:** src/main.c:982-998
**Acceptance:** negative, local — start a connection, stall before
completing `SEC_AUTH_CONT`/`AUTH_COOKIE_REQ` past `auth-timeout`; confirm
the worker process is killed by the next maintenance tick. Cross-reference
REQ-IPC-011 (sec-mod's independent `auth_timeout` replay-window check —
both checks use the same configuration value but enforce it in different
processes).
**Links:** REQ-IPC-011
### REQ-MAIN-TEARDOWN-002 — SIGTERM with server-drain-ms stops listeners before terminating sessions
**Requirement:** On `SIGTERM`, if `server_drain_ms > 0`, main MUST first
stop and close all listening sockets (no new connections accepted) and
start a `server_drain_ms` timer, deferring `terminate_server()` (which kills
existing worker sessions) until that timer fires. If `server_drain_ms == 0`,
`terminate_server()` MUST run immediately on `SIGTERM`.
**Strength:** MUST
**Status:** DERIVED
**Source:** src/main.c:1037-1068
**Acceptance:** unit, local — with `server-drain-ms > 0`, send `SIGTERM`;
confirm new connection attempts fail (listener closed) while existing
sessions continue for up to `server_drain_ms`, then are terminated. With
`server-drain-ms = 0`, confirm immediate termination. Cross-reference
`doc/sample.config` `server-drain-ms`.
**Links:** REQ-MAIN-TEARDOWN-003
### REQ-MAIN-TEARDOWN-003 — terminate_server force-kills remaining workers after a 5s grace period
**Requirement:** `terminate_server()` MUST send termination signals to all
children via `kill_children()`, then wait up to 5000ms (polling
`waitpid(-1, NULL, WNOHANG)`) for them to exit; if children remain after
5000ms, it MUST `kill(0, SIGKILL)` (signal the entire process group) rather
than waiting indefinitely.
**Strength:** MUST
**Status:** DERIVED
**Source:** src/main.c:1000-1028
**Acceptance:** unit, local — spawn a worker that ignores `SIGTERM`
(test-only signal handler); trigger server termination; confirm the worker
is force-killed via `SIGKILL` within ~5 seconds and `ev_break` is reached
(main exits).
**Links:** REQ-MAIN-TEARDOWN-002
---
## CFG — reload
### REQ-MAIN-CFG-001 — SIGHUP reloads sec-mod before main's own config
**Requirement:** On `SIGHUP`, main MUST, for each sec-mod instance: send
`SIGHUP` to the sec-mod process AND send `CMD_SECM_RELOAD` (via
`secmod_reload()`) — and only after all sec-mod instances have been signaled
does main call `reload_cfg_file()` for its own configuration. If
`secmod_reload()` fails for any instance, main MUST trigger a full
`SIGTERM` (`ev_feed_signal_event(loop, SIGTERM)`) rather than continuing
with an inconsistent reload.
**Strength:** MUST
**Status:** DERIVED
**Source:** src/main.c:1070-1091 (comment: "Reload on main needs to happen
later than sec-mod. That's because of a test that the certificate matches
the used key.")
**Acceptance:** unit, local — trigger `SIGHUP` and confirm ordering via
log timestamps: sec-mod's `CMD_SECM_RELOAD_REPLY` (REQ-SECMOD, see
src/sec-mod.c:491-501) is observed before main's `reload_cfg_file` log
entry. Negative: make `secmod_reload()` fail (e.g. stop one sec-mod
instance before SIGHUP); confirm main initiates full shutdown rather than a
partial reload.
**Links:** REQ-SECMOD-INIT-001
## Completeness notes
- **Listener setup** (`_listen_ports`, `listen_ports`,
`set_udp_socket_options`, `set_common_socket_options`) is configuration
plumbing with no MUST/MUST NOT contract beyond "bind what's configured in
`doc/sample.config` (`listen-host`, `tcp-port`, `udp-port`, etc.)" —
`[UNDOCUMENTED: candidate REQ-MAIN-INIT-* if specific socket-option
requirements (e.g. SO_REUSEADDR, IPV6_V6ONLY) are found to matter for
correctness rather than just performance.]`
- **UDP session forwarding** (`forward_udp_to_owner`,
`CMD_UDP_FD`/`udp_fd_msg`): not covered here. `[UNDOCUMENTED: candidate
REQ-MAIN-NET-* + REQ-IPC-* covering how main demultiplexes UDP packets to
the owning worker by source address, and what happens on a DTLS
client-hello from an address with no matching worker.]`
- **occtl control socket** (`main-ctl-unix.c`): only the termination flow is
covered (`internal/ipc.md` REQ-IPC-070..072). Other `occtl` commands
(`show status`, `show ip bans`, `reload`, `show iroutes`) are
reporting/administrative with no further requirements in this pass.
- **chroot** (`src/main.c:1675-1691`): sec-mod's chdir-to-chroot behavior is
noted but not analyzed for completeness — `[UNDOCUMENTED: candidate
REQ-MAIN-SEC-* covering what happens if chroot_dir is misconfigured
(relative vs absolute socket paths).]`
+321
View File
@@ -0,0 +1,321 @@
---
title: sec-mod requirements
generator: requirements-from-implementation
process: sec-mod
id-prefix: REQ-SECMOD
sources:
- src/sec-mod.c
- src/sec-mod-auth.c
- src/sec-mod-auth.h
- src/sec-mod-db.c
- src/sec-mod-cookies.c
- src/sec-mod-resume.c
- src/sec-mod.h
- src/defs.h
- src/vpn.h
- doc/design.md#the-security-module-process
- doc/requirements/internal/ipc.md
---
# sec-mod Requirements
sec-mod runs as root, holds private keys, session state (`client_db`), and
performs authentication and accounting. It is the only process that may
access credentials and the only process trusted to assign SIDs and issue
session cookies. See `internal/ipc.md` for the message-level contracts;
this document covers sec-mod's internal state machine, the auth module
vtable (`auth_mod_st`, `src/sec-mod-auth.h`), and session/cookie lifecycle.
## INIT
### REQ-SECMOD-INIT-001 — Per-vhost module initialization is idempotent
**Requirement:** `sec_auth_init()` MUST initialize each enabled auth
module's `vhost_init` and the accounting module's `vhost_init` for a vhost
at most once: it MUST only call `vhost_init` when `auth_ctx`/`acct_ctx` is
still `NULL`.
**Strength:** MUST
**Status:** DERIVED
**Source:** src/sec-mod-auth.c:63-85
**Acceptance:** unit, local — call `sec_auth_init(vhost)` twice; confirm
`vhost_init` is invoked exactly once per configured auth/acct module
(e.g. via a counting mock module).
**Links:**
## AUTH — auth_mod_st vtable contract
The auth-module vtable (`src/sec-mod-auth.h`), per-method authentication
requirements, and the certificate defense-in-depth/group-selection logic
formerly numbered `REQ-SECMOD-AUTH-001..006` have moved to
`internal/authentication.md` as `REQ-AUTH-AUTH-001..006`, alongside a full
per-method (plain/PAM/RADIUS/GSSAPI/OIDC/certificate) requirements pass. The
old IDs are kept reserved (not reused) for citation stability:
| Old ID | New ID |
|--------|--------|
| `REQ-SECMOD-AUTH-001` | `REQ-AUTH-AUTH-001` |
| `REQ-SECMOD-AUTH-002` | `REQ-AUTH-AUTH-002` |
| `REQ-SECMOD-AUTH-003` | `REQ-AUTH-AUTH-003` |
| `REQ-SECMOD-AUTH-004` | `REQ-AUTH-AUTH-004` |
| `REQ-SECMOD-AUTH-005` | `REQ-AUTH-AUTH-005` |
| `REQ-SECMOD-AUTH-006` | `REQ-AUTH-AUTH-006` |
`REQ-SECMOD-AUTH-001..006` are `WITHDRAWN` (relocated; see table above).
## SEC — anti-replay, banning, key isolation
### REQ-SECMOD-SEC-001 — SID is generated with a CSPRNG and is per-instance-tagged
**Requirement:** `new_client_entry()` MUST generate `e->sid` using
`gnutls_rnd(GNUTLS_RND_RANDOM, ...)` (a cryptographically secure RNG), and
MUST overwrite `sid[0]` with `sec->sec_mod_instance_id` so that SIDs from
different sec-mod instances (in multi-instance deployments) are
distinguishable. If after 3 retries a collision still exists in
`client_db`, entry creation MUST fail rather than reuse a SID.
**Strength:** MUST
**Status:** DERIVED
**Source:** src/sec-mod-db.c:98-156
**Acceptance:** [SEC] unit, local — generate many client entries and
confirm no two share a `sid`; confirm `sid[0]` always equals the
configured `sec_mod_instance_id`. Negative: simulate `find_client_entry`
always returning non-NULL (collision) and confirm `new_client_entry`
returns NULL after 3 retries rather than looping forever or reusing.
**Links:** REQ-IPC-017
### REQ-SECMOD-SEC-002 — safe_id is derived from SID, not independently random
**Requirement:** `e->acct_info.safe_id` (the value exposed to `occtl` and
external accounting, `SAFE_ID_SIZE` bytes printable) MUST be computed by
`calc_safe_id(e->sid, SID_SIZE, ...)` — a deterministic derivation from the
SID — not a separately generated random value, so that `safe_id` can be used
to look up sessions (REQ-IPC-070) without sec-mod maintaining a second
index.
**Strength:** MUST
**Status:** DERIVED
**Source:** src/sec-mod-db.c:139-140; src/common/common.c:54-79
(`calc_safe_id` calls `safe_hash`, which runs `sid` through SHA-1 via
nettle's `sha1_init`/`sha1_update`/`sha1_digest`, then `oc_base64_encode`s
the 20-byte digest; comment at common.c:53 states "The goal is one-wayness")
**Acceptance:** unit, local — confirm `calc_safe_id(sid, ...)` is
deterministic (same `sid` -> same `safe_id`, since SHA-1 + base64 are
deterministic) and that `safe_id` does not trivially reveal `sid`: the
derivation is SHA-1(`sid`) (not a simple encoding/truncation), so recovering
`sid` from `safe_id` requires inverting SHA-1, which is computationally
infeasible. `safe_id` MUST nonetheless continue to be treated as sensitive:
since it is a deterministic function of `sid` and terminate-by-prefix
(REQ-IPC-070) accepts a `safe_id` prefix to select a session, possession of
`safe_id` is sufficient to identify/terminate that session even though `sid`
itself cannot be recovered from it.
**Links:** REQ-SECMOD-SEC-001, REQ-IPC-070
### REQ-SECMOD-SEC-003 — Wrong-password and connection scoring feed the IP ban list, gated by max-ban-score
**Requirement:** sec-mod MUST send `CMD_SECM_BAN_IP` with
`score = ban_points_wrong_password` on a qualifying failed/retried
authentication (REQ-AUTH-AUTH-003, and on final auth failure per
src/sec-mod-auth.c:443-445), but MUST NOT send any `CMD_SECM_BAN_IP`
message at all if `vhost->config->max_ban_score == 0` (banning disabled for
that vhost).
**Strength:** MUST
**Status:** DERIVED
**Source:** src/sec-mod-auth.c:89-119 (`sec_mod_add_score_to_ip`, early
return on `max_ban_score == 0`); src/config.c:796-805
(`DEFAULT_MAX_BAN_SCORE`, `DEFAULT_PASSWORD_POINTS`)
**Acceptance:** unit, local — set `max-ban-score = 0` in vhost config; drive
repeated failed authentications; confirm no `CMD_SECM_BAN_IP` is sent
(main's ban table for that IP remains empty). With `max-ban-score > 0`,
confirm `CMD_SECM_BAN_IP.score` matches the configured
`ban-points-wrong-password`. Cross-reference `doc/sample.config` `max-ban-score` /
`ban-points-wrong-password` documentation.
**Links:** REQ-AUTH-AUTH-003, REQ-IPC-080
### REQ-SECMOD-SEC-004 — sec-mod never serves a private-key operation for an unbound vhost
**Requirement:** sec-mod's connection-acceptance path (`accept()` plus
`check_upeer_id()` in its main loop) MUST establish which vhost a connecting
worker (`cfd`) was started for, and `process_worker_packet()` MUST use that
server-recorded vhost — not the `vhost` field of the incoming
`sec_op_msg`/`sec_get_pk_msg` — to select the vhost whose `key[key_idx]` is
used for `CMD_SEC_SIGN`, `CMD_SEC_DECRYPT`, `CMD_SEC_SIGN_DATA`,
`CMD_SEC_SIGN_HASH`, and `CMD_SEC_GET_PK`.
**Strength:** MUST NOT
**Status:** REVIEW
**Source:** src/sec-mod.c:219-338 (`process_worker_packet`, all five
key-operation cases call `find_vhost(sec->vconfig, op->vhost)` /
`find_vhost(sec->vconfig, pkm->vhost)` — the message-supplied string — then
bounds-check and index `vhost->key[key_idx]`); src/sec-mod.c:1160-1201
(accept loop — `check_upeer_id()` validates only the peer's uid/gid/pid, and
records no vhost for `cfd`); src/vhost.h:137-152 (`find_vhost()` — matches
any configured vhost by name, case-insensitively, falling back to
`default_vhost()`; never returns NULL); src/ipc.proto:284-300 (`sec_op_msg`,
`sec_get_pk_msg``vhost` is an optional string in the message, with no
`sid` or other connection-identity field); REQ-IPC-051
**Acceptance:** [REVIEW: as implemented, this requirement does NOT hold.
sec-mod's accept loop performs only OS-level peer-credential validation
(`check_upeer_id`) and records no per-`cfd` vhost binding. Each of the five
key-operation handlers in `process_worker_packet()` resolves the vhost
solely via `find_vhost(sec->vconfig, <message>->vhost)` — a string supplied
by the requesting worker — then indexes that vhost's `key[key_idx]`
(bounds-checked against `vhost->key_size`, but only for the
*message-resolved* vhost, not the requester's own). Concretely: a worker
process started for vhost A can send `sec_op_msg{vhost="B", key_idx=0,
sig=...}` over its existing sec-mod socket, and sec-mod will perform the
signing/decryption with vhost B's private key and return the result — there
is no check that the requester is actually serving vhost B. Negative test
(once fixed): a worker socket established for vhost A sends a
`sec_op_msg`/`sec_get_pk_msg` naming vhost B; sec-mod MUST reject it (e.g.
`ERR_AUTH_FAIL`/connection close) rather than performing the operation with
vhost B's key. This needs maintainer review per AGENTS.md's "Changes to
cookie or SID handling" / privilege-boundary criteria — flagging here rather
than resolving unilaterally.]
**Links:** REQ-IPC-050, REQ-IPC-051
## SESSION — client_db lifecycle, cookies, expiry
### REQ-SECMOD-SESSION-001 — A client_entry_st is keyed solely by SID
**Requirement:** `find_client_entry()` MUST locate entries by exact 32-byte
(`SID_SIZE`) match on `e->sid` only; sec-mod MUST NOT accept a partial or
prefix match for `SEC_AUTH_CONT`, `SECM_SESSION_OPEN`, or
`SECM_SESSION_CLOSE` (contrast with `terminate_session_by_sid`,
REQ-SECMOD-SESSION-005, which intentionally allows a `safe_id` prefix for
the human-facing `occtl terminate` command only).
**Strength:** MUST
**Status:** DERIVED
**Source:** src/sec-mod-db.c:158-176
**Acceptance:** negative, local — send `SEC_AUTH_CONT` with a `sid` that is
a correct prefix of a valid SID but padded/truncated to `SID_SIZE`;
confirm `find_client_entry` returns NULL (no match) — i.e., this is a
full-value comparison, covered structurally by REQ-IPC-015.
**Links:** REQ-IPC-015, REQ-SECMOD-SESSION-005
### REQ-SECMOD-SESSION-002 — Expiry requires in_use == 0
**Requirement:** A `client_entry_st` MUST NOT be treated as expired
(`IS_CLIENT_ENTRY_EXPIRED`) while `e->in_use > 0`, regardless of
`e->exptime`, so that an active session (one with an open
`SECM_SESSION_OPEN` reference) is never reaped by `cleanup_client_entries()`
mid-use.
**Strength:** MUST
**Status:** DERIVED
**Source:** src/sec-mod.h:84-87 (`IS_CLIENT_ENTRY_EXPIRED_FULL`:
`e->exptime != -1 && now >= e->exptime && e->in_use == 0`)
**Acceptance:** unit, local — set `e->exptime` in the past while
`e->in_use == 1`; confirm `cleanup_client_entries()` does not delete the
entry. Decrement `in_use` to 0 (via `expire_client_entry`); confirm it is
now eligible for cleanup once `exptime` passes.
**Links:** REQ-IPC-022, REQ-SECMOD-SESSION-003
### REQ-SECMOD-SESSION-003 — expire_client_entry: persistent-cookies and disconnect-reason determine immediate delete vs. temporary close
**Requirement:** On `expire_client_entry()` with `e->in_use` reaching 0:
- If `persistent_cookies == 0` AND `discon_reason` is one of
`REASON_SERVER_DISCONNECT`, `REASON_SESSION_TIMEOUT`, or
(`REASON_USER_DISCONNECT` AND `session_is_open`), the entry MUST be
deleted immediately (`del_client_entry`) — no cookie reuse is possible.
- Otherwise, the entry MUST be kept with a refreshed `exptime`:
- For `REASON_USER_DISCONNECT`, `exptime` MUST only be shortened to
`now + AUTH_SLACK_TIME` if not `persistent_cookies` or the entry would
otherwise outlive `now + AUTH_SLACK_TIME`'s lower bound (i.e. never
*extend* expiry on user disconnect).
- For all other reasons, `exptime = now + cookie_timeout +
AUTH_SLACK_TIME`.
**Strength:** MUST
**Status:** DERIVED
**Source:** src/sec-mod-db.c:210-247
**Acceptance:** unit, local — for each `discon_reason` value and each
`persistent_cookies` setting (0/1), call `expire_client_entry` and assert
the resulting state (deleted vs. kept) and, if kept, `exptime`. Cross-check
`doc/sample.config` for `persistent-cookies`.
**Links:** REQ-SECMOD-SESSION-002, REQ-IPC-031
### REQ-SECMOD-SESSION-004 — terminate_user_sessions deletes all matching entries unconditionally
**Requirement:** `terminate_user_sessions(sec, username)` MUST delete every
`client_entry_st` with `acct_info.username == username`, regardless of
`in_use`, `status`, or `exptime` — administrative termination overrides
normal expiry rules (REQ-SECMOD-SESSION-002 does not apply to this path). It
MUST return 1 if at least one entry was deleted, 0 if `username` is
NULL/empty or no entry matched.
**Strength:** MUST
**Status:** DERIVED
**Source:** src/sec-mod-db.c:252-284
**Acceptance:** negative, local — terminate a user with `in_use > 0` (an
open session); confirm the entry is deleted immediately and a subsequent
`SECM_SESSION_OPEN`/`AUTH_COOKIE_REQ` for that SID fails (REQ-IPC-021,
REQ-IPC-072). Confirm return value 0 for an empty username.
**Links:** REQ-IPC-071, REQ-IPC-072, REQ-SECMOD-SESSION-002
### REQ-SECMOD-SESSION-005 — terminate_session_by_sid requires an exact-length safe_id and stops at first match
**Requirement:** `terminate_session_by_sid()` MUST reject a `safe_id` whose
length is not exactly `SAFE_ID_SIZE - 1`, MUST match by prefix
(`memcmp` over `safe_id_len` bytes) against `t->acct_info.safe_id`, and MUST
stop at the first match (session IDs are assumed unique at this length).
Disambiguation of *shorter* prefixes is occtl's responsibility
(REQ-IPC-070), not sec-mod's — by the time sec-mod receives
`SECM_TERMINATE_SESSION`, the `safe_id` MUST already be full-length.
**Strength:** MUST
**Status:** DERIVED
**Source:** src/sec-mod-db.c:289-331
**Acceptance:** negative, local — send `SECM_TERMINATE_SESSION` with a
`safe_id` shorter than `SAFE_ID_SIZE - 1`; confirm rejection with log
`invalid session ID length` and `terminated == 0`. This is the
defense-in-depth backstop for REQ-IPC-070 (occtl-side prefix
disambiguation) — even if occtl's check were bypassed, sec-mod will not
act on a short prefix.
**Links:** REQ-IPC-070, REQ-IPC-071
### REQ-SECMOD-SESSION-006 — list-cookies omits expired entries and never-expiring in-use sessions report expires=0
**Requirement:** `handle_secm_list_cookies_reply()` MUST skip entries for
which `IS_CLIENT_ENTRY_EXPIRED` is true, and MUST report
`CookieIntMsg.expires = 0` (meaning "does not expire") for any entry with
`e->in_use > 0`, even if `e->exptime` is set to a finite value.
**Strength:** MUST
**Status:** DERIVED
**Source:** src/sec-mod-cookies.c:74-114
**Acceptance:** unit, local — create entries in states {expired,
unexpired+in_use=0, unexpired+in_use>0}; request `SECM_LIST_COOKIES`;
confirm the expired entry is absent, and the `in_use>0` entry has
`expires=0` in the reply (`occtl show sessions valid` reflects this).
**Links:** REQ-SECMOD-SESSION-002, REQ-IPC-070
## TEARDOWN
### REQ-SECMOD-TEARDOWN-001 — db deinit calls auth_deinit for every remaining entry
**Requirement:** `sec_mod_client_db_deinit()` MUST call
`sec_auth_user_deinit()` (which invokes `auth_mod_st.auth_deinit` if the
entry has a module/`auth_ctx`) for every remaining `client_entry_st` before
freeing the hash table, so auth modules can release per-session resources
(e.g. PAM handles) on sec-mod shutdown — not only on normal session
completion.
**Strength:** MUST
**Status:** DERIVED
**Source:** src/sec-mod-db.c:71-85
**Acceptance:** unit, local — populate `client_db` with entries that have a
mock module with a counting `auth_deinit`; call
`sec_mod_client_db_deinit()`; confirm `auth_deinit` was called once per
entry, including entries that never reached `PS_AUTH_COMPLETED`.
**Links:** —
## Completeness notes
- **Vtable gap**: `auth_mod_st` fields `vhost_deinit` and `group_list` have
no requirement above — `[UNDOCUMENTED: vhost_deinit's call site was not
located in this pass; group_list appears used only by occtl-facing group
enumeration, not the auth state machine. Add requirements once call sites
are confirmed.]`
- **PAM/RADIUS/GSSAPI/OIDC module-specific behaviors** (e.g. PAM
conversation function quirks, RADIUS Access-Challenge mapping to
`ERR_AUTH_CONTINUE`) are intentionally out of scope here — they implement
the `auth_mod_st` contract above and should get their own
`internal/auth-<module>.md` if/when needed; this document covers the
vtable contract every module must satisfy.
- **Reload (`CMD_SECM_RELOAD`)**: sec-mod calls `reload_server(sec)` and
replies `CMD_SECM_RELOAD_REPLY` (src/sec-mod.c:491-501) but this document
does not enumerate which parts of `sec_mod_st` state survive reload vs.
reset — `[UNDOCUMENTED: candidate REQ-SECMOD-INIT-* once reload semantics
for client_db / vhost config are confirmed against doc/sample.config
reload annotations.]`
+465
View File
@@ -0,0 +1,465 @@
---
title: worker process requirements
generator: requirements-from-implementation
process: worker
id-prefix: REQ-WORKER
sources:
- src/worker.c
- src/worker-vpn.c
- src/worker-auth.c
- src/worker-resume.c
- src/worker-privs.c
- src/isolate.c
- src/tlslib.c
- src/worker.h
- doc/design.md#the-worker-processes
- doc/requirements/internal/ipc.md
- doc/requirements/internal/sec-mod.md
---
# worker Process Requirements
The worker is unprivileged, seccomp-confined, and handles exactly one
client's TLS/DTLS session and HTTP(S) authentication exchange. It has no
direct access to credentials, private keys, or the ban database — these are
delegated to sec-mod (REQ-IPC-050) and main (`ws_add_score_to_ip` /
`WORKER_BAN_IP`, REQ-IPC-080) respectively. This document covers the
worker's own state machine: startup/config receipt, privilege drop, seccomp
filter, TLS/DTLS session setup, the HTTP authentication handlers, and cookie
finalization.
## INIT
### REQ-WORKER-INIT-001 — Worker refuses to run without OCSERV_ENV_WORKER_STARTUP_MSG
**Requirement:** `main()` MUST check for the `OCSERV_ENV_WORKER_STARTUP_MSG`
environment variable and `exit(EXIT_FAILURE)` with a diagnostic if absent,
before any other initialization — the worker binary MUST NOT be runnable
standalone.
**Strength:** MUST
**Status:** DERIVED
**Source:** src/worker.c:71-75
**Acceptance:** negative, local — run `ocserv-worker` directly (no env var
set); confirm it prints "This application is part of ocserv and should not
be run in isolation" and exits non-zero without opening any sockets.
**Links:**
### REQ-WORKER-INIT-002 — set_ws_from_env bounds-checks every variable-length field before memcpy
**Requirement:** `set_ws_from_env()` MUST validate
`msg->secmod_addr.len <= sizeof(ws->secmod_addr)`,
`msg->remote_addr.len <= sizeof(ws->remote_addr)`,
`msg->our_addr.len <= sizeof(ws->our_addr)`, and
`msg->sec_auth_init_hmac.len <= sizeof(ws->sec_auth_init_hmac)`
returning failure (and the worker exiting) if any check fails — before
copying the corresponding field with `memcpy`.
**Strength:** MUST
**Status:** DERIVED
**Source:** src/worker.c:277-307
**Acceptance:** [SEC] negative, unit — construct a `WorkerStartupMsg` with
`secmod_addr.len` (or each of the other three fields) larger than the
destination struct field; confirm `set_ws_from_env` returns 0 and the
worker exits via `return 1` in `main()`, rather than overflowing
`ws->secmod_addr`/etc. This is the worker-side mirror of REQ-IPC-041's
schema-diff concern — even if main never sends an oversized field, the
worker MUST NOT trust that invariant blindly, since `WorkerStartupMsg` is
attacker-influenced indirectly (fields like `remote_addr`/`our_addr`
ultimately derive from the client's connection).
**Links:** REQ-IPC-040, REQ-IPC-041
### REQ-WORKER-INIT-003 — drop_privileges performs chroot, then chdir("/"), then setgid/setuid, in that order
**Requirement:** If `chroot_dir` is configured, `drop_privileges()` MUST
`chdir(chroot_dir)`, then `chroot(chroot_dir)`, then `chdir("/")` — in this
order — each step fatal (`exit(EXIT_FAILURE)`) on failure. `setgid`/
`setgroups` (if `gid != -1` and currently root) MUST be performed before
`setuid` (if `uid != -1` and currently root) — reversing this order would
leave the process unable to change its group membership after dropping
root.
**Strength:** MUST
**Status:** DERIVED
**Source:** src/isolate.c:127-184
**Acceptance:** [SEC] negative, local — configure `chroot`, `run-as-user`,
`run-as-group`; after worker startup, confirm (a) the worker's filesystem
root is the configured chroot directory (e.g. via `/proc/<pid>/root`), (b)
`getuid()`/`getgid()` in the worker match the configured non-root
user/group, and (c) `getgroups()` returns exactly the configured group
(supplementary groups dropped via `setgroups(1, &gid)`).
**Links:**
### REQ-WORKER-INIT-004 — drop_privileges sets RLIMIT_NPROC to 0 after privilege drop
**Requirement:** `drop_privileges()` MUST call
`setrlimit(RLIMIT_NPROC, {0,0})` as its final step, preventing the worker
(now unprivileged) from forking any child process. A failure to set this
limit MUST be logged but is non-fatal (the worker continues — some
container/seccomp environments deny `setrlimit` itself).
**Strength:** MUST
**Status:** DERIVED
**Source:** src/isolate.c:186-193
**Acceptance:** [SEC] negative, local — after worker startup, attempt
`fork()` from within the worker (e.g. a debug build hook); confirm it fails
with `EAGAIN`/`ENOMEM` due to `RLIMIT_NPROC=0`. This is defense-in-depth: a
worker that achieved arbitrary code execution still cannot spawn a shell via
`fork`+`exec`.
**Links:** REQ-WORKER-INIT-005
### REQ-WORKER-INIT-005 — Worker syscall filter is a fixed allowlist; unlisted syscalls terminate the process
**Requirement:** When built with `HAVE_LIBSECCOMP`, `disable_system_calls()`
MUST install a seccomp filter whose default action is
`SCMP_ACT_ERRNO(ENOSYS)` (or `SCMP_ACT_TRAP` under
`USE_SECCOMP_TRAP`, which logs via `oc_syslog` and calls
`exit(EXIT_FAILURE)` from `sigsys_action`) for any syscall not on the
explicit allowlist. The allowlist MUST be installed (`seccomp_load`)
before the worker processes any client-supplied data. `ioctl` is
allowed ONLY for `SIOCGIFMTU` (`SCMP_A1(SCMP_CMP_EQ, SIOCGIFMTU)`) — no
other `ioctl` request codes are permitted.
**Strength:** MUST
**Status:** DERIVED
**Source:** src/worker-privs.c:71-237
**Acceptance:** [SEC] negative, local — with seccomp enabled
(`ISOLATE_WORKERS=true`, not ASAN/coverage build), attempt to trigger a
disallowed syscall from the worker (e.g. `socketpair`, `execve`, `ptrace`,
or `ioctl` with a request other than `SIOCGIFMTU`); confirm the worker
terminates (under `SCMP_ACT_ERRNO`: the call fails with `ENOSYS` and
whatever error handling follows; under `SCMP_ACT_TRAP`: the process exits
via `SIGSYS`/`sigsys_action`). A worker that gained code execution via a
parsing bug MUST NOT be able to `execve()` a shell or `ptrace()` another
process.
**Links:** REQ-WORKER-INIT-004
### REQ-WORKER-INIT-006 — seccomp filter is conditionally relaxed only for socket_wrapper test environments
**Requirement:** `readlink`/`readlinkat` MUST be added to the seccomp
allowlist ONLY when `SOCKET_WRAPPER_DIR` is set in the environment (test
harness). Production deployments (no `SOCKET_WRAPPER_DIR`) MUST NOT have
these syscalls available to the worker.
**Strength:** MUST NOT
**Status:** DERIVED
**Source:** src/worker-privs.c:107-112
**Acceptance:** [SEC] negative, local — run a production-configured worker
(no `SOCKET_WRAPPER_DIR`) and confirm `readlink`/`readlinkat` are rejected
by the seccomp filter. Confirmed no other env-var-gated relaxations exist in
`disable_system_calls()` (src/worker-privs.c:71-220, the only function
adding `seccomp_rule_add(ctx, SCMP_ACT_ALLOW, ...)` rules, called from
src/worker-vpn.c:881): every other `ADD_SYSCALL(...)` invocation in that
function is unconditional — `SOCKET_WRAPPER_DIR` is the only `getenv()` check
in the file, gating exactly the `readlink`/`readlinkat` pair.
**Links:** REQ-WORKER-INIT-005
---
## AUTH
The credential-delegation principle and certificate-username-extraction
requirements formerly numbered `REQ-WORKER-AUTH-001..003` have moved to
`internal/authentication.md`, alongside the full per-auth-method requirements
pass. The old IDs are kept reserved (not reused) for citation stability:
| Old ID | New ID |
|--------|--------|
| `REQ-WORKER-AUTH-001` | `REQ-AUTH-AUTH-007` |
| `REQ-WORKER-AUTH-002` | `REQ-AUTH-AUTH-008` |
| `REQ-WORKER-AUTH-003` | `REQ-AUTH-AUTH-009` |
`REQ-WORKER-AUTH-001..003` are `WITHDRAWN` (relocated; see table above). The
cookie/camouflage/IPC-validation requirements below
(`REQ-WORKER-AUTH-004..006`) remain here — they concern worker session/cookie
handling rather than authentication-method semantics.
### REQ-WORKER-AUTH-004 — Camouflage gate: failed cookie auth under camouflage returns 405, not 401/503, until the secret is matched
**Requirement:** When `camouflage` is configured and
`ws->camouflage_check_passed == 0`, a failed `cookie_authenticate_or_exit()`
MUST respond `405 Method Not Allowed` (indistinguishable from a non-VPN web
server's response to an unexpected method) rather than `401`/`503`, which
would reveal the presence of an ocserv endpoint to an unauthenticated
prober. `check_camouflage_url()` sets `camouflage_check_passed = 1` only
when the request URL's query string exactly equals
`camouflage_secret`, OR when `ws->auth_state >= S_AUTH_COOKIE` (i.e. the
client has already gotten past the camouflage gate once this session).
**Strength:** MUST
**Status:** DERIVED
**Source:** src/worker-auth.c:1044-1057, src/worker-vpn.c:819-836
**Acceptance:** [SEC] negative — with `camouflage` configured and a secret
set, send a CONNECT request with an invalid cookie and no/incorrect
camouflage query string; confirm the response is `405`, not `401` or
`503`. Positive: append `?<camouflage-secret>` to the URL with an invalid
cookie; confirm the response reverts to `401`/`503` (camouflage passed,
real error now shown).
**Links:**
### REQ-WORKER-AUTH-005 — Cookie auth reply from main is strictly validated before TUN claim and config acceptance
**Requirement:** `recv_cookie_auth_reply()`, on `AUTH__REP__OK`, MUST
validate (failing with `ERR_AUTH_FAIL` and NOT installing `ws->user_config`
or `ws->tun_fd` otherwise): `msg->vname`, `msg->config`, `msg->user_name`
all non-NULL; `msg->sid.len == sizeof(ws->sid)`;
`msg->session_id.len == sizeof(ws->session_id)`;
`msg->secmod_addr.len <= sizeof(ws->secmod_addr)`; and that
`tun_claim(ws->tun_fd)` succeeds for the fd received via
`send_socket_msg_to_worker` (REQ-IPC-023). Any failure MUST result in
`ERR_AUTH_FAIL` and the worker exiting via `cookie_authenticate_or_exit()`'s
`exit_worker(ws)` path — never falling through to `tun_mainloop` with a
partially-populated `ws->user_config`.
**Strength:** MUST
**Status:** DERIVED
**Source:** src/worker-auth.c:721-867, src/worker-auth.c:1024-1060
**Acceptance:** [SEC] negative, local — (requires fault injection in main or
a test double for `AUTH_COOKIE_REP`) send `AUTH__REP__OK` with `sid.len`
one byte short of `SID_SIZE`; confirm the worker treats this as
`ERR_AUTH_FAIL`, never sets `ws->sid_set = 1`, and exits rather than
proceeding into `connect_handler`.
**Links:** REQ-IPC-023, REQ-IPC-024
### REQ-WORKER-AUTH-006 — user_config from AuthCookieReplyMsg is a borrowed pointer; freed only on the error path
**Requirement:** On `AUTH__REP__OK`, `ws->user_config = msg->config` MUST
alias memory owned by the unpacked `AuthCookieReplyMsg` (`msg`) for the
remainder of the worker's lifetime — `recv_cookie_auth_reply()` MUST NOT
call `auth_cookie_reply_msg__free_unpacked(msg, &pa)` on the success path.
On any error path (`ret < 0`), it MUST free `msg` and set
`ws->user_config = NULL` to avoid a dangling pointer.
**Strength:** MUST
**Status:** DERIVED
**Source:** src/worker-auth.c:856-866 (comment explicitly documents this
lifetime contract)
**Acceptance:** [SEC] This is a memory-safety invariant rather than a
behaviorally-observable one, and is covered by the existing `asan/clang/Fedora`
CI job (.gitlab-ci.yml:403-414) rather than a dedicated test. That job runs
with `DISABLE_ASAN_BROKEN_TESTS=1`, which `tests/common.sh:33-36` translates
into `ISOLATE_WORKERS=false` — i.e. worker processes run under ASAN/LSAN
instrumentation instead of seccomp isolation. `recv_cookie_auth_reply()` is
called from `auth_cookie_request()` (src/worker-auth.c:1102) on every
successful connection, and `ws->user_config` is dereferenced throughout
`worker-vpn.c` for the life of the worker, so the `AUTH__REP__OK` success
path (and the subsequent free of `msg`/`ws->user_config` at worker exit) is
exercised by every CONNECT-flow `root_scripts` test (e.g.
`test-cookie-timeout`, `traffic`) that job runs. A pass of `asan/clang/Fedora`
with no LeakSanitizer/ASAN report against `recv_cookie_auth_reply` or
`ws->user_config` is sufficient acceptance; no new test required.
**Links:**
---
## SEC
### REQ-WORKER-SEC-001 — Private-key operations are marshaled to sec-mod; the worker never holds the private key
**Requirement:** `key_cb_sign_data_func`, `key_cb_sign_hash_func`,
`key_cb_sign_func`, and `key_cb_decrypt_func` (installed via
`gnutls_privkey_import_ext4`/`_ext2` with
`GNUTLS_PRIVKEY_IMPORT_AUTO_RELEASE`) MUST each call
`key_cb_common_func()`, which sends `CMD_SEC_SIGN_DATA` /
`CMD_SEC_SIGN_HASH` / `CMD_SEC_SIGN` / `CMD_SEC_DECRYPT` to sec-mod over
`cdata->sa` (the sec-mod socket address recorded at certificate-load time)
and returns sec-mod's result — the `gnutls_privkey_t` registered in the
worker's TLS credentials holds no exploitable key material, only `cdata`
(vhost name, key index, socket address).
**Strength:** MUST
**Status:** DERIVED
**Source:** src/tlslib.c:793-836, src/tlslib.c:893-930; REQ-IPC-050
**Acceptance:** [SEC] negative, local — after worker startup (post
`drop_privileges`+seccomp), search the worker's heap/memory for the PEM
private key's byte pattern (e.g. via a debug build that logs the key,
compared against `/proc/<pid>/maps` + `gcore`); confirm it is absent — only
sec-mod's process memory contains it. This is the core privilege-boundary
invariant restated from the worker's perspective; REQ-IPC-050 covers the
IPC framing.
**Links:** REQ-IPC-050
### REQ-WORKER-SEC-002 — TLS session resumption data is size-capped before being sent to sec-mod
**Requirement:** `resume_db_store()` MUST reject (return
`GNUTLS_E_DB_ERROR` without contacting sec-mod) any resumption `data.size >
MAX_SESSION_DATA_SIZE` or `key.size > GNUTLS_MAX_SESSION_ID`.
`resume_db_fetch()`/`resume_db_delete()` MUST similarly reject
`key.size > GNUTLS_MAX_SESSION_ID` before sending `RESUME_FETCH_REQ`/
`RESUME_DELETE_REQ`.
**Strength:** MUST
**Status:** DERIVED
**Source:** src/worker-resume.c:82-206
**Acceptance:** [SEC] negative, unit — call `resume_db_store` with a
`gnutls_datum_t` larger than `MAX_SESSION_DATA_SIZE`; confirm it returns
`GNUTLS_E_DB_ERROR` and `connect_to_secmod()` is never called (no socket
opened). This bounds the size of data sec-mod must store per session
(REQ-IPC-060) and prevents a malicious/buggy TLS stack from using session
tickets as an amplification vector against sec-mod's memory.
**Links:** REQ-IPC-060, REQ-IPC-061
### REQ-WORKER-SEC-003 — DTLS-PSK key is derived per-session via gnutls_prf from the already-authenticated TLS master secret
**Requirement:** `setup_dtls_psk_keys()` MUST derive the DTLS-PSK key via
`gnutls_prf(ws->session, ..., PSK_LABEL, ...)` over the existing
(authenticated) CSTP TLS session — it MUST NOT proceed
(`oclog(...); return -1`) if `ws->session == NULL` (no CSTP session to
derive from). The legacy DTLS path (`setup_legacy_dtls_keys`) similarly
requires `ws->req.selected_ciphersuite != NULL`, failing otherwise.
**Strength:** MUST
**Status:** DERIVED
**Source:** src/worker-vpn.c:273-381
**Acceptance:** unit, local — attempt DTLS setup before CSTP TLS handshake
completes (`ws->session == NULL`); confirm `setup_dtls_psk_keys` returns -1
and no DTLS session is established. This ensures the DTLS channel is always
cryptographically bound to an already-authenticated TLS session — a DTLS
channel cannot be the *first* authenticated channel for a worker.
**Links:**
### REQ-WORKER-SEC-004 — Ban-score reports to main are advisory for final reports; main's verdict on non-final reports is enforced by immediate worker exit
**Requirement:** `ws_add_score_to_ip()` MUST send a `WORKER_BAN_IP`
(`BanIpMsg`) to main and act on `BanIpReplyMsg` (REQ-IPC-080) as follows:
the worker holds no ban database and cannot itself decide whether an IP is
banned — that decision is made by main (`add_str_ip_to_ban_list`,
`check_if_banned`, REQ-MAIN-SEC-002..007). For a **final** report
(`final == 1`, sent only from `exit_worker_reason()` when the worker has
already decided to exit for `reason`/`discon_reason`), the worker MUST NOT
let `reply->reply` alter that already-decided exit. For a **non-final**
report (`final == 0`, e.g. on a scoring event during an active session),
the worker MUST treat `reply->reply != AUTH__REP__OK` as main's
instruction to terminate the *current* connection immediately
(`exit(EXIT_FAILURE)`) — main is the sole authority on whether the ban
threshold (`max_ban_score`) has been exceeded, but the worker enforces that
single verdict locally.
**Strength:** MUST
**Status:** DERIVED
**Source:** src/worker-vpn.c:479-531 (`ws_add_score_to_ip`: `if (final == 0
&& reply->reply != AUTH__REP__OK) exit(EXIT_FAILURE);`); src/worker-vpn.c:600-618
(`exit_worker_reason` — calls `ws_add_score_to_ip(ws, 0, 1, reason)` only
after unconditionally committing to `talloc_free(ws->main_pool)` +
`worker_exit(EXIT_FAILURE)`, which execute regardless of `reply->reply`);
src/worker-kkdcp.c:153 (`ws_add_score_to_ip(ws,
WSRCONFIG(ws)->ban_points_kkdcp, 0, 0)` — the only `final == 0` call site);
src/ipc.proto:209-214 (`ban_ip_reply_msg.reply` comment: "whether to
disconnect the user"); src/main-worker-cmd.c:298-310 (`CMD_BAN_IP` handler —
`reply.reply = AUTH__REP__FAILED` iff `add_str_ip_to_ban_list()` reports the
score now exceeds `max_ban_score`, else `AUTH__REP__OK`)
**Acceptance:** [SEC] Confirmed by tracing both call sites of
`ws_add_score_to_ip`:
- **Final report** (`exit_worker_reason`, `final=1`): the `exit()` check
at worker-vpn.c:525 is gated on `final == 0`, so `reply->reply` has no
effect — the worker exits for the reason it already had, independent of
the ban reply. Positive test: drive a session to `exit_worker_reason`
with `ws->ban_points > 0` while main's reply is `AUTH__REP__FAILED`;
confirm the worker's exit code/log reflects `discon_reason`, not the
ban reply.
- **Non-final report** (`worker-kkdcp.c`, `final=0`): if main replies
`AUTH__REP__FAILED` (max-ban-score exceeded for this IP), the worker
calls `exit(EXIT_FAILURE)` immediately, tearing down the current
connection. Positive test: configure a low `max-ban-score`, trigger
repeated `ban_points_kkdcp`-scoring KKDCP requests from one IP; confirm
the worker exits once main reports `AUTH__REP__FAILED`, and that the
*next* connection attempt from that IP is rejected by main per
REQ-MAIN-SEC-002..007 (the ban now applies to future connections too,
not just this one).
- Negative: confirm a worker cannot use `BanIpReplyMsg` to learn ban-list
state for *other* IPs — `ban_ip_msg.ip = ws->remote_ip_str` is always the
worker's own peer address (worker-vpn.c:500), so the reply is scoped to
that single IP only.
**Links:** REQ-IPC-080, REQ-MAIN-SEC-002, REQ-MAIN-SEC-005
---
## NET
### REQ-WORKER-NET-001 — Virtual host selection from TLS SNI happens before credentials are set, and falls back to default vhost
**Requirement:** `hello_hook_func()` (or its `peek_client_hello` fallback
for GnuTLS < 3.4) MUST call `find_vhost(ws->vconfig, hostname)` using the
TLS ClientHello's `server_name` extension (parsed via manual
`SKIP8`/`SKIP16` traversal of the handshake message, bounds-checked against
`msg->size` and `sizeof(ws->buffer) - 1` at each step), and MUST call
`SET_VHOST_CREDS` (selecting the matching vhost's certificate/key
credentials for the handshake) regardless of whether a matching vhost was
found — falling back to the default vhost. A client requesting an unknown
hostname MUST NOT cause the handshake to abort; it proceeds with the
default vhost's credentials (logged at `LOG_INFO`).
**Strength:** MUST
**Status:** DERIVED
**Source:** src/worker-vpn.c:671-836
**Acceptance:** unit, local — connect with SNI set to a hostname not
matching any configured vhost; confirm the handshake completes using the
default vhost's certificate, and the server logs "client requested hostname
... does not match known vhost". [SEC] negative — send a ClientHello with a
server_name extension whose declared length (`hsize`) would read past
`msg->size` or past `sizeof(ws->buffer)`; confirm `hello_hook_func` detects
this (`hsize == 0 || hsize + pos > msg->size || hsize > sizeof(ws->buffer) -
1`) and aborts SNI parsing (`goto finish`) without an out-of-bounds
`memcpy`.
**Links:**
### REQ-WORKER-NET-002 — connect_handler only accepts the literal CONNECT target /CSCOSSLC/tunnel
**Requirement:** `connect_handler()` MUST respond `404` and close the
connection (`cstp_fatal_close` + `exit_worker`) for any CONNECT request
whose `req->url` is neither `/CSCOSSLC/tunnel` nor `CSCOSSLC/tunnel`
(the latter accommodating a known Clavister Android client defect). This
check MUST happen after `cookie_authenticate_or_exit()` — i.e. an
unauthenticated client gets the cookie-auth error response, not a 404,
regardless of the URL it requested.
**Strength:** MUST
**Status:** DERIVED
**Source:** src/worker-vpn.c:2075-2085
**Acceptance:** negative, local — with a valid cookie, send
`CONNECT /not-the-tunnel-path HTTP/1.1`; confirm `404` and connection
close. With an invalid/absent cookie and the same bad path, confirm the
cookie-auth error (REQ-WORKER-AUTH-005) is returned, not `404` — ordering
matters for camouflage (REQ-WORKER-AUTH-004).
**Links:** REQ-WORKER-AUTH-004, REQ-WORKER-AUTH-005
### REQ-WORKER-NET-003 — DTLS is enabled only if UDP is configured, not disabled by user config, and a TLS master secret was captured
**Requirement:** `connect_handler()` MUST set
`DTLS_ACTIVE(ws)->udp_state = DTLS_INACTIVE(ws)->udp_state = UP_WAIT_FD`
(enabling DTLS) only if ALL of: `WSSCONFIG(ws)->udp_port != 0`,
`!WSRCONFIG(ws)->no_udp` (REQ-IPC AUTH_COOKIE_REP `config.no_udp`,
REQ-WORKER-AUTH-005), and `req->master_secret_set != 0` (the TLS handshake
exposed a master secret via the `SSLKEYLOGFILE`-style hook). If any
condition fails, DTLS MUST remain `UP_DISABLED` and `ws->master_secret`
MUST NOT be populated from `req->master_secret`.
**Strength:** MUST
**Status:** DERIVED
**Source:** src/worker-vpn.c:2157-2168
**Acceptance:** unit, local — (a) set `no_udp` for a user (via sec-mod
per-user config) and confirm `X-DTLS-*` headers are absent / DTLS never
activates for that session even though `udp-port` is globally configured;
(b) configure `udp-port = 0` and confirm the same.
**Links:** REQ-WORKER-SEC-003
## Completeness notes
- **`worker-vpn.c` main loops** (`tls_mainloop`, `dtls_mainloop`,
`tun_mainloop`, `parse_data`/`parse_cstp_data`/`parse_dtls_data`,
~2600 lines combined): the per-packet CSTP/DTLS framing and tun-bridging
protocol is largely *protocol*-level (what bytes mean) rather than
process-security — those requirements belong in
`protocol/sources/anyconnect.md` (AC-CLIENT-*) and `protocol/unified.md`
(REQ-PROTO-DATA-*), not here. `[UNDOCUMENTED: candidate
REQ-WORKER-NET-* if a process-boundary-relevant invariant is found in
these loops, e.g. bounds-checking of attacker-controlled length fields
before `tun_enqueue_write`.]`
- **`worker-kkdcp.c`** (GSSAPI KKDCP proxying, 334 lines): not examined in
this pass. `[UNDOCUMENTED: candidate REQ-WORKER-AUTH-* / REQ-WORKER-NET-*
covering how KKDCP requests are validated/forwarded — this is a
worker-to-KDC proxy path and warrants its own security review given it
involves the worker making outbound network connections.]`
- **`worker-proxyproto.c`** (492 lines, PROXY protocol v1/v2 parsing): not
examined. `[UNDOCUMENTED: candidate REQ-WORKER-NET-* — this is the
worker-side counterpart to REQ-MAIN-INIT-005's open question about
post-PROXY-header ban enforcement; also a natural place for
length/bounds-checking requirements on attacker-controlled proxy headers.]`
- **`worker-svc.c`** (Cisco SVC/AnyConnect-specific binary protocol
handling, 304 lines): deferred to `protocol/sources/anyconnect.md`
(AC-CLIENT-*) per the project layout — this is client-compatibility
protocol surface, not a process-isolation concern.
- **`worker-http.c`/`worker-http-handlers.c`** (HTTP parsing via llhttp,
~1187 lines combined): the HTTP request/response framing itself
(header limits, method routing) is protocol-level;
`MAX_HTTP_REQUESTS`/`requests_left` (src/worker-vpn.c:860) bounds the
number of pipelined requests per TLS connection — `[UNDOCUMENTED:
candidate REQ-WORKER-NET-* if this bound has security significance
beyond resource exhaustion, e.g. preventing auth-state confusion across
pipelined requests.]`
- **GSSAPI/KKDCP ASN.1 init** (`asn1_array2tree(kkdcp_asn1_tab, ...)`,
src/worker.c:175-182): fatal on failure at startup; not analyzed further.
@@ -0,0 +1,473 @@
---
title: AnyConnect / Cisco-compatibility client protocol requirements (working set)
generator: requirements-elicitation
process: n/a
id-prefix: AC-CLIENT
sources:
- doc/README-cisco-svc.md
- src/worker-svc.c
- src/worker-http.c (user-agent detection, known_urls table)
- src/worker-auth.c (cisco_client_compat, ANYCONNECT_CLIENT_COMPAT, webvpnc cookie, profile delivery)
- src/worker-vpn.c (per-agent IPv6/DNS/route behavior)
- src/worker.h (user_agent_type enum, ws->req.is_ios)
---
# Scope
This document captures requirements that are specific to **Cisco AnyConnect** and
the broader family of clients/devices that identify themselves via a
Cisco-compatible `User-Agent` string, as distinct from the generic OpenConnect
protocol covered in `protocol/sources/openconnect.md` (OC-PROTO-*).
**Core objective** [from doc/README-cisco-svc.md and code]: allow ocserv to
interoperate with clients and devices that were built against Cisco's AnyConnect
server (ASA) rather than against the OpenConnect protocol draft, by recognizing
their identifying strings and adjusting behavior (URL routing, cookie format,
config delivery, IPv6/DNS handling, TLS cipher selection) to match what those
clients expect.
**Explicit constraints**:
- The `/svc` endpoint and its behavior are gated by `cisco-svc-client-compat = true`
(per-vhost config option) — `[PROC: worker]`.
- The `ANYCONNECT_CLIENT_COMPAT` build-time option gates an entire family of
URL routes and cookie/profile-delivery extensions (`/profiles/*`,
`/VPNManifest.xml`, `/1/*`, `/+CSCOT+/*`, `/logout`, `webvpnc` cookie,
`<vpn-profile-manifest>` XML fragment) — `[PROC: worker]`.
- `cisco_client_compat` (a separate, runtime per-vhost config flag) changes the
certificate-presence requirement at cookie-auth time — `[PROC: worker, IPC]`
(affects what is sent to main in `AUTH_COOKIE_REQ`).
**Implicit constraints** `[IMPLICIT]`:
- `[IMPLICIT]` All AnyConnect-compatibility behavior must remain additive: it must
not change the wire behavior observed by clients that identify as
`AGENT_OPENCONNECT` / `AGENT_OPENCONNECT_V3`, except where the code explicitly
branches on `user_agent_type`.
- `[IMPLICIT]` User-Agent string matching is the *only* signal used to select
per-client behavior; there is no protocol-level capability negotiation for these
extensions (cross-ref OC-PROTO note on "no real version negotiation").
**Out of scope**: the generic config-auth XML handshake, CSTP/DTLS framing, and
DPD/keepalive/rekey semantics, which are common to all agent types and already
covered by `openconnect.md`.
---
# COMPAT — Client identification
## AC-CLIENT-COMPAT-001
**Requirement**: The worker MUST classify each connecting client into one of the
`user_agent_type` values (`AGENT_UNKNOWN`, `AGENT_OPENCONNECT_V3`,
`AGENT_OPENCONNECT`, `AGENT_ANYCONNECT`, `AGENT_OPENCONNECT_CLAVISTER`,
`AGENT_ANYLINK`, `AGENT_SVC_IPPHONE`) based on a case-insensitive prefix match of
the `User-Agent` HTTP header against a fixed, ordered list of strings.
- Strength: MUST (this is the sole dispatch mechanism for all AC-CLIENT-* behavior)
- Source: `src/worker-http.c` (`HEADER_USER_AGENT` case, ~lines 410-477)
- Acceptance: connecting with each of the recognized `User-Agent` strings (e.g.
`"Cisco AnyConnect VPN Agent for Apple..."`, `"Cisco AnyConnect..."`,
`"AnyConnect-compatible OpenConnect..."`, `"AnyConnect..."`,
`"Clavister OneConnect VPN..."`, `"AnyLink Secure Client..."`,
`"Cisco SVC IPPhone Client..."`, `"Open AnyConnect VPN Agent v<N>"`,
`"OpenConnect VPN Agent..."`) results in the expected `user_agent_type`; an
unrecognized string results in `AGENT_UNKNOWN` and is logged at `LOG_DEBUG`
without rejecting the connection.
- Links: REQ-WORKER-NET-* (worker.md), OC-PROTO-CONN-005 (capability headers)
## AC-CLIENT-COMPAT-002 `[AMBIGUOUS]`
**Requirement**: The order of the `User-Agent` prefix checks SHALL be significant:
`"AnyConnect-compatible OpenConnect"` (33 chars) is checked before the shorter
`"AnyConnect"` (10 chars) prefix, so OpenConnect builds that self-identify with
the compatibility string are classified as `AGENT_OPENCONNECT`, not
`AGENT_ANYCONNECT`.
- Strength: MUST (de facto — reordering would silently reclassify a whole class of
OpenConnect clients as AnyConnect, changing DNS/IPv6 header formatting per
AC-CLIENT-CONN-002)
- Status: `[UNDOCUMENTED]` — this ordering dependency is not commented in the code
and is easy to break with future additions to the table.
- Source: `src/worker-http.c` lines 434-459
- Acceptance: a `User-Agent` of `"AnyConnect-compatible OpenConnect vX"` MUST yield
`AGENT_OPENCONNECT`, not `AGENT_ANYCONNECT`.
- `[CANDIDATE for unified.md]`: recommend adding a comment documenting the ordering
invariant, and/or a unit test enumerating one representative string per branch
with its expected `user_agent_type`, to guard against future reordering.
## AC-CLIENT-COMPAT-003
**Requirement**: Detection of `"Open AnyConnect VPN Agent v<N>"` (the historical
OpenConnect self-identification string) MUST further branch on the numeric
version `N`: `N <= 3` yields `AGENT_OPENCONNECT_V3` (legacy XML reply format, see
AC-CLIENT-CONN-001), `N > 3` yields `AGENT_OPENCONNECT`.
- Strength: MUST
- Source: `src/worker-http.c` lines 421-433 (`atoi(&req->user_agent[27])`)
- Acceptance: `"Open AnyConnect VPN Agent v3"` -> `AGENT_OPENCONNECT_V3`;
`"Open AnyConnect VPN Agent v4"` -> `AGENT_OPENCONNECT`.
- `[REVIEW]`: `atoi` on attacker-controlled input is bounds-safe here (result only
drives an enum branch, no allocation/indexing), but a non-numeric suffix (e.g.
`"Open AnyConnect VPN Agent vX"`) yields `atoi() == 0 <= 3`, i.e.
`AGENT_OPENCONNECT_V3`. This degrades gracefully (more conservative feature set)
and is not flagged as a defect, but is worth a one-line comment.
---
# CONN — AnyConnect-specific connection/response shaping
## AC-CLIENT-CONN-001
**Requirement**: For `AGENT_OPENCONNECT_V3` clients, the successful-authentication
response body MUST use the legacy XML wrapper (`ocv3_success_msg_head` /
`ocv3_success_msg_foot`, i.e. a bare `<auth id="success">...</auth>` without the
enclosing `<config-auth>` element), instead of the standard
`oc_success_msg_head` / `*_FOOT` wrapper used for all other agent types.
- Strength: MUST
- Source: `src/worker-auth.c` lines 50-76, 1124-1151 (`post_common_handler`)
- Acceptance: a client identifying as `"Open AnyConnect VPN Agent v3"` and
successfully authenticating receives a response body beginning with
`<auth id="success">` (no `<?xml...?><config-auth...>` wrapper element around
it) and ending with `</auth>`.
- Links: OC-PROTO-AUTH-008 (cert auth success -> complete flow), `[CANDIDATE for
unified.md]`: classify as **EXTENSION** (backward-compatibility shim for a
client population predating the `config-auth` XML schema described in
draft-openconnect).
## AC-CLIENT-CONN-002
**Requirement**: For DNS server delivery, when `user_agent_type == AGENT_ANYCONNECT`
and a DNS entry is IPv6, the worker MUST send `X-CSTP-DNS-IP6: <addr>`; for an IPv4
DNS entry (any agent) or for any DNS entry from a non-AnyConnect agent, it MUST
send `X-CSTP-DNS: <addr>` (the comment in the source states "openconnect does not
require the split of DNS and DNS-IP6 and only recent versions understand the IP6
variant").
- Strength: MUST
- Source: `src/worker-vpn.c` lines 2291-2314
- Acceptance: with `full_ipv6` enabled and an IPv6 DNS server configured, an
`AGENT_ANYCONNECT` session receives `X-CSTP-DNS-IP6: <addr>`; an
`AGENT_OPENCONNECT` session receives `X-CSTP-DNS: <addr>` for the same address.
- `[CANDIDATE for unified.md]`: classify as **DIVERGENT** — same logical
information (DNS server address), different header name depending on detected
client family. Not a security concern but a compatibility-surface item worth
tracking if `X-CSTP-DNS-IP6` is ever standardized.
## AC-CLIENT-CONN-003
**Requirement**: IPv6 route/DNS advertisement MUST be gated per `user_agent_type`
when `ws->full_ipv6` is set: `AGENT_OPENCONNECT_V3` -> disabled (logged as "agent
known not to support them"); `AGENT_OPENCONNECT`, `AGENT_ANYCONNECT`,
`AGENT_OPENCONNECT_CLAVISTER`, `AGENT_ANYLINK` -> enabled; `AGENT_UNKNOWN` (and any
future/unhandled value via `default`) -> enabled, with a `LOG_NOTICE` warning
("Enabling IPv6 routes/DNS although the agent is unknown").
- Strength: MUST
- Source: `src/worker-vpn.c` lines 2265-2289
- Acceptance: with IPv6 enabled server-side, a session whose `User-Agent` matches
none of the known strings still receives IPv6 routes/DNS, and the server log
contains the `LOG_NOTICE` line.
- `[REVIEW]`: the `default:` branch silently enabling IPv6 for unknown agents is a
permissive default; flagged for maintainer awareness but not classified as a
defect since failing to advertise IPv6 to a capable-but-unrecognized client would
be a worse failure mode (broken connectivity vs. unused advertised routes).
## AC-CLIENT-CONN-004
**Requirement**: When `ws->full_ipv6` is set and `req->is_ios` is set (set only
when the `User-Agent` matches `"Cisco AnyConnect VPN Agent for Apple..."`, see
AC-CLIENT-COMPAT-001) and the session has no configured routes or
`ws->default_route == 0`, the worker MUST additionally send
`X-CSTP-Split-Include-IP6: 2000::/3`.
- Strength: MUST
- Source: `src/worker-vpn.c` lines 2351-2357 (comment: "Anyconnect on IOS requires
this route in order to use IPv6")
- Acceptance: an iOS AnyConnect session (`User-Agent` starting with
`"Cisco AnyConnect VPN Agent for Apple"`) with IPv6 enabled and no
split-tunnel/default route receives `X-CSTP-Split-Include-IP6: 2000::/3` in the
config-auth response headers.
- `[CANDIDATE for unified.md]`: classify as **EXTENSION** — a platform-specific
(iOS) workaround with no analogue in `openconnect.md`.
---
# COMPAT — `/svc` endpoint (Cisco IP-Phone clients)
## AC-CLIENT-COMPAT-010
**Requirement**: The `/svc` URL (GET and POST) MUST be registered and reachable
regardless of `ANYCONNECT_CLIENT_COMPAT` build configuration (it is the one entry
in `known_urls[]` outside the `#ifdef ANYCONNECT_CLIENT_COMPAT` block), but both
`get_svc_handler` and `post_svc_handler` MUST reject the request unless
**both** of the following hold: (a) `WSRCONFIG(ws)->cisco_svc_client_compat` is
true, AND (b) `ws->req.user_agent_type == AGENT_SVC_IPPHONE`.
- Strength: MUST
- Source: `src/worker-http.c` line 81 (`LL("/svc", get_svc_handler, post_svc_handler)`);
`src/worker-svc.c` lines 40-44 and 216-220
- Acceptance (negative): a request to `/svc` (GET or POST) when
`cisco-svc-client-compat = false` (the documented default per
`doc/README-cisco-svc.md`), or when `cisco-svc-client-compat = true` but the
`User-Agent` is not `"Cisco SVC IPPhone Client..."`, MUST be rejected (the
handler does not proceed to the IP-Phone-specific logic).
- `[REVIEW]`: confirm the exact rejection response (404 vs. falling through to
default handler behavior) — `get_svc_handler`/`post_svc_handler` early-return,
but the precise HTTP status returned to a non-IP-Phone client hitting `/svc`
should be verified against `response_404`/`response_401` call sites for a
complete negative-test specification.
- Links: REQ-WORKER-AUTH-004 (camouflage), `[CANDIDATE for unified.md]`: classify
as **EXTENSION** (additional entry point beyond OC-PROTO-CONN-003/004's
`/` and `/CSCOSSLC/tunnel`).
## AC-CLIENT-AUTH-010
**Requirement**: `get_svc_handler` (the initial GET to `/svc`) MUST respond with
HTTP 200 and a `Set-Cookie: webvpnlogin=1; secure` header, with no body, to signal
the IP-Phone client to proceed with a credential POST. It MUST also log a warning
(but still proceed) if `cisco_svc_client_compat` is false or the `User-Agent` does
not match `AGENT_SVC_IPPHONE` — `[AMBIGUOUS]` this appears to overlap with
AC-CLIENT-COMPAT-010's hard gate; the relationship between the warning-and-proceed
path and the reject path needs clarification from source re-inspection.
- Strength: MUST
- Status: `[REVIEW]` — possible duplication/overlap between the gating check (which
appears twice, at lines 40-44 and again implicitly) and a separate
warn-but-continue check; needs a side-by-side reading of `get_svc_handler` and
`post_svc_handler` to resolve which check is authoritative.
- Source: `src/worker-svc.c` (`get_svc_handler`)
- Acceptance: a `Cisco SVC IPPhone Client` issuing `GET /svc` with
`cisco-svc-client-compat = true` receives `HTTP/1.1 200` with header
`Set-Cookie: webvpnlogin=1; secure` and an empty body.
- Links: AC-CLIENT-COMPAT-010
## AC-CLIENT-AUTH-011
**Requirement**: `post_svc_handler` MUST parse `username` and `password` fields
from the POST body (via `parse_reply`), then perform a full
`SEC_AUTH_INIT` / `SEC_AUTH_CONT` exchange with sec-mod via `client_auth()`
(populating `SecAuthInitMsg`/`SecAuthContMsg` with `hmac`, `remote_ip`,
`orig_remote_ip`, `our_ip`, `session_start_time`, `user_agent`, `device_type`,
`device_platform`), identical in IPC shape to the worker-driven password
authentication path used by `post_auth_handler`.
- Strength: MUST
- Source: `src/worker-svc.c` (`post_svc_handler`, `client_auth`)
- Acceptance: posting valid `username`/`password` form fields to `/svc` as a
`Cisco SVC IPPhone Client` results in the same `SEC_AUTH_INIT`/`SEC_AUTH_CONT`
IPC traffic to sec-mod as an equivalent password login via `/auth`.
- Links: REQ-IPC-015/016 (per internal/ipc.md), REQ-AUTH-AUTH-002,
OC-PROTO-AUTH-004 (password auth-request/auth-reply flow)
- `[CANDIDATE for unified.md]`: classify as **MAJORITY** for the underlying
auth-exchange shape (shared with OC-PROTO-AUTH-004), **EXTENSION** for the
`/svc`-specific transport (form POST instead of config-auth XML).
## AC-CLIENT-AUTH-012
**Requirement**: On successful authentication, `post_svc_handler` MUST set
`ws->auth_state = S_AUTH_COOKIE` and respond HTTP 200 with
`Set-Cookie: webvpn=<base64 cookie>; secure`. On authentication failure, it MUST
respond `HTTP/1.1 401 Authentication failed`, then call `cstp_fatal_close` and
`exit_worker` — i.e. the worker process MUST terminate on a failed `/svc` login,
unlike the `/auth` path which may permit further auth rounds.
- Strength: MUST
- Source: `src/worker-svc.c` (`post_svc_handler`)
- Acceptance (positive): successful `/svc` POST with valid credentials yields
`HTTP/1.1 200` + `Set-Cookie: webvpn=<cookie>; secure`, and the worker remains
alive in `S_AUTH_COOKIE` state for a subsequent `CONNECT` (or `/svc`
cookie-validated request, see AC-CLIENT-AUTH-013).
- Acceptance (negative): `/svc` POST with invalid credentials yields
`HTTP/1.1 401 Authentication failed`, and the worker process exits — a
subsequent request on the same TCP connection MUST fail (connection closed).
- Links: OC-PROTO-AUTH-005 (401 on auth failure — note OC-PROTO-AUTH-005 already
flags ocserv's camouflage-405 behavior as a DIVERGENT/EXTENSION candidate;
`/svc`'s unconditional-401-then-exit is a *third* variant worth folding into
that same unified entry), REQ-WORKER-AUTH-004 (camouflage 405 gate — note
`cookie_authenticate_or_exit`'s camouflage branch is NOT shown to apply inside
`post_svc_handler`'s own 401 path; `[REVIEW]` whether `/svc` login failures are
exempt from camouflage masking by design or by oversight).
- `[CANDIDATE for unified.md]`: the immediate `exit_worker()` on `/svc` auth
failure (vs. allowing retries on `/auth`) should be evaluated as a
**negative-test requirement**: confirm no information beyond "401
Authentication failed" is leaked, and that the worker exit does not leave
sec-mod-side session/ban-score state inconsistent (cross-ref REQ-MAIN-SEC-005,
REQ-AUTH-AUTH-* ban accounting).
## AC-CLIENT-AUTH-013 `[UNDOCUMENTED]`
**Requirement**: After a successful `/svc` login (`S_AUTH_COOKIE`), the IP-Phone
client is expected to use the returned `webvpn` cookie for subsequent requests —
the exact follow-on flow (does the phone then issue `CONNECT /CSCOSSLC/tunnel`
like a standard OpenConnect client, or does it use `/svc` again, or a different
endpoint?) is not stated in `doc/README-cisco-svc.md` and was not traced in
`worker-svc.c` beyond `post_svc_handler`.
- Strength: SHOULD (informational gap, not a normative gap in ocserv's own
behavior — ocserv's obligations end at issuing the cookie)
- Status: `[UNDOCUMENTED]`
- Source: `doc/README-cisco-svc.md` (56 lines, does not describe post-login flow)
- `[CANDIDATE for unified.md]`: if the IP-Phone reuses `CONNECT /CSCOSSLC/tunnel`
with the `webvpn` cookie, AC-CLIENT-AUTH-013 collapses into OC-PROTO-CONN-004
(classify **MAJORITY**); if it uses a distinct mechanism, this needs its own
unified entry. Recommend tracing an actual IP-Phone packet capture or asking
maintainers before reconciliation.
---
# COMPAT — TLS cipher / transport requirements for IP-Phones
## AC-CLIENT-SEC-010
**Requirement**: Per `doc/README-cisco-svc.md`, when serving Cisco IP-Phone
(7800/8800/8900/9900 Enterprise-firmware) clients, the administrator MUST
configure `tls-priorities` to force AES256-CBC or AES128-CBC, because these
phones fail with an "old session cipher not returned" error if the negotiated
cipher is outside this set.
- Strength: MUST (documented operational requirement; not enforced by ocserv code
— it is a configuration constraint on the administrator)
- Source: `doc/README-cisco-svc.md`
- Status: this is a **documentation-only** requirement — no corresponding code
enforces or validates it. `[CANDIDATE for unified.md]`: classify as
**EXTENSION**, and consider whether ocserv should emit a configuration-time
warning when `cisco-svc-client-compat = true` is set without a compatible
`tls-priorities` override (a `[CANDIDATE]` for a future config-validation
requirement, not in scope for this elicitation pass).
## AC-CLIENT-SEC-011
**Requirement**: Per `doc/README-cisco-svc.md`, DTLS for these IP-Phones requires
the server to listen on **port 443** for UDP, regardless of the configured HTTPS
TCP port.
- Strength: MUST (documented operational requirement)
- Source: `doc/README-cisco-svc.md`
- Status: documentation-only, as AC-CLIENT-SEC-010.
- `[CANDIDATE for unified.md]`: classify as **EXTENSION**; cross-ref
REQ-WORKER-NET-003 (DTLS enablement conditions) — confirm whether ocserv's UDP
listener port is independently configurable from the TCP port, or whether this
is purely an administrator-side firewall/NAT requirement.
---
# AUTH — `cisco_client_compat` cookie-authentication relaxation
## AC-CLIENT-AUTH-020
**Requirement**: In `auth_cookie()`, when the selected auth type includes
`AUTH_TYPE_CERTIFICATE` AND `WSRCONFIG(ws)->cisco_client_compat == 0` (the
default), the worker MUST require `ws->cert_auth_ok != 0` and MUST call
`get_cert_info(ws)` before sending `AUTH_COOKIE_REQ` to main, failing the cookie
auth (`return -1`) if either check fails. When `cisco_client_compat == 1`, this
entire certificate-presence check is **skipped** — `AUTH_COOKIE_REQ` is sent
without re-validating `cert_auth_ok`/`get_cert_info`, even if certificate auth was
the selected method.
- Strength: MUST (both branches are unconditional given the flag's value)
- Source: `src/worker-auth.c` lines 1066-1085
- Acceptance (negative, default config): with `cisco_client_compat = false`
(default) and certificate-based auth selected, a cookie-auth attempt where
`ws->cert_auth_ok == 0` MUST fail (`auth_cookie` returns -1, leading to the
401/405/503 handling in `cookie_authenticate_or_exit`, REQ-WORKER-AUTH-004).
- Acceptance (positive, compat config): with `cisco_client_compat = true` and
certificate-based auth selected, a cookie-auth attempt proceeds to
`AUTH_COOKIE_REQ` even when `ws->cert_auth_ok == 0` — relying entirely on
sec-mod's independent verification (REQ-AUTH-AUTH-005) for the actual
security decision.
- `[SEC-RISK / REVIEW]`: this flag removes a worker-side defense-in-depth check
(cross-ref REQ-AUTH-AUTH-008, which already notes sec-mod independently
re-derives cert identity). Because sec-mod's check is authoritative, this is
*not* a privilege-boundary violation per se, but it widens the set of requests
that reach sec-mod without a worker-side pre-filter. Flag explicitly for
maintainer review per AGENTS.md's "Human-judgment required" list (cookie/SID
handling changes) if this flag is ever touched.
- Links: REQ-AUTH-AUTH-007, REQ-AUTH-AUTH-008, REQ-AUTH-AUTH-005,
`[CANDIDATE for unified.md]`: classify as **DIVERGENT** (config-gated relaxation
of a check present in the default/MAJORITY path).
---
# EXT — `ANYCONNECT_CLIENT_COMPAT` build-time extensions
## AC-CLIENT-EXT-030
**Requirement**: When built `WITH ANYCONNECT_CLIENT_COMPAT`, the `known_urls[]`
table MUST additionally register: `/profiles/*` (directory handler ->
`get_config_handler`), `/VPNManifest.xml`, `/1/index.html`, `/1/Linux`,
`/1/Linux_64`, `/1/Windows`, `/1/Windows_ARM64`, `/1/Darwin_i386`,
`/1/binaries/vpndownloader.sh`, `/1/VPNManifest.xml`,
`/1/binaries/update.txt`, `/+CSCOT+/translation-table`,
`/+CSCOT+/oem-customization`, and `/logout`.
- Strength: MUST (build-conditional, but unconditional within that build)
- Source: `src/worker-http.c` lines 64-80
- Acceptance: a build with `ANYCONNECT_CLIENT_COMPAT` defined serves a non-404
response for `GET /profiles/<file>` (subject to AC-CLIENT-EXT-031) and for each
literal path above; a build without it returns 404 (the default
GET-URL-not-found handling) for all of these paths.
- `[CANDIDATE for unified.md]`: classify as **EXTENSION**. Several of these paths
(`/1/binaries/vpndownloader.sh`, `/1/Linux*`, `/1/Windows*`, `/1/Darwin_i386`)
appear to mimic Cisco ASA's webvpn client-download portal; `[UNDOCUMENTED]`
whether ocserv actually serves meaningful content for these vs. stub/empty
responses (`get_empty_handler` for most, `get_dl_handler`/`get_string_handler`
for a few) — worth a one-line note in `doc/README-cisco-svc.md` or
`ocserv.8.md` if administrators are expected to populate any of these.
## AC-CLIENT-EXT-031
**Requirement**: `get_config_handler` (registered for `/profiles/*` under
`ANYCONNECT_CLIENT_COMPAT`) MUST: (1) require a valid session cookie via
`cookie_authenticate_or_exit`; (2) respond 404 if
`ws->user_config->xml_config_file` is unset, or if `stat()` on that path fails;
(3) otherwise stream the file with `Content-Type: text/xml` and
`Content-Length` set to the file's size via `cstp_send_file`.
- Strength: MUST
- Source: `src/worker-http-handlers.c` lines 94-130
- Acceptance (positive): with `xml_config_file` configured for the authenticated
user and the file present, `GET /profiles/<anything>` (cookie-authenticated)
returns `200` with `Content-Type: text/xml` and the file's bytes.
- Acceptance (negative): without a valid cookie, `cookie_authenticate_or_exit`
enforces the same 401/405/503 rules as REQ-WORKER-AUTH-004; with a valid cookie
but no `xml_config_file` configured, or a configured-but-missing file, the
response is 404.
- `[REVIEW]`: `ws->req.url` (the requested sub-path under `/profiles/`) does not
appear to be used to select *which* file to serve — `get_config_handler` always
serves `ws->user_config->xml_config_file` regardless of the URL suffix. This
means any path under `/profiles/` (e.g. `/profiles/../../etc/passwd`) maps to
the same single configured file — i.e. **the URL suffix is not used for file
selection, so path traversal via the URL is not applicable here**. Confirmed by
reading `get_config_handler`'s body (no `ws->req.url` reference after the
initial debug log). Recorded as a negative-requirement *confirmation*, not a
gap: `[CANDIDATE for unified.md negative requirements]` — "the `/profiles/<x>`
URL suffix MUST NOT influence which file is served."
- Links: REQ-WORKER-AUTH-004, AC-CLIENT-AUTH-021 (webvpnc cookie below, which
advertises the `/profiles/%s` URI to the client)
## AC-CLIENT-AUTH-021
**Requirement**: When built `WITH ANYCONNECT_CLIENT_COMPAT` and
`WSRCONFIG(ws)->xml_config_file` is set, the successful-authentication response
(`post_common_handler`) MUST:
(a) append an `OC_SUCCESS_MSG_FOOT_PROFILE` XML fragment (a
`<config type="private"><vpn-profile-manifest><vpn rev="1.0"><file
type="profile" service-type="user"><uri>/profiles/%s</uri><hash
type="sha1">%s</hash></file></vpn></vpn-profile-manifest></config>`,
with `%s` = `xml_config_file` name and `xml_config_hash`) instead of the
plain `OC_SUCCESS_MSG_FOOT`; and
(b) set an additional `Set-Cookie: webvpnc=bu:/&p:t&iu:1/&sh:<cert_hash>&...
&fu:profiles%2F<xml_config_file>&fh:<xml_config_hash>; path=/; Secure;
HttpOnly` header (or, if `xml_config_file` is unset, the shorter
`webvpnc=bu:/&p:t&iu:1/&sh:<cert_hash>; path=/; Secure; HttpOnly` form).
Both forms are sent in addition to, and after, the unconditional
`Set-Cookie: webvpn=<cookie>; Secure; HttpOnly` and the `webvpnc=` *clearing*
cookie (`expires=Thu, 01 Jan 1970...`) that precede them.
- Strength: MUST (build-conditional)
- Source: `src/worker-auth.c` lines 1132-1151 (XML foot), 1219-1249 (cookies)
- Acceptance: under `ANYCONNECT_CLIENT_COMPAT` with `xml_config_file` configured,
a successful auth response contains, in order: a `webvpn=` cookie, a clearing
`webvpnc=; expires=...1970...` cookie, OWASP headers, then a non-expiring
`webvpnc=bu:/&p:t&iu:1/&sh:<hash>&...&fu:profiles%2F<file>&fh:<hash>` cookie, then
the XML body with the `<vpn-profile-manifest>` fragment referencing
`/profiles/<xml_config_file>`.
- `[CANDIDATE for unified.md]`: classify as **EXTENSION** (config-profile delivery
has no analogue in `openconnect.md`). `[REVIEW]`: the two `webvpnc=` `Set-Cookie`
headers (one clearing, one setting) sent in the same response is unusual;
confirm this ordering is intentional (clear-then-reset pattern, perhaps for
clients that cache the first `Set-Cookie` for a given name) rather than a
leftover from incremental development.
- Links: AC-CLIENT-EXT-031 (the `/profiles/<file>` URI this cookie/XML advertises)
---
# Completeness notes
- **`src/worker-svc.c` `client_auth()` internals** (HMAC construction, exact field
population of `SecAuthInitMsg`/`SecAuthContMsg`): covered at the level of "same
IPC shape as `/auth`" (AC-CLIENT-AUTH-011); a field-by-field diff against
`post_auth_handler`'s equivalent call was not performed in this pass.
`[CANDIDATE]` for a follow-up pass if sec-mod-side AC-CLIENT requirements are
ever needed (currently sec-mod.md treats all `SEC_AUTH_INIT`/`CONT` sources
uniformly).
- **`get_dl_handler` / `get_string_handler` / `get_empty_handler`** content:
not inspected; assumed to serve static/stub content per AC-CLIENT-EXT-030's
`[UNDOCUMENTED]` note.
- **DTLS cipher negotiation table (`ciphersuites[]` in `worker-http.c`)**: this
table implements the `X-DTLS-CipherSuite` / `X-DTLS12-CipherSuite` HTTP-header
based DTLS cipher negotiation referenced generically in `openconnect.md`
(OC-PROTO-CTRL-* rekey/DTLS area) — it is **not** AnyConnect-specific (both
OpenConnect and AnyConnect clients use this header-based negotiation), so it is
intentionally excluded from this document and left to `openconnect.md` /
`unified.md` to cover under CTRL or CONN.
- **`AGENT_OPENCONNECT_CLAVISTER` and `AGENT_ANYLINK`**: recognized for
classification (AC-CLIENT-COMPAT-001) and IPv6 enablement
(AC-CLIENT-CONN-003), but no further Clavister- or AnyLink-specific branches
were found in the files read for this pass. `[UNDOCUMENTED]` whether these
agent types have any other special-cased behavior elsewhere in the worker
(not searched exhaustively).
@@ -0,0 +1,475 @@
---
title: OpenConnect protocol (draft) requirement extraction
generator: requirements-elicitation
process: n/a
id-prefix: OC-PROTO
sources:
- ~/projects/openconnect/protocol/draft-openconnect.xml (draft-mavrogiannopoulos-openconnect-04/05, "The OpenConnect VPN Protocol Version 1.2", 2023)
---
# OpenConnect Protocol — Extracted Requirements
This is a **working extraction**, not a normative spec for ocserv. It applies
`requirements-elicitation.md` to the OpenConnect protocol draft
(`draft-mavrogiannopoulos-openconnect-04`/`-05`), which describes the
**generic, client-agnostic** wire protocol that both the `openconnect` client
and `ocserv` implement, and which AnyConnect is "believed to be compatible
with."
IDs here are `OC-PROTO-<CAT>-<NNN>` using the `protocol/unified.md` category
tags (`CONN`, `AUTH`, `DATA`, `CTRL`, `SEC`, `EXT`, `COMPAT`) so that
`requirements-reconciliation.md` can align them directly against
`AC-CLIENT-*` (observed AnyConnect behavior) and `OCSERV` (current
`internal/worker.md` + code) entries. These IDs are **not** cited from
`internal/*` — only `protocol/unified.md` may reference them.
Per Phase 1 of the elicitation protocol: **in scope** is the wire protocol
(TLS/HTTP/CONNECT handshake sequence, CSTP/DTLS framing, rekey/DPD/keepalive,
compression). **Out of scope**: the specific XML schema validation rules
beyond the DTD given, and any ocserv-side IPC — those belong to `internal/*`.
---
## CONN — Tunnel establishment
### OC-PROTO-CONN-001
**Requirement:** The client SHOULD negotiate TLS 1.2 or later for the initial
HTTPS connection to the server's well-known port (conventionally 443).
**Strength:** SHOULD
**Source:** §"VPN tunnel establishment" (tunnel-establishment)
**Notes:** "SHOULD" not "MUST" — a server MAY be configured to accept earlier
TLS versions for legacy clients. `[CANDIDATE for unified.md: ocserv's
`min-tls-version` / `default-priorities` config interacts with this — check
default `priorities` string for the effective floor.]`
### OC-PROTO-CONN-002
**Requirement:** The client SHOULD include the Server Name Indication (SNI,
RFC 6066) extension in its initial TLS ClientHello, carrying the DNS name of
the server it intends to reach.
**Strength:** SHOULD
**Source:** §"Tunnel initiation" (tunnel-initiation)
**Notes:** Maps directly to `internal/worker.md` REQ-WORKER-NET-001
(`hello_hook_func` vhost selection by SNI). `[CANDIDATE: unified.md should
note ocserv's behavior for a client that omits SNI entirely — falls back to
default vhost, per REQ-WORKER-NET-001.]`
### OC-PROTO-CONN-003
**Requirement:** After the TLS session is established, the client MUST send
an HTTP POST to `/` with a `config-auth` XML body of `type="init"` and
`Content-Type: text/xml`, regardless of which authentication method(s) it
supports.
**Strength:** MUST
**Source:** §"Tunnel initiation" (tunnel-initiation), example XML
**Notes:** This is the protocol's single entry point — all authentication
flows (password, certificate, SPNEGO) begin from the server's response to
this POST.
### OC-PROTO-CONN-004
**Requirement:** Upon receipt of a `config-auth` reply of `type="complete"`
(with `<auth id="success">`), the client MUST issue an HTTP `CONNECT
/CSCOSSLC/tunnel HTTP/1.1` request over the same TLS connection to initiate
the VPN tunnel.
**Strength:** MUST
**Source:** §"Tunnel and channels establishment" (params-exchange)
**Notes:** The literal path `/CSCOSSLC/tunnel` is a fixed, Cisco-derived
string in the *generic* protocol — not an AnyConnect-only extension. Maps to
`internal/worker.md` REQ-WORKER-NET-002 (`connect_handler` accepts only this
path, plus a documented Clavister-client variant).
### OC-PROTO-CONN-005
**Requirement:** The CONNECT request MUST advertise client capabilities via
HTTP headers: `X-CSTP-Address-Type` (comma-separated `IPv4`/`IPv6`/both),
`X-CSTP-Base-MTU`, and optionally `X-CSTP-Accept-Encoding` and `User-Agent`.
**Strength:** MUST (Address-Type, Base-MTU) / MAY (Accept-Encoding,
User-Agent)
**Source:** §"Client capabilities" (capabilities)
### OC-PROTO-CONN-006
**Requirement:** After a successful CONNECT, the server's response MUST be
the last HTTP message on this connection — the TCP connection then becomes
the CSTP channel, transporting framed IP packets (§"primary-channel-protocol")
for the remainder of the session.
**Strength:** MUST
**Source:** §"The primary CSTP channel - TCP" (primary-channel)
**Notes:** **[Missing conditional branch — Phase 3]** The draft does not
specify what a client should do if the server sends *additional* HTTP
headers/data after the CONNECT response but before the first CSTP frame, nor
what a server should do if it receives non-CSTP-framed bytes immediately
after sending its CONNECT response. `[CANDIDATE: unified.md should record
ocserv's actual behavior here as `OCSERV`-side, likely DIVERGENT or
UNIVERSAL-by-silence.]`
### OC-PROTO-CONN-007
**Requirement:** The server's CONNECT response configuration headers
(`X-CSTP-Address`, `X-CSTP-Netmask`, `X-CSTP-Address-IP6`, `X-CSTP-DNS`,
`X-CSTP-Default-Domain`, `X-CSTP-Split-DNS`, `X-CSTP-Split-Include`,
`X-CSTP-Split-Exclude`, `X-CSTP-Base-MTU`, `X-CSTP-DynDNS`,
`X-CSTP-Content-Encoding`, `X-DTLS-Content-Encoding`) constitute the client's
tunnel networking configuration; absence of any `X-CSTP-Split-Include` header
means the client MUST route its default route through the VPN.
**Strength:** MUST (default-route inference) / the headers themselves are
each individually optional depending on what's been requested/negotiated
**Source:** §"Server response and tunnel configuration" (server-response)
**Notes:** "X-CSTP-Address-IP6 ... prefix length is RECOMMENDED to be set to
127-bits" (RFC 6164) and "X-CSTP-Netmask ... RECOMMENDED the server address to
be the first in defined network" are both SHOULD-strength conventions, not
MUST — `[CANDIDATE: check whether ocserv's `ip-lease.c` (REQ-MAIN-NET-001)
follows the /127 and "server address first" recommendations for IPv6/IPv4
respectively.]`
---
## AUTH — Authentication
### OC-PROTO-AUTH-001
**Requirement:** The server MUST always be authenticated to the client via
its X.509 certificate during the TLS handshake; the server's identity SHOULD
be carried in the certificate's `SubjectAlternativeName` (type `dNSName`).
**Strength:** MUST (server cert auth) / SHOULD (SAN dNSName placement)
**Source:** §"Server authentication" (server-authentication)
### OC-PROTO-AUTH-002
**Requirement:** The protocol allows client authentication via password,
X.509 client certificate, or HTTP SPNEGO (GSSAPI/Kerberos) — singly or in
combination ("or combinations of them").
**Strength:** MAY (the protocol permits all three; deployment-specific which
are enabled)
**Source:** §"Client authentication" (client-authentication)
**Notes:** **[Open-ended enumeration — Phase 3, resolved]** The enumeration
is closed: exactly {password, certificate, SPNEGO} and combinations thereof.
Any ocserv auth backend (PAM, RADIUS, OIDC, plain, GSSAPI) is an
*implementation* of "password" or "SPNEGO" from the protocol's perspective —
`internal/sec-mod.md`'s `auth_mod_st` vtable is the OCSERV-side
implementation point, not a protocol extension. `[CANDIDATE: unified.md
should classify ocserv's OIDC auth as EXTENSION if it doesn't map cleanly
onto "password" — it likely uses a browser redirect flow not described
here.]`
### OC-PROTO-AUTH-003
**Requirement:** It is RECOMMENDED that clients complete authentication
within a single TLS session and rely on TLS session resumption for
reconnection, because splitting username/password across separate TLS/HTTP
connections (a legacy pattern some clients use) prevents the server from
binding the TLS channel to the VPN session (RFC 5056 channel binding).
**Strength:** SHOULD NOT (split-connection auth) / SHOULD (single-session +
resumption)
**Source:** §"Client authentication" (client-authentication), penultimate
paragraph
**Notes:** This directly motivates `internal/ipc.md` REQ-IPC-010
(`sec_auth_init_hmac` anti-replay) and `internal/worker.md`
REQ-WORKER-SEC-003 (DTLS-PSK derived from the CSTP TLS session) — ocserv's
design assumes the RECOMMENDED single-session model and adds its own binding
(HMAC over `remote_ip`/`our_ip`/`session_start_time`) as defense for clients
that still split connections. `[CANDIDATE: unified.md SEC entry — does
ocserv's HMAC scheme provide equivalent channel binding to RFC 5056 for the
legacy split-connection case, or only mitigate replay?]`
### OC-PROTO-AUTH-004
**Requirement:** During password authentication, the server presents one or
more `config-auth` `type="auth-request"` forms (each potentially requesting
multiple fields, e.g. username+password, or a second-factor token in a
follow-up form); the client responds via HTTP POST to the form's `action`
URL with a `type="auth-reply"` body. The server MAY repeat this exchange
(HTTP 200 + new `auth-request`) an arbitrary number of times before either
`type="complete"` (success) or HTTP 401 (failure).
**Strength:** MUST (the request/reply/complete shapes) / MAY (number of
rounds)
**Source:** §"Tunnel authentication using passwords"
(authentication_pass)
**Notes:** Maps to `internal/ipc.md` REQ-IPC-015/016 (`SEC_AUTH_CONT`
multi-round) and `internal/authentication.md` REQ-AUTH-AUTH-002 (`auth_msg`
always called when continuing).
### OC-PROTO-AUTH-005
**Requirement:** If client authentication fails (password or SPNEGO), the
server MUST respond with HTTP 401 Unauthorized.
**Strength:** MUST
**Source:** §"Tunnel authentication using passwords" (authentication_pass),
§"Tunnel authentication using SPNEGO" (authentication_gssapi)
**Notes:** **[Missing negative requirement — Phase 3, resolved by spec
itself]** This *is* the negative requirement; pairs with OC-PROTO-AUTH-004's
success case. `[CANDIDATE: unified.md should check whether ocserv ever
returns a non-401 status (e.g. 403, 503) on auth failure for any auth
backend — `internal/worker.md` REQ-WORKER-AUTH-004 shows camouflage mode
deliberately returns 405 instead, which would be a documented DIVERGENT/
EXTENSION case.]`
### OC-PROTO-AUTH-006
**Requirement:** It is RECOMMENDED that clients pad authentication XML
bodies containing username/password to a multiple of 64 bytes (via an
`X-Pad` HTTP header with arbitrary printable data) to reduce the information
a passive eavesdropper gains from observing encrypted message lengths.
**Strength:** SHOULD
**Source:** §"Tunnel authentication using passwords" (authentication_pass),
final paragraph
**Notes:** Client-side mitigation; no corresponding server requirement is
stated. `[CANDIDATE: unified.md — does ocserv's worker do anything with an
`X-Pad` header if present (e.g. ignore it safely), or could an oversized
`X-Pad` header interact badly with HTTP header size limits
(`internal/worker.md` "Completeness notes" re: `worker-http.c`)?]`
### OC-PROTO-AUTH-007
**Requirement:** For certificate authentication, the TLS server MUST request
a client certificate during the handshake; because under TLS 1.2 client
certificates are sent unencrypted, certificates used with this protocol
SHOULD NOT contain identifying information beyond a username or pseudonymous
identifier, RECOMMENDED to be placed in the certificate DN's `UID` attribute
(OID 0.9.2342.19200300.100.1.1).
**Strength:** MUST (request cert) / SHOULD NOT (extraneous identifying info
in cert) / RECOMMENDED (UID OID placement)
**Source:** §"Tunnel authentication using certificates" (authentication_cert)
**Notes:** Maps to `internal/authentication.md` REQ-AUTH-AUTH-008/009
(`get_cert_username`, `cert-user-oid` config) and
REQ-AUTH-AUTH-005 (defense-in-depth re-verification).
`cert-user-oid` is configurable in ocserv (not hardcoded to the RFC 4519 UID
OID) — `[CANDIDATE: unified.md EXTENSION — ocserv generalizes "RECOMMENDED
UID OID" into a configurable OID, including the special value
`SAN(rfc822name)` not mentioned in the draft at all.]`
### OC-PROTO-AUTH-008
**Requirement:** On successful certificate validation (after the `init`
POST), the server replies HTTP 200 with `config-auth type="complete"`,
identical in shape to the password-success case; the client then proceeds
directly to CONNECT (OC-PROTO-CONN-004) — there is no certificate-specific
`auth-request`/`auth-reply` round trip.
**Strength:** MUST
**Source:** §"Tunnel authentication using certificates" (authentication_cert)
### OC-PROTO-AUTH-009
**Requirement:** A client supporting HTTP SPNEGO (RFC 4559 / GSSAPI, RFC
2743) MUST signal this in its `init` POST via the header
`X-Support-HTTP-Auth: true`. The server then responds HTTP 401 and SPNEGO
negotiation proceeds per RFC 4559. The server MAY additionally send
`X-Support-HTTP-Auth: fallback` to indicate alternative methods (e.g.
password) are available if SPNEGO fails — a client receiving this header
after a SPNEGO failure SHOULD retry without `X-Support-HTTP-Auth: true`.
**Strength:** MUST (signaling header) / MAY (fallback header) / SHOULD
(client retry behavior)
**Source:** §"Tunnel authentication using SPNEGO" (authentication_gssapi)
**Notes:** **[Missing conditional branch — Phase 3, resolved]** The
"fallback" branch is explicitly the else-case for "SPNEGO advertised but
failed." Maps to `internal/sec-mod.md` GSSAPI auth module (out of detailed
scope per that doc's completeness notes) — `[CANDIDATE: unified.md COMPAT —
does ocserv's GSSAPI module emit `X-Support-HTTP-Auth: fallback` correctly
when GSSAPI auth fails and another module is configured?]`
---
## DATA — CSTP and DTLS channel framing
### OC-PROTO-DATA-001
**Requirement:** Every CSTP channel packet MUST begin with an 8-byte header:
bytes 0-3 fixed to `0x53 0x54 0x46 0x01` ("STF" + 0x01), bytes 4-5 the
big-endian length of the following payload, byte 6 the payload type (see
OC-PROTO-DATA-002), byte 7 fixed `0x00`. The entire 8-byte-header-plus-payload
unit MUST be carried inside a single TLS record.
**Strength:** MUST
**Source:** §"The CSTP Channel Protocol" (primary-channel-protocol), table
`cstp_table`
**Notes:** **[Vague/undefined term — Phase 3]** "fixed to 0x53/0x54/0x46/0x01"
— a receiver encountering a different magic should presumably treat the
connection as protocol-violating, but the draft doesn't say so explicitly.
`[CANDIDATE: unified.md negative requirement — OCSERV's `worker-vpn.c`
`parse_cstp_data` behavior on a bad magic: does it terminate the connection,
log, or silently ignore?]`
### OC-PROTO-DATA-002
**Requirement:** The CSTP/DTLS payload type byte MUST be one of: `0x00`
DATA (IPv4/IPv6 packet), `0x03` DPD-REQ, `0x04` DPD-RESP, `0x05` DISCONNECT
(followed by 1 reason byte), `0x07` KEEPALIVE (no payload), `0x08` COMPRESSED
DATA, `0x09` TERMINATE (server→client only, no payload). A receiver MUST be
able to process both `0x00` and `0x08` regardless of whether compression was
negotiated for *outbound* data (OC-PROTO-EXT-002).
**Strength:** MUST
**Source:** §"The CSTP Channel Protocol" (primary-channel-protocol), table
`packet_table`
**Notes:** **[Open-ended enumeration — Phase 3, resolved]** The set of
payload types is closed (7 values). **[Missing negative requirement —
Phase 3]** No explicit statement of receiver behavior for an *unrecognized*
payload type byte (e.g. `0x01`, `0x02`, `0x06`, or `>=0x0a`).
`[CANDIDATE: unified.md — record OCSERV's actual behavior for unknown CSTP
type bytes as either DIVERGENT (if it errors/disconnects) or an
UNDOCUMENTED gap if it's silently ignored; this is security-relevant as a
potential DoS or parser-confusion vector.]`
### OC-PROTO-DATA-003
**Requirement:** DISCONNECT packets (type `0x05`) MUST carry exactly one
reason byte: `0x70` LOCAL ERROR (session invalidated), `0x91` VPN RECONNECT
(session preserved, client intends immediate reconnect), `0xb0` USER
DISCONNECT (session invalidated), `0xd1` VPN PAUSE (session preserved).
Reason codes not in this set MUST be treated by the receiver as equivalent
to `0x91` (temporary disconnect, session preserved).
**Strength:** MUST
**Source:** §"The CSTP Channel Protocol" (primary-channel-protocol), table
`disconnect_reason_table`
**Notes:** Directly maps to `internal/ipc.md` REQ-IPC-033
(`server_disconnected``REASON_SERVER_DISCONNECT`) and
`internal/sec-mod.md` REQ-SECMOD-SESSION-003 (persistent-cookies +
discon_reason). `REASON_USER_DISCONNECT` in ocserv corresponds to `0xb0`;
`[CANDIDATE: unified.md — verify ocserv's internal `discon_reason` enum
(referenced in REQ-SECMOD-SESSION-003) maps 1:1 onto these 4 wire values plus
the "unknown→0x91" fallback rule, including the "session should be
invalidated" vs "preserved" distinction matching
`IS_CLIENT_ENTRY_EXPIRED`/`expire_client_entry` logic.]`
### OC-PROTO-DATA-004
**Requirement:** The DTLS channel packet format consists of a 1-byte header
(same type values as OC-PROTO-DATA-002, from `packet_table`) followed by
data, the whole encapsulated in a single DTLS record (RFC 6347).
**Strength:** MUST
**Source:** §"The DTLS Channel Protocol" (secondary-channel-protocol)
**Notes:** **[Unanchored comparative / underspecified — Phase 3]** The DTLS
header is 1 byte vs. CSTP's 8 bytes — the draft doesn't restate which of the
7 payload types are meaningful over DTLS (e.g. is DISCONNECT/TERMINATE valid
over DTLS, or only DATA/DPD/KEEPALIVE since DTLS is the "secondary,
optional" channel and CSTP remains the control channel?). `[CANDIDATE:
unified.md AMBIGUOUS — needs OCSERV source inspection
(`parse_dtls_data` in worker-vpn.c) to determine which type bytes the
implementation actually accepts on the DTLS channel.]`
---
## CTRL — Rekey, DPD, keepalive
### OC-PROTO-CTRL-001
**Requirement:** The server advertises the rekey method for each channel via
`X-CSTP-Rekey-Method`/`X-DTLS-Rekey-Method`, one of: `none` (no rekey until
2^48 DTLS records / 2^64 TLS records), `ssl` (periodic TLS/DTLS
rehandshake-or-rekey), or `new-tunnel` (periodic full session teardown +
client reconnect). When not `none`, `X-CSTP-Rekey-Time`/`X-DTLS-Rekey-Time`
(seconds) gives the period.
**Strength:** MUST (header semantics) / the chosen method is server policy
**Source:** §"The Channel Re-Key Protocol" (rekey-protocol)
**Notes:** **[Open-ended enumeration — Phase 3, resolved]** Exactly 3 rekey
methods. `[CANDIDATE: unified.md — ocserv's `rekey-method`/`rekey-time`
config options (check `doc/sample.config`) map onto which of these 3? Is
`new-tunnel` actually implemented, or is it client-only behavior ocserv
merely advertises?]`
### OC-PROTO-CTRL-002
**Requirement:** When the `ssl` rekey method is used under TLS/DTLS 1.2, both
peers MUST ensure either safe renegotiation (RFC 5746) is used, or that the
peer's identity (certificate) remains unchanged across the rekey — to prevent
a renegotiation-based identity-substitution attack.
**Strength:** MUST
**Source:** §"The Channel Re-Key Protocol" (rekey-protocol), final paragraph
**Notes:** This is a `SEC`-flavored requirement embedded in the CTRL section.
`[CANDIDATE: unified.md SEC — does GnuTLS's RFC 5746 support make this
automatic for ocserv (i.e. is this requirement satisfied "for free" by the
TLS library and thus UNIVERSAL/no-action), or does ocserv need explicit
identity-pinning logic across a TLS 1.2 rehandshake?]`
### OC-PROTO-CTRL-003
**Requirement:** Any peer receiving a DPD-REQ packet MUST respond with a
DPD-RESP packet carrying identical contents to the request (enabling its use
for Path MTU detection via arbitrary attached data); any peer receiving a
KEEPALIVE packet MUST respond with another KEEPALIVE packet. DPD timing is
advisory to clients but the response obligation is unconditional ("MUST
respond").
**Strength:** MUST
**Source:** §"The Keepalive and Dead Peer Detection Protocols"
(dead-peer-detection)
**Notes:** Server-advertised timing via `X-CSTP-DPD`, `X-CSTP-Keepalive`,
`X-DTLS-DPD`, `X-DTLS-Keepalive` (relative seconds, per-channel).
`[CANDIDATE: unified.md — verify OCSERV worker responds to DPD-REQ with
byte-identical DPD-RESP payload, not just an empty/fixed DPD-RESP — the
"suitable for Path MTU detection" property depends on this.]`
---
## SEC — Security considerations
### OC-PROTO-SEC-001
**Requirement:** Implementations MUST NOT enable payload compression
(`oc-lz4`, `lzs`) by default, because compression before encryption can leak
information about plaintext length/content (CRIME/BREACH-style attacks);
each side MAY still choose to compress individual packets using the
COMPRESSED DATA payload type if compression was explicitly negotiated, and
MUST be able to *receive* both compressed and uncompressed payloads
regardless of its own send-side choice.
**Strength:** MUST NOT (default-on) / MAY (opt-in compression) / MUST
(bidirectional decode capability)
**Source:** §"Compression" (compression), §security-analysis
**Notes:** This is the spec's one explicit `MUST NOT` outside the auth
sections — a strong signal for `protocol/unified.md`. `[CANDIDATE: unified.md
SEC — confirm ocserv's default config has compression disabled
(`doc/sample.config` `compression` option default), and that enabling it
requires an explicit administrator opt-in, not just client advertisement via
`X-CSTP-Accept-Encoding`.]`
### OC-PROTO-SEC-002
**Requirement:** All security considerations of the referenced TLS (RFC 8446)
and DTLS (RFC 6347) specifications apply to this protocol; additionally,
while payload contents are encrypted, payload *lengths* remain visible and
may in some scenarios reveal information about the transferred data, an
effect compression can exacerbate.
**Strength:** MUST (RFC 8446/6347 considerations apply) / informational
(length-leakage caveat)
**Source:** §"Security Considerations" (security-analysis)
**Notes:** This is a blanket incorporation-by-reference — `[CANDIDATE:
unified.md should not attempt to re-derive RFC 8446/6347 requirements
verbatim, but should reference them as the "RFC-TLS/RFC-DTLS" source
category mentioned in the document map, and focus reconciliation on the
OpenConnect-specific deltas in this document.]`
### OC-PROTO-SEC-003
**Requirement:** Because TLS 1.2 and earlier do not encrypt client/server
certificates during the handshake, certificates used with this protocol
SHOULD contain the minimum possible identifying information (reiterates
OC-PROTO-AUTH-007's SHOULD NOT for client certs, extended to server certs).
**Strength:** SHOULD
**Source:** §"Security Considerations" (security-analysis), final paragraph
---
## EXT — Extensibility / compression algorithms
### OC-PROTO-EXT-001
**Requirement:** The set of named compression algorithms for CSTP/DTLS
channels is `oc-lz4` (stateless LZ4) and `lzs` (stateless LZS/Stacker); both
MUST be stateless, specifically so that compression context is not shared
across packets from different sources.
**Strength:** MUST (statelessness) — the algorithm *names* are a closed
enumeration in this draft version
**Source:** §"Compression" (compression), table `compression_table`
**Notes:** **[Open-ended enumeration — Phase 3, resolved for this draft
version]** Exactly 2 algorithms named. `[CANDIDATE: unified.md EXTENSION —
does ocserv support both, one, or additional algorithms beyond this list
(e.g. zlib)? Check `WSRCONFIG(ws)->...` compression config and GnuTLS
capabilities.]`
### OC-PROTO-EXT-002
**Requirement:** `X-CSTP-Content-Encoding` / `X-DTLS-Content-Encoding`
response headers, when present, MUST be set to one of the algorithm names the
client offered via `X-CSTP-Accept-Encoding` / `X-DTLS-Accept-Encoding`
i.e. the server MUST NOT select a compression algorithm the client did not
advertise support for.
**Strength:** MUST NOT (select unadvertised algorithm)
**Source:** §"Server response and tunnel configuration" (server-response)
---
## Completeness notes
- **`example-session1.atxt`/`.uml`** (sibling files in the protocol repo, not
read in this pass): likely a worked example consistent with the
"Overview of the tunnel establishment" ASCII diagram already extracted
inline (§example-session) — `[CANDIDATE: skip; the inline diagram already
covers the same sequence as OC-PROTO-CONN-001..006.]`
- **DTD (`config-auth.dtd`, §dtd-decl)**: defines the XML schema for
`config-auth`/`auth`/`form`/`input`/`select`/`option` elements, including a
`select name="group_list"` element not otherwise discussed in the prose.
`[UNDOCUMENTED in the prose: candidate OC-PROTO-AUTH-* for group selection
via a `<select name="group_list">` form — this likely corresponds to
ocserv's `auth-group`/multi-group selection UI (`internal/worker.md`
`resolve_selected_group`/`append_group_idx` functions, referenced but not
detailed in worker.md). Should be elicited as its own requirement before
reconciliation, since `internal/worker.md` has direct code for this.]`
- **Versioning**: the draft is "Version 1.2" with no discussion of how a
client/server negotiate or detect protocol version, beyond the
`<version who="vpn">v5.01</version>` / `<version who="sg">0.1(1)</version>`
free-form strings in the XML, which appear to be client/server *software*
versions, not protocol versions. `[CANDIDATE: unified.md AMBIGUOUS — is
there any protocol-level version negotiation at all, or is compatibility
determined entirely by which optional headers/features are present
("feature detection" rather than version negotiation)?]`
File diff suppressed because it is too large Load Diff