Files
ocserv/doc/requirements/internal/authentication.md
T
DmitriiandNikos Mavrogiannopoulos df086188d7 radius: send the session ID as Acct-Session-Id in the Access-Request
RFC 2866 (5.5) allows an Access-Request to carry Acct-Session-Id and
requires the same value in the session's Accounting-Requests. Sending it
already at authentication time lets the RADIUS server correlate the two
exchanges by a single per-session key (e.g. for rlm_ippool, so concurrent
sessions of the same user from the same client do not collide on one IP
lease).

Documented as REQ-AUTH-AUTH-025, with a positive check in tests/radius
that the id sent as Acct-Session-Id in the Access-Request matches the
Accounting-Request.

Signed-off-by: Dmitrii <dimmispencer@gmail.com>
Resolves: #751
2026-07-14 21:40:23 +02:00

82 KiB

title, generator, process, id-prefix, sources
title generator process id-prefix sources
authentication and accounting requirements requirements-from-implementation sec-mod (primary), worker (credential transport), main (vhost config) REQ-AUTH
src/sec-mod-auth.c
src/sec-mod-auth.h
src/auth/common.c
src/auth/common.h
src/auth/plain.c
src/auth/plain.h
src/auth/pam.c
src/auth/pam.h
src/auth/radius.c
src/auth/radius.h
src/auth/gssapi.c
src/auth/gssapi.h
src/auth/openidconnect.c
src/auth/openidconnect.h
src/acct/pam.c
src/acct/radius.c
src/config.c
src/subconfig.c
src/worker-auth.c
doc/sample.config
doc/README-radius.md
doc/README-oidc.md
doc/requirements/internal/sec-mod.md
doc/requirements/internal/worker.md
doc/requirements/internal/ipc.md

Authentication and Accounting Requirements

This document covers everything that decides who a client is and which group they belong to: the auth_mod_st vtable contract (src/sec-mod-auth.h), each authentication method implementing it (src/auth/*), how auth= / enable-auth= / acct= are parsed and composed (src/config.c, src/subconfig.c), and the worker-side credential-transport guarantees that support it (src/worker-auth.c). It does not cover session/cookie lifecycle, IP banning infrastructure, or the camouflage HTTP gate, which remain in internal/sec-mod.md and internal/worker.md.

It also covers the ACCT category: selection of the acct= method (src/acct/*) and the RADIUS accounting record lifecycle (REQ-AUTH-ACCT-*), since both share the same auth_mod_st-adjacent module-registration and per-vhost configuration machinery as authentication. The accounting-related configuration options themselves (acct=, RADIUS/PAM accounting suboptions, and their defaults) are documented for administrators in doc/sample.config and doc/ocserv.8.md; this document covers the behavioral requirements those options drive.

doc/sample.config documents six values for auth=/enable-auth=: certificate, pam[...], plain[...], radius[...], gssapi[...], and (when built with SUPPORT_OIDC_AUTH) oidc[...]. acct= accepts radius or pam.

INIT — registration and composition of authentication methods

REQ-AUTH-INIT-001 — Authentication methods are registered in a single table; per-method options are parsed by a dedicated get_brackets_string

Requirement: Every authentication method available to auth=/ enable-auth= MUST appear in avail_auth_types[] with a name, an auth_mod_st * (or NULL for certificate, which has no module), an AUTH_TYPE_* bitmask, and (optionally) a get_brackets_string callback that parses the method's [...] suboptions into a per-method config struct (pam_cfg_st, plain_cfg_st, radius_cfg_st, gssapi_cfg_st, oidc config path). Unknown suboptions inside [...] MUST cause exit(EXIT_FAILURE) with unknown option '%s'. Strength: MUST Status: DERIVED Source: src/config.c:246-267 (avail_auth_types[]); src/subconfig.c:107-150 (gssapi), 215-286 (radius), 290-323 (pam), 326-367 (plain), 369+ (oidc) Acceptance: unit, local — for each compiled-in method, configure auth = <name>[unknown-option=1] and confirm ocserv exits with "unknown option 'unknown-option'" before starting any listener. Links: REQ-AUTH-AUTH-010 (certificate), REQ-AUTH-AUTH-011 (plain), REQ-AUTH-AUTH-017 (pam), REQ-AUTH-AUTH-022 (radius), REQ-AUTH-AUTH-030 (gssapi), REQ-AUTH-AUTH-036 (oidc)

REQ-AUTH-INIT-002 — auth= (primary) composes methods with AND into a single auth[0] entry; at most one may own a module

Requirement: For primary=1 (the auth= directive, which may repeat), figure_auth_funcs() MUST merge every listed method into a single config->auth[0]: OR-ing their AUTH_TYPE_* bits into auth[0].type, concatenating their names with + (e.g. certificate+pam), and setting auth[0].amod to the first method's non-NULL auth_mod_st *. If a second listed method also has a non-NULL module (i.e. two password/credential-based modules in the same auth= group), this MUST exit(EXIT_FAILURE) with "you cannot mix multiple authentication methods of %s type". certificate (whose avail_auth_types[] entry has mod == NULL) MAY be combined with exactly one module-based method this way. Strength: MUST Status: DERIVED Source: src/config.c:304-375 (primary branch), :322-329 (duplicate-module check) Acceptance: positive, local — auth = certificate followed by auth = pam[service=ocserv] in the same vhost; confirm config->auth[0].type == AUTH_TYPE_CERTIFICATE | AUTH_TYPE_PAM | AUTH_TYPE_USERNAME_PASS and auth[0].name == "certificate+pam". Negative: auth = pam[...] followed by auth = radius[...]; confirm exit(EXIT_FAILURE) with "you cannot mix multiple authentication methods of radius type". Links: REQ-AUTH-INIT-005, REQ-AUTH-AUTH-010, REQ-AUTH-SEC-001

REQ-AUTH-INIT-003 — enable-auth= (alternatives) composes methods with OR into separate auth[1..] entries; password-based methods cannot be mixed

Requirement: For primary=0 (the enable-auth= directive), each listed method MUST become its own config->auth[x] entry (x starting at config->auth_methods), each independently enabled, up to MAX_AUTH_METHODS; exceeding that limit MUST exit(EXIT_FAILURE). check_for_duplicate_password_auth() MUST exit(EXIT_FAILURE) with "you cannot mix multiple password authentication methods" if more than one configured auth[i] (across auth[0] from auth= and all enable-auth= alternatives) has AUTH_TYPE_USERNAME_PASS set — i.e. pam, plain, and radius cannot appear as alternatives to each other (each may still appear combined with certificate per REQ-AUTH-INIT-002, and only once). Strength: MUST Status: DERIVED Source: src/config.c:376-438 (alternatives branch); :269-288 (check_for_duplicate_password_auth) Acceptance: positive, local — auth = certificate, enable-auth = gssapi; confirm config->auth_methods == 2 with auth[1].type == AUTH_TYPE_GSSAPI. Negative — auth = pam[...], enable-auth = radius[...]; confirm exit(EXIT_FAILURE) with "you cannot mix multiple password authentication methods". Links: REQ-AUTH-INIT-004, REQ-AUTH-SEC-001

REQ-AUTH-INIT-004 — set_module() selects the first configured auth[i] whose type bitmask matches the worker's requested auth type

Requirement: On SEC_AUTH_INIT, sec-mod MUST scan vhost->static_config.auth[0..auth_methods-1] in declaration order and select the first entry e for which (e.type & req_auth_type) != 0 (the worker's requested auth method, derived from how the client authenticated to the HTTPS endpoint — certificate presented, Basic-Auth credentials, SPNEGO token, OIDC bearer token), binding e->module = config->auth[i].amod, e->auth_type = config->auth[i].type, and the corresponding vhost_auth_ctx/vhost_acct_ctx. If no entry matches, authentication MUST fail. Strength: MUST Status: DERIVED Source: src/sec-mod-auth.c:841-871 (set_module) Acceptance: unit, local — with auth = certificate+pam and enable-auth = gssapi, send SEC_AUTH_INIT with auth_type = AUTH_TYPE_GSSAPI; confirm e->module == gssapi_auth_funcs and e->auth_type == AUTH_TYPE_GSSAPI (the second configured entry, not the first). Negative: send auth_type = AUTH_TYPE_OIDC (not configured for this vhost); confirm set_module finds no match and authentication fails. Links: REQ-AUTH-AUTH-001

REQ-AUTH-INIT-005 — cert-user-oid is mandatory whenever certificate authentication causes a client certificate to be requested or required

Requirement: If auth[0].type & AUTH_TYPE_CERTIFICATE and auth_methods == 1 (certificate is the only primary method), config validation MUST set cert_req = GNUTLS_CERT_REQUIRE (or GNUTLS_CERT_REQUEST under cisco-client-compat). If certificate is combined with another method (REQ-AUTH-INIT-002) or appears only as an enable-auth= alternative, cert_req = GNUTLS_CERT_REQUEST (optional). In either case, if cert_req != 0 and config->cert_user_oid == NULL, config validation MUST exit(EXIT_FAILURE). If cert_user_oid is set, it MUST be either a numeric OID or the literal string SAN(rfc822name). Strength: MUST Status: DERIVED Source: src/config.c:1992-2030 Acceptance: negative, local — set auth = certificate without cert-user-oid; confirm config validation fails with "a certificate is requested by the option 'cert-user-oid' is not set". Positive — set cert-user-oid = SAN(rfc822name) and confirm it is accepted; set cert-user-oid = not-an-oid and confirm rejection. Links: REQ-AUTH-AUTH-005, REQ-AUTH-AUTH-010

AUTH — auth_mod_st vtable contract

The vtable is defined in src/sec-mod-auth.h: type, allows_retries, vhost_init/vhost_deinit, auth_init, auth_msg, auth_pass, auth_group, auth_user, auth_deinit, group_list. The following requirements were relocated here (with new IDs) from internal/sec-mod.md (REQ-SECMOD-AUTH-001..006) and internal/worker.md (REQ-WORKER-AUTH-001..003) because they describe the authentication-method contract itself rather than sec-mod's or the worker's session machinery. internal/sec-mod.md and internal/worker.md now cross-link here.

REQ-AUTH-AUTH-001 — auth_init return value drives PS_AUTH_INIT vs multi-factor continuation

Requirement: auth_mod_st.auth_init() MUST return 0 for single-step authentication that is immediately decided by auth_pass, or ERR_AUTH_CONTINUE if the module requires auth_msg/auth_pass rounds before a verdict (multi-factor); any other negative return MUST cause sec-mod to set e->status = PS_AUTH_FAILED and reply SEC_AUTH_REP(FAILED) without calling auth_msg. Strength: MUST Status: DERIVED Source: src/sec-mod-auth.c:946-952 (handle_sec_auth_init dispatch); src/sec-mod-auth.c:374-381,440-460 (handle_sec_auth_res) Acceptance: unit, local — for a mock module returning each of 0, ERR_AUTH_CONTINUE, and -1 from auth_init, confirm sec-mod's resulting e->status and the SEC_AUTH_REP reply type match this contract. Links: REQ-IPC-010, REQ-AUTH-AUTH-002

REQ-AUTH-AUTH-002 — auth_msg is called whenever continuing

Requirement: Whenever auth_init/auth_pass returns 0 or ERR_AUTH_CONTINUE and e->module is non-NULL, sec-mod MUST call auth_msg() to obtain the next prompt (passwd_msg_st) before deciding whether the session is complete or needs another SEC_AUTH_CONT round. A negative return from auth_msg MUST set e->status = PS_AUTH_FAILED and propagate the error. Strength: MUST Status: DERIVED Source: src/sec-mod-auth.c:374-387 Acceptance: unit, local — mock auth_msg returning -1; confirm handle_sec_auth_res sets PS_AUTH_FAILED and returns the error without sending SEC_AUTH_REP(MSG). Links: REQ-AUTH-AUTH-001

REQ-AUTH-AUTH-003 — passwd_counter increases monotonically and gates retry scoring

Requirement: sec-mod MUST track e->passwd_counter from passwd_msg_st.counter. A retry of the same password stage (pst.counter <= e->passwd_counter) on a module with allows_retries set MUST add ban_points_wrong_password to the client's IP score (via sec_mod_add_score_to_ip); advancing to a new stage (pst.counter > e->passwd_counter) MUST NOT add ban points. Strength: MUST Status: DERIVED Source: src/sec-mod-auth.c:384-396 Acceptance: unit, local — drive two SEC_AUTH_CONT rounds with the same pst.counter value against a module with allows_retries=1; confirm exactly one CMD_SECM_BAN_IP is sent with score = ban_points_wrong_password. Repeat with increasing pst.counter and confirm no ban message is sent. Links: REQ-SECMOD-SEC-001, REQ-IPC-080, REQ-AUTH-AUTH-015, REQ-AUTH-AUTH-018, REQ-AUTH-AUTH-027

REQ-AUTH-AUTH-004 — auth_group failure fails the session post auth_pass success

Requirement: Even after auth_pass/auth_init report success (result == 0), sec-mod MUST call check_group() (which calls auth_mod_st.auth_group if set) and MUST set e->status = PS_AUTH_FAILED and reply SEC_AUTH_REP(FAILED) if check_group() returns negative — i.e., a credential check alone is not sufficient for PS_AUTH_COMPLETED. Strength: MUST Status: DERIVED Source: src/sec-mod-auth.c:405-415, 309-358 Acceptance: negative, local — configure a mock auth_group that always returns -1; confirm a session with otherwise-valid credentials still receives SEC_AUTH_REP(FAILED) and e->status == PS_AUTH_FAILED. Links: REQ-AUTH-AUTH-005, REQ-AUTH-AUTH-006

REQ-AUTH-AUTH-005 — Certificate auth: defense-in-depth re-check of cert-user-oid / cert-group-oid

Requirement: When e->auth_type & AUTH_TYPE_CERTIFICATE, sec-mod MUST independently re-verify (not merely trust the worker's TLS handshake result): (a) if vhost->config->cert_user_oid is set, e->cert_user_name MUST be non-empty; (b) e->tls_auth_ok MUST be true; (c) if e->acct_info.username was already set (e.g. from a prior auth stage) and cert_user_oid is configured, it MUST equal e->cert_user_name; (d) if cert_group_oid is configured, e->acct_info.groupname MUST be among e->cert_group_names[]. Any failure MUST cause check_cert_user_group_status() to return negative, which check_group() propagates as a failed authentication (REQ-AUTH-AUTH-004). Strength: MUST Status: DERIVED Source: src/sec-mod-auth.c:229-307 (comment: "Defense in depth: ... The worker already enforces this, but sec-mod should not rely on the worker having done so.") Acceptance: [SEC] negative, local — for each of (a)-(d), construct a SEC_AUTH_INIT/SEC_AUTH_CONT sequence where the worker-supplied fields (tls_auth_ok, cert_user_name, cert_group_names) violate the constraint while auth_pass would otherwise succeed; confirm sec-mod rejects in each case. This is the privilege-boundary test: sec-mod must not trust worker-reported TLS state. Links: REQ-AUTH-AUTH-004, REQ-AUTH-AUTH-010, REQ-IPC-014

REQ-AUTH-AUTH-006 — Group selection precedence: module > certificate

Requirement: check_group() MUST first ask auth_mod_st.auth_group (if set) to determine e->acct_info.groupname. Only if that leaves groupname empty AND the user requested a group AND vhost->config->cert_group_oid is configured does sec-mod fall back to matching the requested group against e->cert_group_names[]. If the requested group is not in cert_group_names[] in this fallback path, authentication MUST fail. Strength: MUST Status: DERIVED Source: src/sec-mod-auth.c:309-358 Acceptance: unit, local — (a) mock auth_group setting a groupname; confirm the cert-group fallback is not consulted even if it would disagree. (b) no auth_group, req_group_name set to a group not in cert_group_names[], cert_group_oid configured; confirm rejection with log "is not included on his certificate groups". Links: REQ-AUTH-AUTH-005

REQ-AUTH-AUTH-007 — Worker delegates all credential verification to sec-mod over CMD_SEC_AUTH_*

Requirement: The worker MUST NOT itself accept or reject a username/password/OTP/GSSAPI/OIDC credential — post_auth_handler() and its sub-handlers (basic_auth_handler, oidc_auth_handler) parse the HTTP request body and forward credentials to sec-mod via SEC_AUTH_INIT/ SEC_AUTH_CONT (REQ-IPC-010/015/016), interpreting only the SEC_AUTH_REP reply (recv_auth_reply(), AUTH__REP__{MSG,OK,FAILED}). Strength: MUST NOT Status: DERIVED Source: src/worker-auth.c:895-981, src/worker-auth.c:1572 (signature) Acceptance: [SEC] negative — confirm (by code inspection / call-graph) that no comparison of a client-supplied password against any locally-held secret exists in src/worker-*.c. All AUTH__REP__FAILED paths originate from a recv_auth_reply/recv_cookie_auth_reply result, never from local string comparison. Cross-references REQ-AUTH-AUTH-001. Links: REQ-IPC-010, REQ-IPC-015, REQ-IPC-016, REQ-AUTH-AUTH-001

REQ-AUTH-AUTH-008 — Certificate username/groups are extracted once and cached; sec-mod's check is consistency-checking of worker-reported fields, not re-derivation from a raw certificate

Requirement: get_cert_names() MUST be idempotent — if ws->cert_username[0] != 0 || ws->cert_groups_size > 0, it returns immediately without re-parsing the certificate. The worker's parsed ws->cert_username/ws->cert_groups are sent to sec-mod as part of SEC_AUTH_INIT (req->cert_user_name/req->cert_group_names, src/ipc.proto:251-252) and copied verbatim into e->cert_user_name/e->cert_group_names[] (src/sec-mod-auth.c:990-995). sec_auth_init_msg has no raw-certificate, certificate-hash, or other corroborating field (src/ipc.proto:246-264), and neither cert_user_name/cert_group_names nor tls_auth_ok is covered by sec_auth_init_hmac (src/sec-mod-auth.c:899-908 builds the HMAC from orig_remote_ip/our_ip/session_start_time only). REQ-AUTH-AUTH-005's check_cert_user_group_status() therefore cross-checks these fields for internal consistency against other worker-supplied fields (e->acct_info.username/groupname, also from the same SEC_AUTH_INIT) — it does not re-derive identity from any certificate material sec-mod itself examines, because sec-mod never receives the certificate. The worker's parsing exists for UI/cookie purposes (e.g. populating ws->username for logging before sec-mod replies) and MUST NOT be treated as authoritative by anything downstream of sec-mod (e.g. accounting), but sec-mod itself has no independent ground truth to validate it against either. Strength: MUST Status: DERIVED Source: src/worker-auth.c:605-704; src/sec-mod-auth.c:990-995, 899-908; src/ipc.proto:246-264, 251-252 Acceptance: [SEC] negative — modify a test client to send a SEC_AUTH_INIT with a cert_username/cert_groups that doesn't match the actual certificate's DN (requires a custom worker build, since the real worker always derives it from the cert); confirm check_cert_user_group_status() (REQ-AUTH-AUTH-005) rejects an internally-inconsistent set of worker-supplied fields (e.g. cert_user_name disagreeing with a pre-set acct_info.username). [SEC-RISK: this test cannot demonstrate the stronger property "sec-mod detects a forged-but-internally-consistent set" — e.g. tls_auth_ok=true, cert_user_name="victim", user_name="victim", cert_group_names/group_nameall naming the victim's group, sent together by a compromised worker without any client certificate ever being presented. Under the trust model where a worker is "potentially compromised" (contrib/ai/protocols/security-vulnerability.md, Trust Boundary Model), this set passes every check incheck_cert_user_group_status()and is indistinguishable from a legitimate certificate-authenticated session, forauth = certificate(andcertificate+, on the certificate half) vhosts. Closing this gap would require extending sec_auth_init_hmac(or a new IPC field) to covertls_auth_ok/cert_user_name/cert_group_names/auth_type so sec-mod can detect a worker reporting values main did not expect for that connection, or forwarding cert material (hash/DN) for sec-mod to check directly. Both options change the worker/sec-mod IPC contract and need maintainer design review per AGENTS.md's privilege-boundary criteria — flagging here, not resolving unilaterally.] Links: REQ-AUTH-AUTH-005, REQ-AUTH-AUTH-006, REQ-AUTH-AUTH-010, REQ-SECMOD-SEC-004

REQ-AUTH-AUTH-009 — get_cert_username never returns a positive value, distinguishing "not found" from "SAN type" codes

Requirement: get_cert_username() MUST return 0 on success or a negative GnuTLS error code (never a positive value) — specifically, when searching for an SAN(rfc822name) and gnutls_x509_crt_get_subject_alt_name returns a non-matching positive SAN-type code, the loop MUST continue (not return that positive code), and exhaustion MUST yield GNUTLS_E_REQUESTED_DATA_NOT_AVAILABLE (negative). Strength: MUST Status: DERIVED Source: src/worker-auth.c:557-603 (comment explains the rationale) Acceptance: unit, local — configure cert-user-oid = SAN(rfc822name) and present a certificate whose SANs include only non-rfc822name entries (e.g. a DNS SAN before any rfc822Name, or none at all); confirm get_cert_username returns a negative value (and get_cert_names logs "the certificate does not contain ... cannot determine username") rather than misinterpreting a positive SAN-type return as success. Links: REQ-AUTH-AUTH-010

AUTH — certificate

REQ-AUTH-AUTH-010 — Certificate authentication has no module; identity comes from the TLS handshake under cert-user-oid/cert-group-oid

Requirement: certificate in avail_auth_types[] has mod == NULL and no get_brackets_string — it contributes only AUTH_TYPE_CERTIFICATE to auth[0].type. There is no auth_init/auth_pass round for it: identity and group membership come entirely from the already-completed TLS client certificate verification (e->tls_auth_ok, e->cert_user_name, e->cert_group_names[]), gated by cert-user-oid/cert-group-oid (REQ-AUTH-INIT-005) and re-checked per REQ-AUTH-AUTH-005/006. When combined with a password method (certificate+pam, etc., REQ-AUTH-INIT-002), both the certificate check and the password module's auth_init/auth_pass MUST succeed. Strength: MUST Status: DERIVED Source: src/config.c:262 (avail_auth_types[] entry); src/sec-mod-auth.c: 229-358 Acceptance: positive, local — auth = certificate+pam, present a valid client certificate and correct PAM password; confirm PS_AUTH_COMPLETED. Negative — same config, valid certificate but wrong PAM password; confirm PS_AUTH_FAILED even though the certificate alone would pass REQ-AUTH-AUTH-005. Links: REQ-AUTH-INIT-002, REQ-AUTH-INIT-005, REQ-AUTH-AUTH-005, REQ-AUTH-AUTH-006, REQ-AUTH-AUTH-008

REQ-AUTH-AUTH-041 — Worker auto-selects the certificate group when exactly one is eligible, avoiding an unnecessary group-selection prompt

Requirement: In post_auth_handler(), when AUTH_TYPE_CERTIFICATE is active, ws->cert_groups_size > 0, and the client did not request a group (no group_list/group-select field, no select-group-by-url match, and default-select-group not requested), the worker MUST NOT unconditionally respond with the "Please select your group." prompt. It MUST first compute the set of eligible certificate groups: if select-group is configured (WSRCONFIG(ws)->n_group_list > 0), eligible groups are the entries of ws->cert_groups[] that also appear in WSRCONFIG(ws)->group_list[]; otherwise (no select-group configured) every entry of ws->cert_groups[] is eligible. If exactly one eligible group exists, the worker MUST set ws->groupname to it and proceed with SEC_AUTH_INIT as if the client had requested that group, completing authentication without an extra round trip. If zero or more than one eligible groups exist, the "Please select your group." prompt (and the cert-group fallback of REQ-AUTH-AUTH-006) is unchanged. Strength: MUST Status: DERIVED Source: src/worker-auth.c:1730-1738 Acceptance: positive, local — cert-group-oid configured, no select-group list, client certificate carries exactly one group (OU); a first POST with no <group-select> completes directly with <auth id="success"> and a Set-Cookie: webvpncontext= header. Negative, local — client certificate carries multiple groups (OUs) and no select-group list is configured (or more than one of its groups matches a configured select-group list); a first POST with no <group-select> still yields "Please select your group." and no session cookie. Links: REQ-AUTH-AUTH-006, REQ-AUTH-AUTH-010

AUTH — plain (auth = plain[passwd=...,otp=...])

REQ-AUTH-AUTH-011 — plain[...] requires at least one of passwd=/otp=; vhost_init fails closed without it

Requirement: plain_get_brackets_string() MUST parse passwd=<path> and (when built HAVE_LIBOATH) otp=<path>, also accepting the legacy [/path/to/passwd] form for passwd. If neither is set, config parsing MUST exit(EXIT_FAILURE) with "no password or OTP file specified". plain_vhost_init() MUST itself exit(EXIT_FAILURE) if additional == NULL (i.e. plain configured with no [...] at all). Strength: MUST Status: DERIVED Source: src/subconfig.c:326-367; src/auth/plain.c:63-83 (plain_vhost_init) Acceptance: negative, local — auth = plain (no brackets); confirm exit(EXIT_FAILURE). auth = plain[foo=bar]; confirm "no password or OTP file specified". Positive — auth = plain[passwd=%SRCDIR%/tests/data/test1.passwd] starts successfully. Links: REQ-AUTH-AUTH-012

REQ-AUTH-AUTH-012 — passwd= file format is username:groupname1,groupname2,...:hash; a dummy_salt is captured for timing normalization

Requirement: read_auth_pass() MUST parse each line of the passwd= file as username:groups:hash, splitting groups on , via break_group_list() into pctx->groupnames[] (capped at MAX_GROUPS). The matched user's hash is copied into pctx->cpass. While scanning, the first non-empty hash field seen on any line (regardless of whether it matches the requested username) MUST be copied into pctx->dummy_salt for use by REQ-AUTH-AUTH-013. read_auth_pass() MUST always return 0 — an unknown username is not a parse error. Strength: MUST Status: DERIVED Source: src/auth/plain.c:142-218 Acceptance: unit, local — passwd file with two users alice:grp1,grp2:$5$... and bob:grp3:$5$...; authenticate as alice; confirm pctx->groupnames == {"grp1","grp2"} and pctx->cpass matches alice's hash. Authenticate as an unknown user eve; confirm read_auth_pass returns 0 and pctx->cpass[0] == 0 but pctx->dummy_salt is non-empty (taken from alice's or bob's entry). Links: REQ-AUTH-AUTH-013, REQ-AUTH-AUTH-016

REQ-AUTH-AUTH-013 — plain_auth_pass always calls crypt(), even for unknown users, to prevent timing-based username enumeration

Requirement: plain_auth_pass() MUST call crypt(pass, salt) unconditionally on every invocation, regardless of whether the username was found: salt is pctx->cpass if the user is known, else pctx->dummy_salt if one was captured (REQ-AUTH-AUTH-012), else the fixed fallback "$5$fakesalt$". The result MUST be compared against pctx->cpass and MUST NOT match for an unknown user (pctx->cpass[0] == 0), so the rejection path for "unknown user" and "wrong password for a known user" perform the same crypt() work and differ only in the final comparison. Strength: MUST Status: DERIVED Source: src/auth/plain.c:300-330 (comment: "Always call crypt() to normalize timing regardless of whether the user exists") Acceptance: [SEC] negative — authenticate with a known username and wrong password, and with an unknown username and any password; both MUST return ERR_AUTH_CONTINUE/ERR_AUTH_FAIL per REQ-AUTH-AUTH-015's retry rules with no crypt() call skipped (verify via strace/ltrace counting crypt calls, or a build-time counter) — i.e. response timing must not distinguish "no such user" from "bad password". Links: REQ-AUTH-AUTH-012, REQ-AUTH-AUTH-015

REQ-AUTH-AUTH-014 — OTP verification, when otp= is configured, is a second stage after the password stage

Requirement: When HAVE_LIBOATH and pctx->config->otp_file is set, plain_auth_pass() MUST, after the password stage succeeds (or if no passwd= is configured and the password stage is skipped), prompt for an OTP (pass_msg_otp) and verify it with oath_hotp_validate() using HOTP_WINDOW = 20. Failure of the OTP step MUST be ERR_AUTH_FAIL (no separate retry budget beyond REQ-AUTH-AUTH-015). If neither passwd= nor otp= ultimately yields a password to check (empty password and no OTP file), plain_auth_init/plain_auth_pass MUST return ERR_AUTH_FAIL. Strength: MUST Status: DERIVED Source: src/auth/plain.c:219-256 (plain_auth_init), 300-367 (plain_auth_pass, OTP branch at 341-365) Acceptance: positive, local — configure plain[passwd=...,otp=...]; after a correct password, confirm a second SEC_AUTH_CONT round is required (pass_msg_otp), and a valid HOTP code (within HOTP_WINDOW) completes authentication. Negative — an out-of-window or reused HOTP code yields ERR_AUTH_FAIL. Links: REQ-AUTH-AUTH-011, REQ-AUTH-AUTH-015

REQ-AUTH-AUTH-015 — plain allows up to MAX_PASSWORD_TRIES - 1 retries via ERR_AUTH_CONTINUE

Requirement: plain_auth_funcs.allows_retries == 1. On a password mismatch, plain_auth_pass() MUST increment pctx->retries and, while pctx->retries < MAX_PASSWORD_TRIES - 1 (i.e. up to 2 retries for the default MAX_PASSWORD_TRIES == 3), return ERR_AUTH_CONTINUE with pass_msg_failed; once the limit is reached, return ERR_AUTH_FAIL. Strength: MUST Status: DERIVED Source: src/auth/plain.c:300-330; src/auth/common.h (MAX_PASSWORD_TRIES == 3); src/auth/plain.c:490-501 (plain_auth_funcs, .allows_retries = 1) Acceptance: negative, local — submit a wrong password 3 times in the same session; confirm the first 2 return SEC_AUTH_REP(MSG) with pass_msg_failed (and increment the IP ban score per REQ-AUTH-AUTH-003), and the 3rd returns SEC_AUTH_REP(FAILED). Links: REQ-AUTH-AUTH-003, REQ-AUTH-AUTH-013

REQ-AUTH-AUTH-016 — plain_auth_group validates the requested group against the passwd-file groups; plain_auth_user/plain_group_list do not change identity beyond the passwd file

Requirement: plain_auth_group() MUST return -1 (rejecting the session per REQ-AUTH-AUTH-004) if the worker's suggested group is non-empty and not present in pctx->groupnames[]; otherwise it copies the (first, or suggested) group into groupname. plain_auth_user() MUST always return -1 (the username from SEC_AUTH_INIT is authoritative; plain never rewrites it). plain_group_list() MUST build the full set of group names available for select-group/auto-select-group by scanning the entire passwd= file and deduplicating via a hash table (rehash/str_cmp). Strength: MUST Status: DERIVED Source: src/auth/plain.c:259-299 (plain_auth_group, plain_auth_user); 389-490 (rehash, str_cmp, plain_group_list) Acceptance: negative, local — passwd file entry alice:grp1,grp2:...; request group grp3 (not in alice's list); confirm PS_AUTH_FAILED per REQ-AUTH-AUTH-004. Positive — request grp1; confirm e->acct_info.groupname == "grp1". select-group/occtl show users MUST list the union of all groups across all passwd-file entries. Links: REQ-AUTH-AUTH-004, REQ-AUTH-AUTH-006, REQ-AUTH-AUTH-012

AUTH — PAM (auth = pam[service=...,gid-min=...])

REQ-AUTH-AUTH-017 — pam[...] options are service= (default "ocserv"/PACKAGE) and gid-min=

Requirement: pam_get_brackets_string() MUST accept only service (a string, stored as service_name) and gid-min (a non-negative integer, exit(EXIT_FAILURE) if < 0); any other suboption MUST exit(EXIT_FAILURE). If service is unset, pam_vhost_init() MUST default config->service_name to PACKAGE ("ocserv"). Strength: MUST Status: DERIVED Source: src/subconfig.c:290-323; src/auth/pam.c:59-76 (pam_vhost_init) Acceptance: positive, local — auth = pam (no brackets, or pam[]); confirm pam_start() is called with service name "ocserv". auth = pam[service=sshd]; confirm pam_start("sshd", ...). Negative — pam[gid-min=-1]; confirm exit(EXIT_FAILURE). Links: REQ-AUTH-AUTH-021

REQ-AUTH-AUTH-018 — PAM conversation runs in a coroutine; the prompt hash drives passwd_counter

Requirement: pam_auth_init() MUST pam_start() and create a coroutine (PCL cr) running co_auth_user(), returning ERR_AUTH_CONTINUE. ocserv_conv() (the PAM conversation function, running inside the coroutine) MUST: append PAM_ERROR_MSG/PAM_TEXT_INFO messages to pctx->msg; on PAM_PROMPT_ECHO_OFF/PAM_PROMPT_ECHO_ON, set pctx->state = PAM_S_WAIT_FOR_PASS and co_resume() back to the caller, then check pctx->aborted before filling the reply from pctx->password. pam_auth_msg() MUST, on first entry (PAM_S_INIT), co_call() to drive the coroutine to its first prompt, then compute prompt_hash = hash_any(pctx->msg.data, pctx->msg.length, 0); pst->counter = pctx->passwd_counter, and only if prompt_hash != pctx->prev_prompt_hash does it increment pctx->passwd_counter (before updating prev_prompt_hash) — i.e. a repeated identical prompt does NOT advance the counter (REQ-AUTH-AUTH-003 treats it as a retry of the same stage), but a new/different prompt (e.g. a second factor) does. Strength: MUST Status: DERIVED Source: src/auth/pam.c:77-171 (ocserv_conv), 172-215 (co_auth_user), 216-269 (pam_auth_init), 270-318 (pam_auth_msg) Acceptance: unit, local — configure a PAM service whose stack prompts twice with different text (e.g. password then a second factor); confirm pst.counter increments between the two SEC_AUTH_CONT rounds. Configure a PAM service that re-prompts with the same text on retry (e.g. "Password:" again after a failure); confirm pst.counter does NOT increment, so REQ-AUTH-AUTH-003 scores it as a retry. Links: REQ-AUTH-AUTH-003, REQ-AUTH-AUTH-019, REQ-AUTH-AUTH-020

REQ-AUTH-AUTH-019 — pam_auth_pass rejects calls outside PAM_S_WAIT_FOR_PASS; completion requires PAM_S_COMPLETE

Requirement: pam_auth_pass() MUST return ERR_AUTH_FAIL if pctx->state != PAM_S_WAIT_FOR_PASS (the worker sent a password when none was requested, or sent it twice). On a valid call, it MUST copy pass into pctx->password, co_call() to resume the PAM conversation, and return 0 only if the coroutine reaches PAM_S_COMPLETE; otherwise (still PAM_S_WAIT_FOR_PASS, another prompt pending) it MUST return ERR_AUTH_CONTINUE. Strength: MUST Status: DERIVED Source: src/auth/pam.c:319-353 Acceptance: [SEC] negative — send SEC_AUTH_CONT with a password before any SEC_AUTH_REP(MSG) prompt was issued (pctx->state == PAM_S_INIT); confirm ERR_AUTH_FAIL. Positive — single-factor PAM stack: one SEC_AUTH_CONT with the correct password yields 0 and PS_AUTH_COMPLETED (after REQ-AUTH-AUTH-004's group check). Links: REQ-AUTH-AUTH-018

REQ-AUTH-AUTH-020 — Expired-password change flow via PAM_NEW_AUTHTOK_REQD/pam_chauthtok

Requirement: co_auth_user() MUST run pam_authenticate() then pam_acct_mgmt(); if the latter returns PAM_NEW_AUTHTOK_REQD, it MUST set pctx->changing = 1 and call pam_chauthtok(ph, PAM_CHANGE_EXPIRED_AUTHTOK), continuing the same conversation (additional prompts for the new password, each going through ocserv_conv/REQ-AUTH-AUTH-018) before reaching PAM_S_COMPLETE. Any other non-PAM_SUCCESS result from pam_authenticate/pam_acct_mgmt/pam_chauthtok MUST end the coroutine without reaching PAM_S_COMPLETE, so pam_auth_pass keeps returning ERR_AUTH_CONTINUE until the caller gives up (REQ-AUTH-AUTH-015's analogue: PAM has no allows_retries set, so no extra ban score is added by REQ-AUTH-AUTH-003 on these intermediate rounds). Strength: MUST Status: DERIVED Source: src/auth/pam.c:172-215 (co_auth_user) Acceptance: positive, local — configure a PAM service/account with an expired password (e.g. pam_unix with chage -d 0); authenticate with the old password; confirm additional SEC_AUTH_CONT rounds are issued for "new password"/"retype new password" and, on success, PS_AUTH_COMPLETED with the account's password actually changed (verify via the system password database). Negative — supply mismatched new passwords; confirm authentication does not complete. Links: REQ-AUTH-AUTH-018

REQ-AUTH-AUTH-021 — pam_auth_group/pam_group_list use the OS group database, filtered by gid-min

Requirement: pam_auth_group() MUST delegate to get_user_auth_group() (src/auth/auth-unix.h), which validates/selects the group the same way for all OS-account-backed methods. pam_group_list() MUST call unix_group_list(pool, gid_min, ...), excluding groups with GID below config->gid_min (default 0, i.e. no exclusion) from select-group/auto-select-group enumeration. pam_auth_user() MUST read PAM_USER via pam_get_item() (reflecting any username canonicalization PAM itself performed, e.g. via pam_unix's NSS lookup). Strength: MUST Status: DERIVED Source: src/auth/pam.c:354-385 (pam_auth_group, pam_auth_user), 405-416 (pam_group_list) Acceptance: unit, local — set gid-min = 1000; confirm a system group with GID 999 does not appear in occtl show ... groups / is rejected as a select-group value, while a GID 1001 group is accepted. Links: REQ-AUTH-AUTH-004, REQ-AUTH-AUTH-006, REQ-AUTH-AUTH-017

AUTH — RADIUS (auth = radius[config=...,groupconfig=...,nas-identifier=...,group-separator=...])

REQ-AUTH-AUTH-022 — radius[...] options: config= (required), nas-identifier=, group-separator= (semicolon|comma, default ;), groupconfig=; legacy [/path] form supported

Requirement: radius_get_brackets_string() MUST accept either the legacy form radius[/path/to/radiusclient.conf] (optionally followed by ,groupconfig), or the new form with named suboptions config=, nas-identifier=, group-separator= (only semicolon; or comma,, else exit(EXIT_FAILURE)), groupconfig= (boolean). config= (the freeradius-client/radcli config file path) MUST be set — its absence is exit(EXIT_FAILURE) with "No radius configuration specified". groupconfig (legacy or new) sets config->sup_config_type = SUP_CONFIG_RADIUS (per-group/per-user supplemental config sourced from RADIUS attributes rather than occtl/files — see internal/config.md). Strength: MUST Status: DERIVED Source: src/subconfig.c:181-287 Acceptance: negative, local — auth = radius[nas-identifier=foo] (no config=); confirm "No radius configuration specified". auth = radius[config=/etc/radcli.conf,group-separator=pipe]; confirm "unknown group-separator value 'pipe'; use 'semicolon' or 'comma'". Positive — group-separator=comma and a Class attribute "OU=grp1,grp2" splits into {"grp1","grp2"} (REQ-AUTH-AUTH-026). Links: REQ-AUTH-AUTH-026

REQ-AUTH-AUTH-023 — radius_vhost_init loads the radcli config and dictionary at startup; failure is fatal

Requirement: radius_vhost_init() MUST call rc_read_config(config->config) and rc_read_dictionary(rh, rc_conf_str(rh, "dictionary")), and exit(EXIT_FAILURE) (logging via fprintf(stderr)) if either fails — RADIUS configuration errors MUST be caught at startup, not on the first authentication attempt. vctx->group_separator is set from config->group_separator or defaults to ";" (REQ-AUTH-AUTH-022). Strength: MUST Status: DERIVED Source: src/auth/radius.c:77-121 Acceptance: negative, local — point config= at a nonexistent file or one with an invalid dictionary path; confirm ocserv exits at startup (before accepting any connection), not on first client auth. Links: REQ-AUTH-AUTH-022

REQ-AUTH-AUTH-024 — Usernames without @ get default_realm appended (if configured in the radcli config)

Requirement: radius_auth_init() MUST check rc_conf_str(pctx->vctx->rh, "default_realm"); if non-empty and info->username contains no @, it MUST construct "<username>@<default_realm>" as the username sent in subsequent Access-Requests. If default_realm is unset/empty, or the username already contains @, the username is used verbatim. Strength: MUST Status: DERIVED Source: src/auth/radius.c:130-174 (radius_auth_init, realm logic at 155-163) Acceptance: positive, local — radcli config with default_realm = example.com; authenticate as alice; confirm the Access-Request's User-Name is alice@example.com. Authenticate as bob@other.realm; confirm User-Name == "bob@other.realm" (unchanged). Links: REQ-AUTH-AUTH-025

REQ-AUTH-AUTH-025 — Access-Request attribute set

Requirement: radius_auth_pass() MUST build an Access-Request containing: User-Name (REQ-AUTH-AUTH-024), User-Password (the submitted password), NAS-IP-Address or NAS-IPv6-Address (from e->our_ip, whichever family applies), NAS-Identifier if nas-identifier= is configured (REQ-AUTH-AUTH-022), Calling-Station-Id (the client's remote_ip), Connect-Info (the client's User-Agent string), Acct-Session-Id (the session identifier, when available — see below), Service-Type = Authenticate-Only, NAS-Port-Type = Async, and — when continuing a challenge (REQ-AUTH-AUTH-027) — the State attribute echoed back from the prior Access-Challenge.

The Acct-Session-Id carries the same session identifier (acct_info.safe_id) that is later sent in the Accounting-Request packets (REQ-AUTH-ACCT-*). RFC 2866 (section 5.5) allows an Access-Request to carry Acct-Session-Id ("An Access-Request packet MAY have an Acct-Session-Id"), and requires that if it does, the NAS MUST use the same value in the Accounting-Request packets for that session — so the two exchanges correlate on one key. Emitting it during authentication gives the RADIUS server a per-session key already at Access-Request time (e.g. for rlm_ippool, so concurrent sessions of the same user from the same client do not collide on a single IP lease). Strength: MUST Status: DERIVED Source: src/auth/radius.c:293-430 (request construction, before rc_auth()/rc_send_server()) Acceptance: unit, local — capture the RADIUS request (e.g. via a test FreeRADIUS server with auth_log) for a normal login; confirm all listed attributes are present with expected values, that a configured nas-identifier appears as NAS-Identifier, and that Acct-Session-Id is present and equals the session id reported in the matching Accounting-Request. Links: REQ-AUTH-AUTH-024, REQ-AUTH-AUTH-026, REQ-AUTH-AUTH-027

REQ-AUTH-AUTH-026 — Access-Accept attributes populate group membership and per-session network configuration

Requirement: On OK_RC, radius_auth_pass() MUST: (a) require Service-Type == Framed in the reply, else fail; (b) parse Class/PW_CLASS via parse_groupnames() — if the value matches OU=<group1><sep><group2>... (where <sep> is group_separator, REQ-AUTH-AUTH-022), split into up to MAX_GROUPS group names in pctx->groupnames[]; otherwise treat the whole value as a single group name; (c) map Framed-IPv6-Address/Delegated-IPv6-Prefix/Framed-IPv6-Prefix to ipv6/ipv6_subnet_prefix/routes, and DNS-Server-IPv6-Address (max 2 — a 3rd triggers a warning and is ignored) to ipv6_dns1/ipv6_dns2; (d) map Framed-IP-Address/Framed-IP-Netmask to ipv4/ipv4_mask — the special RFC 2865 values 0xfffffffe ("negotiated") and 0xffffffff ("assign from pool") MUST NOT be applied as literal addresses; (e) map vendor 311 (Microsoft) attributes 28/29 (MS-Primary/Secondary-DNS-Server) to ipv4_dns1/ipv4_dns2; (f) map Framed-Route/Framed-IPv6-Route via append_route() — a route attribute without a / (prefix length) MUST be rejected/ignored; (g) map Acct-Interim-Intervalpctx->interim_interval_secs and Session-Timeoutpctx->session_timeout_secs; (h) map vendor 10055 (Roaring Penguin) attributes 1/2 to rx_per_sec/tx_per_sec. Strength: MUST Status: DERIVED Source: src/auth/radius.c:212-244 (append_route), 245-292 (parse_groupnames), 293-589 (radius_auth_pass, OK_RC branch) Acceptance: positive, local — FreeRADIUS test config returning Class = "OU=eng;sales" with group-separator = semicolon, Framed-IP-Address, Framed-Route = "10.0.0.0/24 192.168.1.1 1", and MS DNS attributes; confirm the resulting session has groupnames == {"eng","sales"}, the configured ipv4/route/DNS values, and (negative) that a reply with Service-Type != Framed is rejected. pctx->interim_interval_secs/pctx->session_timeout_secs from (g) reach the session's GroupCfgSt (config->interim_update_secs/ config->session_timeout_secs, with the corresponding has_* flags) only via radius_sup_config.get_sup_config() (src/sup-config/radius.c:48-54), which is installed as vhost->config_module only when groupconfig=true (SUP_CONFIG_RADIUS, REQ-AUTH-AUTH-022) — see internal/config.md's get_sup_config()/apply_default_config() merge mechanism, which leaves a has_*-set field untouched and falls back to the vhost's stats-report-time/session-timeout otherwise (REQ-AUTH-ACCT-003). Without groupconfig=true, pctx->interim_interval_secs/session_timeout_secs are parsed from Acct-Interim-Interval/Session-Timeout but never read — a RADIUS-supplied interim/session-timeout override is silently ignored unless groupconfig=true is set. Links: REQ-AUTH-AUTH-022, REQ-AUTH-AUTH-006, REQ-AUTH-AUTH-029, REQ-AUTH-ACCT-003

REQ-AUTH-AUTH-027 — Access-Challenge round-trips the State attribute as RADIUS multi-factor; bounded by MAX_CHALLENGES

Requirement: On CHALLENGE_RC, radius_auth_pass() MUST extract the State attribute and the challenge prompt (Reply-Message) into pctx->state/pctx->pass_msg, increment pctx->id, and return ERR_AUTH_CONTINUE — UNLESS pass_msg is empty, state == NULL, or pctx->passwd_counter >= MAX_CHALLENGES (16), in which case it MUST fail with pass_msg_failed instead. The next Access-Request (REQ-AUTH-AUTH-025) MUST echo back the saved State. Strength: MUST Status: DERIVED Source: src/auth/radius.c:72-75 (CHALLENGE_RC, MAX_CHALLENGES definitions), 590-652 (challenge handling) Acceptance: positive, local — FreeRADIUS config issuing an Access-Challenge (e.g. an OTP module) with State and Reply-Message set; confirm ERR_AUTH_CONTINUE and that the follow-up Access-Request contains the same State. Negative — a server returning Access-Challenge with no State attribute; confirm pass_msg_failed/ERR_AUTH_FAIL. Negative — drive MAX_CHALLENGES + 1 challenge rounds; confirm the (MAX_CHALLENGES+1)th fails regardless of server response. Links: REQ-AUTH-AUTH-025, REQ-AUTH-AUTH-003

REQ-AUTH-AUTH-028 — Access-Reject is retried (up to MAX_PASSWORD_TRIES - 1) only at the first stage

Requirement: On any RADIUS result other than OK_RC/CHALLENGE_RC (typically Access-Reject/timeout), radius_auth_pass() MUST return ERR_AUTH_CONTINUE with pass_msg_failed (allowing the client to retry the initial password) only if pctx->passwd_counter == 0 AND pctx->retries++ < MAX_PASSWORD_TRIES - 1; otherwise (a reject occurring after at least one Access-Challenge round, or the retry budget exhausted) it MUST return ERR_AUTH_FAIL. radius_auth_funcs.allows_retries == 1. Strength: MUST Status: DERIVED Source: src/auth/radius.c:590-652 (final else branch), src/auth/common.h (MAX_PASSWORD_TRIES == 3) Acceptance: negative, local — Access-Reject on the first attempt, twice; confirm both yield ERR_AUTH_CONTINUE/pass_msg_failed (2 retries allowed), and a 3rd Access-Reject yields ERR_AUTH_FAIL. Negative — a multi-factor RADIUS flow (one Access-Challenge round, passwd_counter > 0) followed by Access-Reject; confirm immediate ERR_AUTH_FAIL (no retry), even though the overall pctx->retries count is 0. Links: REQ-AUTH-AUTH-027, REQ-AUTH-AUTH-003

REQ-AUTH-AUTH-029 — radius_auth_group validates against RADIUS-supplied groups; radius_auth_user and radius_group_list are no-ops

Requirement: radius_auth_group() MUST behave like REQ-AUTH-AUTH-016/REQ-AUTH-AUTH-021 — reject (-1) a suggested group not present in pctx->groupnames[] (populated per REQ-AUTH-AUTH-026), else select it. radius_auth_user() MUST always return -1 (RADIUS never rewrites the authenticated username). radius_auth_funcs.group_list == NULL — RADIUS provides no offline group enumeration, so select-group/auto-select-group cannot list RADIUS-only groups in advance (they are only known after a successful auth's Access-Accept). Strength: MUST Status: DERIVED Source: src/auth/radius.c:175-211 (radius_auth_group, radius_auth_user), 684-699 (radius_auth_funcs, .group_list = NULL) Acceptance: negative, local — Access-Accept with Class = "OU=eng"; request group sales; confirm PS_AUTH_FAILED per REQ-AUTH-AUTH-004. Positive — request eng; confirm acceptance. Confirm auto-select-group/select-group configuration referencing a RADIUS-only group name does not cause a startup-time enumeration failure (since group_list == NULL). [LIMITATION (gitlab#265): the "group" a client requests at connect time (req_group_name, e.g. via select-group/auto-select-groupor ahttps://fqdn/group-style path — used to pick connect-time config such as [split]routes, client profiles, or even the authentication backend) and the "group" returned by the credential backend as a *permission* (RADIUSClass/ OU=..., REQ-AUTH-AUTH-026; or the PAM/plain group list, REQ-AUTH-AUTH-016/021) are the same namespace and the same value: radius_auth_group()requiresreq_group_name(if non-empty) to already be a member ofpctx->groupnames[]— the RADIUS-returned permission groups — or the session is rejected (REQ-AUTH-AUTH-004). There is no way to select a connect-time configuration profile that is independent of, or a superset/subset of, the permission group(s) RADIUS assigns post-authentication; the only current workaround is separate vhosts (distinct FQDNs) with their ownselect-group/auth=` configuration.

A concrete (unmerged, WIP) design exists for this: gitlab!154 proposed (a) a vhost option (radius_check_config_groups in that patch) that, when set, makes radius_auth_group() validate req_group_name against the vhost's configured select-group list (config->group_list[]) instead of against pctx->groupnames[] — decoupling the connect-time "login group"/tunnel-group from RADIUS class membership entirely — and (b) a new per-radius-class-dir sup-config source consulted by radius_get_sup_config() (src/sup-config/radius.c) for each RADIUS class in pctx->groupnames[], looking up first per-radius-class-dir/$class and then per-radius-class-dir/$login_group/$class, merged into the existing config-per-group/config-per-user lookup order from REQ-CONFIG-CFG-006. gitlab!154 was closed without merging (2020, marked WIP; reviewer requested tests and README-radius.md documentation for each part, which were never completed) — its approach is a reasonable starting point but would need to be re-validated against the current auth_mod_st/config_mod_st interfaces (both have changed signatures since 2020) and given the test/doc coverage the review asked for. This would be a config-scope and possibly IPC-contract change (how req_group_name is validated and how vhost_auth_ctx/vhost_acct_ctx/per-group config are selected, REQ-AUTH-INIT-004) and needs maintainer design review per AGENTS.md's privilege-boundary and "new auth method" criteria — flagging here, not resolving unilaterally.]` Links: REQ-AUTH-AUTH-004, REQ-AUTH-AUTH-006, REQ-AUTH-AUTH-016, REQ-AUTH-AUTH-021, REQ-AUTH-AUTH-026, REQ-AUTH-INIT-004

AUTH — GSSAPI (auth = gssapi[keytab=...,require-local-user-map=...,tgt-freshness-time=...])

REQ-AUTH-AUTH-030 — gssapi[...] options: keytab=, require-local-user-map= (default true, inverted into no_local_map), tgt-freshness-time= (non-zero seconds), gid-min=

Requirement: gssapi_get_brackets_string() MUST accept keytab (path, stored verbatim), require-local-user-map (boolean; no_local_map = 1 - CHECK_TRUE(value), so the default when unset is no_local_map == 0, i.e. local-user mapping IS required by default), tgt-freshness-time (integer seconds; 0 or non-numeric MUST exit(EXIT_FAILURE) with "Invalid value for 'tgt-freshness-time'"), and gid-min (non-negative integer). Any other suboption MUST exit(EXIT_FAILURE). Strength: MUST Status: DERIVED Source: src/subconfig.c:107-150 Acceptance: negative, local — gssapi[tgt-freshness-time=0] and gssapi[tgt-freshness-time=notanumber]; both exit(EXIT_FAILURE). Positive — gssapi[require-local-user-map=false] results in no_local_map == 1 (REQ-AUTH-AUTH-033 mapping skipped); default (gssapi[] or require-local-user-map unset) results in no_local_map == 0. Links: REQ-AUTH-AUTH-033, REQ-AUTH-AUTH-034, REQ-AUTH-AUTH-035

REQ-AUTH-AUTH-031 — gssapi_vhost_init acquires credentials restricted to SPNEGO; failure is fatal

Requirement: gssapi_vhost_init() MUST call gss_acquire_cred_from() with keytab (via cred_store if keytab= is configured) or gss_acquire_cred() (default credential store otherwise), in both cases restricting desired_mechs to the single-element set {spnego_mech} (spnego_mech == OID \x2b\x06\x01\x05\x05\x02, i.e. SPNEGO). Any GSS failure during credential acquisition MUST exit(EXIT_FAILURE) (logging via print_gss_err). Strength: MUST Status: DERIVED Source: src/auth/gssapi.c:87-89 (spnego_mech, desired_mechs), 91-145 (gssapi_vhost_init) Acceptance: negative, local — configure gssapi[keytab=/nonexistent]; confirm exit(EXIT_FAILURE) at startup with a GSS error logged. Positive — a valid keytab readable by the worker's effective credentials before privilege drop (or accessible after, per deployment) starts successfully. Links: REQ-AUTH-SEC-003

REQ-AUTH-AUTH-032 — SPNEGO token exchange: base64-decode, gss_accept_sec_context, ERR_AUTH_CONTINUE while GSS_S_CONTINUE_NEEDED

Requirement: Both gssapi_auth_init() and gssapi_auth_pass() MUST base64-decode the client-supplied SPNEGO token and call gss_accept_sec_context(). While the result is GSS_S_CONTINUE_NEEDED, the function MUST return ERR_AUTH_CONTINUE and gssapi_auth_msg() MUST base64-encode the output token (pctx->msg) into pst->msg_str for the next round. On GSS_S_COMPLETE, the function MUST call get_name() (REQ-AUTH-AUTH-033) and verify_krb5_constraints() (REQ-AUTH-AUTH-034); on any other major status, it MUST return ERR_AUTH_FAIL. Strength: MUST Status: DERIVED Source: src/auth/gssapi.c:240-300 (gssapi_auth_init), 320-364 (gssapi_auth_pass), 365-384 (gssapi_auth_msg) Acceptance: positive, local — a real Kerberos/SPNEGO client (e.g. kinit + a SPNEGO-capable test client) completes a multi-round negotiation ending in PS_AUTH_COMPLETED. Negative — a malformed/non-base64 token; confirm ERR_AUTH_FAIL rather than a crash. Links: REQ-AUTH-AUTH-033, REQ-AUTH-AUTH-034

REQ-AUTH-AUTH-033 — get_name maps the GSS principal to a local username unless no_local_map

Requirement: On GSS_S_COMPLETE, get_name() MUST extract the GSS principal via gss_display_name(). If pctx->vctx->no_local_map == 0 (default — see REQ-AUTH-AUTH-030), it MUST additionally call gss_localname() to map the principal to a local username; failure of gss_localname(), or a resulting username that is empty or exceeds the username buffer, MUST cause get_name() to return -1 (authentication fails). If no_local_map == 1, the raw GSS principal name is used as the username directly. gssapi_auth_user() MUST return this (mapped or raw) username. Strength: MUST Status: DERIVED Source: src/auth/gssapi.c:155-202 (get_name), 310-319 (gssapi_auth_user) Acceptance: positive, local — with require-local-user-map = true (default) and /etc/krb5.conf auth_to_local mapping alice@REALM to local user alice; confirm the session's username is alice. Negative — a principal with no local-user mapping; confirm ERR_AUTH_FAIL. Positive — require-local-user-map = false; confirm the session's username is the raw principal (e.g. alice@REALM). Links: REQ-AUTH-AUTH-030, REQ-AUTH-AUTH-032

REQ-AUTH-AUTH-034 — Kerberos ticket freshness check (tgt-freshness-time)

Requirement: verify_krb5_constraints() MUST be a no-op unless the negotiated mechanism is krb5 or krb5_old AND pctx->vctx->ticket_freshness_secs != 0 (REQ-AUTH-AUTH-030). When active, it MUST extract the ticket's authtime via gsskrb5_extract_authtime_from_sec_context() and reject (return -1, yielding ERR_AUTH_FAIL) if time(NULL) > authtime + ticket_freshness_secs — i.e. the client's TGT/ticket must have been obtained recently enough. Strength: MUST Status: DERIVED Source: src/auth/gssapi.c:203-239 Acceptance: positive, local — tgt-freshness-time = 3600; authenticate with a freshly-obtained Kerberos ticket; confirm success. Negative — present a ticket whose authtime is older than 3600 seconds (e.g. via a test KDC with a manipulated clock, or kinit followed by a sleep exceeding the threshold in a local test setup); confirm ERR_AUTH_FAIL. Confirm gssapi[] with no tgt-freshness-time (or a non-krb5 SPNEGO mech, e.g. NTLM-over-SPNEGO if applicable) performs no freshness check. Links: REQ-AUTH-AUTH-030, REQ-AUTH-AUTH-032

REQ-AUTH-AUTH-035 — gssapi_auth_group/gssapi_group_list use the OS group database, filtered by gid-min; GSSAPI has no allows_retries

Requirement: gssapi_auth_group() MUST delegate to get_user_auth_group() like REQ-AUTH-AUTH-021. gssapi_group_list() MUST call unix_group_list(pool, gid_min, ...). gssapi_auth_funcs does not set .allows_retries (defaults to 0) — a failed SPNEGO exchange MUST NOT add ban_points_wrong_password via REQ-AUTH-AUTH-003's retry-scoring path (GSSAPI failures are scored, if at all, only via the generic SEC_AUTH_REP(FAILED) path in REQ-AUTH-AUTH-004's caller, not the allows_retries branch). Strength: MUST Status: DERIVED Source: src/auth/gssapi.c:301-309 (gssapi_auth_group), 396-407 (gssapi_group_list), 408-422 (gssapi_auth_funcs, no .allows_retries) Acceptance: unit, local — gid-min = 1000 excludes low-GID groups from select-group enumeration, as REQ-AUTH-AUTH-021. Confirm (by code inspection or a mock module) that a GSS_S_* failure in gssapi_auth_pass does not pass through the passwd_retries == 1 && allows_retries branch of REQ-AUTH-AUTH-003. Links: REQ-AUTH-AUTH-003, REQ-AUTH-AUTH-021, REQ-AUTH-AUTH-030

AUTH — OIDC (auth = oidc[config=<path-to-json>], requires SUPPORT_OIDC_AUTH)

REQ-AUTH-AUTH-036 — oidc[...] points at a JSON config file requiring openid_configuration_url, required_claims, user_name_claim; missing keys are fatal

Requirement: oidc_get_brackets_string() returns the configured path; oidc_vhost_init() MUST json_load_file() it and exit(EXIT_FAILURE) (via mslog/fprintf + exit) if the JSON cannot be parsed, or if any of openid_configuration_url, required_claims, user_name_claim is absent. minimum_jwk_refresh_time defaults to MINIMUM_KEY_REFRESH_INTERVAL (900 seconds) if not configured. oidc_vhost_init() MUST then call oidc_fetch_oidc_keys() (REQ-AUTH-AUTH-037) and exit(EXIT_FAILURE) if it fails — an unreachable OIDC provider at startup is fatal, not deferred. Strength: MUST Status: DERIVED Source: src/subconfig.c:369+ (oidc_get_brackets_string); src/auth/openidconnect.c:36 (MINIMUM_KEY_REFRESH_INTERVAL), 57-119 (oidc_vhost_init) Acceptance: negative, local — an oidc config JSON missing required_claims; confirm ocserv exits at startup with "config file missing required_claims". Missing user_name_claim: analogous message. An unreachable openid_configuration_url: exit(EXIT_FAILURE) at startup. Links: REQ-AUTH-AUTH-037, REQ-AUTH-AUTH-038

REQ-AUTH-AUTH-037 — JWKS are fetched from the provider's jwks_uri and refreshed on unknown kid, throttled by minimum_jwk_refresh_time

Requirement: oidc_fetch_oidc_keys() MUST download openid_configuration_url (curl), extract jwks_uri, download it, and store the keys array plus last_jwks_load_time = time(NULL). During signature verification (REQ-AUTH-AUTH-038), if the token's kid is not found in the cached JWKS, oidc_verify_signature() MUST re-fetch the JWKS — but only if now - last_jwks_load_time > minimum_jwk_refresh_time (REQ-AUTH-AUTH-036) — to avoid a refresh storm from repeated requests with an unknown/forged kid. Strength: MUST Status: DERIVED Source: src/auth/openidconnect.c:243-361 (oidc_fetch_json_from_uri, oidc_fetch_oidc_keys), 532-627 (oidc_verify_signature, refresh-throttle check around line 601) Acceptance: positive, local — rotate the OIDC provider's signing key (new kid); present a token signed with the new key; confirm the first attempt within minimum_jwk_refresh_time of the last fetch still succeeds (re-fetch happens) — then confirm a second token with yet another unknown kid arriving before minimum_jwk_refresh_time elapses since that refresh is rejected (no refresh storm). [REVIEW: confirm the throttle window's start point — last successful fetch vs. last attempted fetch.] Links: REQ-AUTH-AUTH-038

REQ-AUTH-AUTH-038 — Token verification pipeline: signature → lifetime → required claims → username mapping

Requirement: oidc_verify_token() MUST, in order: (1) cjose_jws_import() the bearer token as a JWS; (2) oidc_verify_signature() — find the JWK by kid (REQ-AUTH-AUTH-037) and cjose_jws_verify(); (3) oidc_extract_claims() — parse the JWS payload as JSON; (4) oidc_verify_lifetime()nbf, iat, and exp claims MUST all be present, and the current time MUST be within [nbf, exp]; (5) oidc_verify_required_claims() — every key configured in required_claims MUST be present in the token claims and json_equal the configured value; (6) oidc_map_user_name() — the claim named by user_name_claim MUST be present and a non-empty string, copied into user_name. Any failure at any stage MUST abort the pipeline with token_verified == false. Strength: MUST Status: DERIVED Source: src/auth/openidconnect.c:407-448 (oidc_verify_lifetime), 449-479 (oidc_verify_required_claims), 480-502 (oidc_map_user_name), 503-531 (oidc_extract_claims), 532-627 (oidc_verify_signature), 628-695 (oidc_verify_token) Acceptance: [SEC] negative — for each stage, construct a token that fails only that stage (bad signature; exp in the past; missing a required_claims key; missing user_name_claim) and confirm oidc_auth_init returns ERR_AUTH_FAIL in every case. Positive — a token valid on all five checks completes authentication with e->acct_info.username equal to the user_name_claim value. Links: REQ-AUTH-AUTH-036, REQ-AUTH-AUTH-037, REQ-AUTH-AUTH-039

REQ-AUTH-AUTH-039 — OIDC is single-step: the bearer token is submitted as the "username" in auth_init; auth_pass always fails

Requirement: oidc_auth_init() MUST treat info->username (the field populated from the worker's SEC_AUTH_INIT) as the OIDC bearer token, call oidc_verify_token() (REQ-AUTH-AUTH-038), and return 0 if token_verified, else ERR_AUTH_FAIL — there is no ERR_AUTH_CONTINUE round. oidc_auth_pass() MUST always return ERR_AUTH_FAIL (it exists only to satisfy the vtable; OIDC never reaches a password stage). oidc_auth_user() MUST return the user_name extracted in REQ-AUTH-AUTH-038 if token_verified, else fail. Strength: MUST Status: DERIVED Source: src/auth/openidconnect.c:139-160 (oidc_auth_init), 161-171 (oidc_auth_user), 172-176 (oidc_auth_pass) Acceptance: positive, local — a valid bearer token in the initial SEC_AUTH_INIT yields PS_AUTH_COMPLETED (modulo REQ-AUTH-AUTH-004's group check) without any SEC_AUTH_CONT round. [SEC] negative — confirm that no sequence of SEC_AUTH_CONT messages can reach PS_AUTH_COMPLETED for OIDC (i.e. oidc_auth_pass is unreachable-success by construction). Links: REQ-AUTH-AUTH-038, REQ-AUTH-SEC-002

REQ-AUTH-AUTH-040 — OIDC has no group support; allows_retries == 1

Requirement: oidc_auth_funcs.auth_group == NULL and oidc_auth_funcs.group_list == NULL — OIDC tokens carry no group concept in this implementation, so REQ-AUTH-AUTH-004's check_group() skips the module-auth_group step entirely for OIDC sessions (falling through to the certificate-group fallback of REQ-AUTH-AUTH-006 only if certificate is also combined). oidc_auth_funcs.allows_retries == 1: a failed token verification on a retried SEC_AUTH_INIT (new connection attempt with a different token) is scored via REQ-AUTH-AUTH-003 like other password-based methods, even though OIDC itself has no SEC_AUTH_CONT rounds. Strength: MUST Status: DERIVED Source: src/auth/openidconnect.c:190-210 (oidc_auth_funcs) Acceptance: unit, local — auth = oidc[...] with no enable-auth; confirm select-group/auto-select-group configuration referencing any group name is rejected or ignored at config time for an OIDC-only vhost (no group_list to validate against). Confirm two SEC_AUTH_INIT attempts with invalid tokens add ban_points_wrong_password twice (REQ-AUTH-AUTH-003). Links: REQ-AUTH-AUTH-003, REQ-AUTH-AUTH-006, REQ-AUTH-AUTH-038

ACCT — accounting method selection

REQ-AUTH-ACCT-001 — acct= selects exactly one of radius/pam, compatible with the configured auth= type

Requirement: figure_acct_funcs() MUST accept acct = radius[...] or acct = pam (only one acct= per vhost — later occurrences overwrite earlier ones via config->acct), exit(EXIT_FAILURE) for any other value. Both radius_acct_funcs and pam_acct_funcs declare .auth_types = ALL_AUTH_TYPES, so (avail_acct_types[i].mod->auth_types & config->auth[0].type) == 0 is currently unreachable for these two — but the check exists as a general compatibility gate for future accounting modules with restricted auth_types, and figure_acct_funcs() MUST exit(EXIT_FAILURE) with "you cannot mix the '%s' accounting method with the '%s' authentication method" if it is ever violated. Strength: MUST Status: DERIVED Source: src/config.c:440-509 (avail_acct_types[], figure_acct_funcs); src/acct/pam.c:91-93, src/acct/radius.c:314-316 (.auth_types = ALL_AUTH_TYPES) Acceptance: positive, local — auth = pam[...], acct = pam; starts successfully and accounting records are written via PAM session modules. Negative — acct = unknownmethod; confirm "unknown or unsupported accounting method". [NOTE: the auth/acct type-compatibility branch (config.c:482-489) is confirmed unreachable with the current avail_acct_types[]/avail_auth_types[]and is not separately tested.ALL_AUTH_TYPES(src/vpn.h:140-143) is(AUTH_TYPE_PAM | AUTH_TYPE_PLAIN | AUTH_TYPE_CERTIFICATE | AUTH_TYPE_RADIUS | AUTH_TYPE_GSSAPI | AUTH_TYPE_OIDC) & ~AUTH_TYPE_USERNAME_PASS— i.e. every bit used by anyavail_auth_types[]entry exceptAUTH_TYPE_USERNAME_PASS(src/vpn.h:132) itself, which never appears alone in any entry's.type (AUTH_TYPE_PAM/PLAIN/RADIUSall OR it together with a bit that IS inALL_AUTH_TYPES). Consequently config->auth[0].type— built by ORing together one or moreavail_auth_types[]entries'.typevalues (REQ-AUTH-INIT-002) — always has at least one bit inALL_AUTH_TYPES, so avail_acct_types[i].mod->auth_types & config->auth[0].typeis nonzero for bothradius_acct_funcsandpam_acct_funcs (.auth_types = ALL_AUTH_TYPES) regardless of which auth=method(s) are configured. The branch is therefore intentional forward-compatibility dead code: it only becomes reachable if a future accounting module declares.auth_typeswith a strict subset ofALL_AUTH_TYPES that excludes all of the vhost's configured auth method bits. No test is written for it now; add the negative test described above only when such a module is introduced.] Links: REQ-AUTH-INIT-002, REQ-AUTH-INIT-003, REQ-AUTH-ACCT-002, REQ-AUTH-ACCT-003, REQ-AUTH-ACCT-004

ACCT — RADIUS accounting (acct = radius[config=...])

REQ-AUTH-ACCT-002 — Every RADIUS Accounting-Request carries a common attribute set

Requirement: append_acct_standard() MUST add to every PW_ACCOUNTING_REQUEST (Start, Interim-Update, and Stop): NAS-Identifier (if nas-identifier= configured, REQ-AUTH-AUTH-022), NAS-IP-Address/NAS-IPv6-Address (from e->acct_info.our_ip, whichever family parses), User-Name, Service-Type = Framed, Framed-Protocol = PPP, Framed-IP-Address/Framed-IPv6-Address (if the session has an assigned ipv4/ipv6), Calling-Station-Id (e->acct_info.remote_ip), and Acct-Session-Id (e->acct_info.safe_id), and Acct-Authentic = RADIUS. append_stats() MUST additionally add (when called, i.e. for Interim-Update and Stop) Acct-Session-Time (if the uptime value passed by the caller — ai->uptime, computed by sec-mod per REQ-AUTH-ACCT-007 — is nonzero), Acct-Input-Octets, Acct-Output-Octets, Acct-Input-Gigawords, and Acct-Output-Gigawords (the gigawords attributes are always sent, even when 0, to disambiguate a genuinely-zero count from a wrapped 32-bit counter). Strength: MUST Status: DERIVED Source: src/acct/radius.c:91-177 (append_stats, append_acct_standard); src/sec-mod-auth.c:656-660,771-772 (ai->uptime computation) Acceptance: positive, local — with a test RADIUS server logging received attributes, open a session and confirm the Start Accounting-Request contains User-Name, Acct-Session-Id, Service-Type=Framed, Framed-Protocol=PPP, Acct-Authentic=RADIUS, and (if the session was assigned an IPv4 address) Framed-IP-Address. Confirm a Stop request additionally contains Acct-Input-Octets/Acct-Output-Octets and Acct-Input-Gigawords/Acct-Output-Gigawords (the latter present and 0 for a session under 4 GiB). Links: REQ-AUTH-ACCT-001, REQ-AUTH-AUTH-022, REQ-AUTH-ACCT-003, REQ-AUTH-ACCT-004

REQ-AUTH-ACCT-003 — Accounting-Request lifecycle: Start/Interim-Update/Stop map to session open/periodic-stats/close

Requirement: radius_acct_funcs.open_session MUST send a PW_STATUS_START Accounting-Request when sec-mod first opens the session (e->session_is_open == 0 on SECM_SESSION_OPEN, gated so a roaming re-open does not re-send Start), including Connect-Info (the client's User-Agent) if non-empty. radius_acct_funcs.session_stats MUST send a PW_STATUS_ALIVE (Interim-Update) Accounting-Request whenever sec-mod receives CMD_SEC_CLI_STATS from the worker; the worker sends this periodically based on ws->user_config->interim_update_secs (src/worker-vpn.c:1402-1403), which defaults to the vhost's stats-report-time (src/main-sec-mod-cmd.c:373-376) unless a RADIUS Acct-Interim-Interval was received during authentication AND groupconfig=true is set (REQ-AUTH-AUTH-026(g)). radius_acct_funcs.close_session MUST send a PW_STATUS_STOP Accounting-Request on CMD_SECM_SESSION_CLOSE, with Acct-Terminate-Cause set from e->discon_reason: REASON_USER_DISCONNECTUser-Request, REASON_SERVER_DISCONNECTAdmin-Reset, REASON_IDLE_TIMEOUTIdle-Timeout, REASON_SESSION_TIMEOUTSession-Timeout, REASON_DPD_TIMEOUTLost-Carrier, REASON_ERRORUser-Error, and any other/unset reason→Lost-Service. Strength: MUST Status: DERIVED Source: src/acct/radius.c:215-312 (radius_acct_open_session, radius_acct_session_stats, radius_acct_close_session); src/sec-mod-auth.c:545-559 (session_is_open gating of open_session), 715-777 (handle_sec_auth_stats_cmdsession_stats), 1020-1040 (close_session on CMD_SECM_SESSION_CLOSE) Acceptance: positive, local — connect a client, confirm Start Accounting-Request; reconnect/roam without a full re-auth and confirm Start is NOT re-sent (session_is_open already 1); wait past stats-report-time and confirm an Interim-Update (Acct-Status-Type=Interim-Update) is sent with current byte counters. Disconnect the client normally; confirm Stop with Acct-Terminate-Cause=User-Request. Negative — kill the worker process abruptly (no CMD_SECM_SESSION_CLOSE from a clean disconnect path); confirm behavior matches proc->invalidated/server_disconnected handling in session_close() (src/main-sec-mod-cmd.c:738-781) — i.e. a Stop is still sent by main's session-close path, not silently dropped. Links: REQ-AUTH-ACCT-002, REQ-AUTH-AUTH-026

REQ-AUTH-ACCT-004 — NAS-Port is intentionally never sent in RADIUS auth or accounting requests

Requirement: Neither src/auth/radius.c nor src/acct/radius.c MUST add a PW_NAS_PORT (attribute 5) attribute to any Access-Request or Accounting-Request. This is a deliberate compatibility decision (since ocserv 1.0.0): a prior NAS-Port value derived from the worker PID changed on client roaming/reconnect, breaking RADIUS accounting correlation. Because default FreeRADIUS configurations use NAS-Port in acct_unique, an administrator enabling RADIUS accounting against a default FreeRADIUS server MUST remove NAS-Port from that server's acct_unique computation (see doc/README-radius.md). Strength: MUST NOT Status: DERIVED Source: src/auth/radius.c (no PW_NAS_PORT reference); src/acct/radius.c (no PW_NAS_PORT reference); doc/README-radius.md (compatibility note) Acceptance: negative, local — capture Access-Request and Accounting-Request traffic against a test RADIUS server; confirm no NAS-Port (attribute 5) attribute is present in any request. Cross-reference doc/README-radius.md's acct_unique guidance. Links: REQ-AUTH-ACCT-002, REQ-AUTH-ACCT-003

Requirement: Acct-Session-Time MUST represent the wall-clock lifetime of the logical session — from the initial authentication (e->created) to the last activity — not the sum of connected time across individual TCP/DTLS connections. A single logical session spans all reconnections performed under the same cookie (roaming, DTLS rekey, a brief link loss, new-tunnel rekey), so an idle gap between such reconnections is included in Acct-Session-Time. sec-mod MUST compute this value directly as now - e->created, live for each Interim-Update (handle_sec_auth_stats_cmd) and snapshotted at disconnect for the Stop (handle_secm_session_close_cmd) so that a session with no worker currently attached does not keep accruing time while it lingers within cookie-timeout. In particular sec-mod MUST NOT derive this value by summing per-segment uptimes reported by the worker or main (each such report is itself already cumulative since e->created, since session_start_time is not reset across reconnects — summing them inflates the reported value roughly linearly with the number of reconnects). The value is bounded above by session-timeout (enforced on the same quantity, e->created), so a session that runs its full term reports Acct-Session-Timesession-timeout; any idle gap folded in by a reconnect is bounded by cookie-timeout, since a longer gap expires the cookie and forces a fresh authentication, starting a new accounting session with a new SID. Strength: MUST Status: DERIVED Source: src/sec-mod-auth.c:656-660 (Stop snapshot), 771-772 (Interim-Update live value); doc/README-radius.md (definition); doc/sample.config (note next to stats-report-time) Acceptance: positive, local (tests/radius-reconnect-acct) — drive three cookie-resumed segments (simulating reconnects via SIGKILL between segments, as tests/test-cookie-timeout does) with idle gaps in between, then a clean final disconnect; confirm the Stop's Acct-Session-Time is close to the actual elapsed wall-clock time from first connect to final disconnect, not the sum of the individual segments' cumulative uptimes (this is also the regression test for the summation bug this requirement documents the fix for). Negative — the same test's tolerance window excludes the pre-fix summed value for a 3-segment timeline. Links: REQ-AUTH-ACCT-002, REQ-AUTH-ACCT-003, REQ-IPC-032

ACCT — PAM accounting (acct = pam)

REQ-AUTH-ACCT-005 — acct = pam's open_session is a non-interactive pam_acct_mgmt() check; failure denies the session even after successful authentication

Requirement: pam_acct_open_session() MUST fail (return negative) if ai->username is empty, logging "PAM-acct: no username present". Otherwise it MUST pam_start(PACKAGE, ai->username, &dc, &ph) with a conversation function (ocserv_conv) that unconditionally returns PAM_SUCCESS with *resp = NULL — i.e. it answers no prompts and cannot drive an interactive PAM stack — then call pam_acct_mgmt(ph, PAM_DISALLOW_NULL_AUTHTOK). A non-PAM_SUCCESS result from either pam_start or pam_acct_mgmt MUST cause pam_acct_open_session to return negative; pam_end(ph, pret) MUST be called on both the success and the pam_acct_mgmt failure path. When acct = pam and e->session_is_open == 0, sec-mod's SECM_SESSION_OPEN handler (handle_sec_auth_session_open) MUST treat a negative open_session return the same as REQ-AUTH-ACCT-003's session-open path treats any other denial: set e->status = PS_AUTH_FAILED, log "denied session for user '%s'", and reply with a failed session-open response — i.e. a PAM account restriction (locked account, expired account, time-of-day restriction, etc.) discovered only at session-open time MUST still deny the session, even though auth_pass (REQ-AUTH-AUTH-019/020) already succeeded. Strength: MUST Status: DERIVED Source: src/acct/pam.c:40-83 (ocserv_conv, pam_acct_open_session), 91-96 (pam_acct_funcs); src/sec-mod-auth.c:545-558 (SECM_SESSION_OPEN handling of open_session < 0) Acceptance: positive, local — auth = pam[...], acct = pam, a PAM service whose account stack succeeds; confirm the session opens normally. Negative — configure the PAM service's account stack to fail for the test user (e.g. pam_succeed_if account deny or an expired/locked account via chage/passwd -l); authenticate successfully (password stage passes) and confirm SECM_SESSION_OPEN is still rejected (PS_AUTH_FAILED, "denied session for user") even though auth_pass returned success. Links: REQ-AUTH-ACCT-001, REQ-AUTH-AUTH-004, REQ-AUTH-AUTH-019, REQ-AUTH-ACCT-006

REQ-AUTH-ACCT-006 — acct = pam has no interim-update reporting and close_session does not invoke any PAM session module

Requirement: pam_acct_funcs.session_stats MUST be NULL — unlike acct = radius (REQ-AUTH-ACCT-003), no Acct-Interim-Interval-style periodic report is produced for acct = pam, and CMD_SEC_CLI_STATS from the worker has no PAM-side effect. pam_acct_close_session() MUST be an empty function (no body): on CMD_SECM_SESSION_CLOSE / sec_auth_user_deinit, acct.amod->close_session() is still called when session_is_open != 0 (src/sec-mod-auth.c:1030-1036), but for PAM this performs no PAM operation — in particular it MUST NOT call pam_open_session()/pam_close_session(), so PAM session modules (e.g. pam_lastlog, pam_limits, pam_systemd) listed in the service's session stack are never invoked by ocserv's PAM accounting, and final session statistics (stats_st) passed to close_session are discarded. Strength: MUST Status: DERIVED Source: src/acct/pam.c:85-96 (pam_acct_close_session, pam_acct_funcs); src/sec-mod-auth.c:1030-1036 (close_session invocation) Acceptance: unit/positive, local — configure a PAM service with a session stack entry that has an observable side effect (e.g. a pam_exec line writing to a file on session open/close); complete a full session lifecycle with acct = pam; confirm the session-stack side effect never occurs, and that no Interim-Update-equivalent log/record is produced between session open and close (contrast with REQ-AUTH-ACCT-003's RADIUS Interim-Update). Links: REQ-AUTH-ACCT-001, REQ-AUTH-ACCT-003, REQ-AUTH-ACCT-005

SEC — cross-cutting negative requirements

Per doc/requirements/README.md, negative requirements are mandatory for AUTH and SEC categories. Most per-method negatives are embedded above; the following are method-composition or cross-method invariants.

REQ-AUTH-SEC-001 — Password-based authentication methods (pam, plain, radius) MUST NOT be combined with each other

Requirement: Exactly one method with AUTH_TYPE_USERNAME_PASS set may be active for a vhost. In auth= (AND composition, REQ-AUTH-INIT-002), this is enforced because at most one listed method may have a non-NULL amod (pam/plain/radius all have non-NULL amod; only certificate has NULL) — a second password module triggers "you cannot mix multiple authentication methods of %s type". Across auth= + enable-auth= alternatives (OR composition, REQ-AUTH-INIT-003), check_for_duplicate_password_auth() enforces the same invariant via "you cannot mix multiple password authentication methods". gssapi, oidc, and certificate do not set AUTH_TYPE_USERNAME_PASS and are unaffected by this restriction. Strength: MUST NOT Status: DERIVED Source: src/config.c:269-288, 322-329 Acceptance: [SEC] negative — covered by REQ-AUTH-INIT-002 and REQ-AUTH-INIT-003's negative acceptance cases. Additionally: auth = certificate+pam, enable-auth = radius; confirm rejection (both pam and radius have AUTH_TYPE_USERNAME_PASS). Links: REQ-AUTH-INIT-002, REQ-AUTH-INIT-003

REQ-AUTH-SEC-002 — OIDC's password step (auth_pass) MUST NOT be able to complete authentication

Requirement: Because oidc_auth_pass() is hardcoded to return ERR_AUTH_FAIL unconditionally (REQ-AUTH-AUTH-039), there is no code path by which a SEC_AUTH_CONT message — regardless of its contents — can cause an OIDC session to reach PS_AUTH_COMPLETED. Only auth_init's token verification (REQ-AUTH-AUTH-038) can do so. Strength: MUST NOT Status: DERIVED Source: src/auth/openidconnect.c:172-176 Acceptance: [SEC] negative — send SEC_AUTH_INIT with an invalid token followed by an arbitrary SEC_AUTH_CONT (any payload); confirm the session never reaches PS_AUTH_COMPLETED and auth_pass is never the source of a 0 return for OIDC. Links: REQ-AUTH-AUTH-039

REQ-AUTH-SEC-003 — GSSAPI credential acquisition MUST be restricted to SPNEGO; no other GSS mechanism is negotiable

Requirement: gssapi_vhost_init() restricts gss_acquire_cred(_from) to desired_mechs = {spnego_mech} (REQ-AUTH-AUTH-031). Consequently gss_accept_sec_context() (REQ-AUTH-AUTH-032) MUST NOT successfully negotiate any mechanism the acquired credential does not advertise — a client offering a non-SPNEGO mechanism directly (bypassing SPNEGO's mechanism negotiation) MUST fail gss_accept_sec_context(). Strength: MUST NOT Status: DERIVED Source: src/auth/gssapi.c:87-89, 91-145 Acceptance: [SEC] negative — a GSS client attempting raw Kerberos (non-SPNEGO-wrapped) gss_init_sec_context against the worker's GSSAPI endpoint; confirm gss_accept_sec_context fails (ERR_AUTH_FAIL) rather than silently accepting a non-SPNEGO mechanism. Links: REQ-AUTH-AUTH-031, REQ-AUTH-AUTH-032