================================================================================
  Fieldbus Simulation Secure Tunnel - Specification

  Module:     Network-independent secure-tunnel core (SOFA variant)
  Files:      shared/fbsec_config.h                  (compile-time config)
              shared/fbsec_aead.{c,h}                (AEAD primitives)
              shared/fbsec_hkdf.{c,h}                (HKDF-SHA256)
              shared/fbsec_secure_proto.{c,h}        (client-side library)
              shared/fbsec_secure_od.{c,h}           (server-side library)
              shared/mbedtls_fbsec_config.h       (mbedTLS subset)
  Version:    V1.0 of 08-MAY-2026
  License:    Apache License, Version 2.0
              www.apache.org/licenses/LICENSE-2.0
  Author:     Embedded Systems Academy, Inc (USA) and GmbH (Germany)
================================================================================


--------------------------------------------------------------------------------
1. PURPOSE AND SCOPE
--------------------------------------------------------------------------------

  This document pins the cryptographic contract between the SOFA
  client and server when secure verbs (srd, swr) are in use. The
  contract is variant-neutral: every per-fieldbus variant (CANopen FD
  today; future generic / CANopen CC / EtherCAT) plugs its own
  transport carrier underneath the same AAD construction.

  The SOFA secure tunnel is a port of the MCOPSecureAccess
  client-side library (Utils/secure_client/secure_protocol.{c,h})
  and server-side library (MCO_CiA401__User_Secure/secure_od.{c,h}),
  with three intentional changes:

    1. AAD widening: the legacy 4-byte addressing field
         (uint8 node_id, uint16 index, uint8 subindex)
       is replaced by a variant-sized addressing field
         (server_id, client_id, data_id), where server_id and
         client_id are each FBSEC_AEAD_DEV_ID_SIZE bytes (1 byte
         on CANopen FD, 2 bytes on future uint16 device_id variants).

    2. Compile-time configuration: shared/fbsec_config.h selects the
       AEAD primitive (AES-128-GCM or AES-256-GCM, with reserved
       slots for Ascon-128 and ChaCha20-Poly1305), the KDF (HKDF-
       SHA256), the truncated tag length (4..16 bytes for AES-GCM),
       the AEAD mode (encrypt-and-authenticate vs auth-only), and
       the per-(key, session) frame budget FBSEC_AEAD_KEY_USE_LIMIT.

    3. Real AEAD by default: FBSEC_AEAD_ENCRYPTION = 1 in the default
       config. The plaintext goes through GCM and only ciphertext
       appears on the wire. Auth-only mode stays available as a
       legacy / debugging option.

  Single in-the-field protocol revision: the protocol-version byte
  in the AAD prefix is FBSEC_AEAD_PROTOCOL_VERSION = 0x01. A future
  version bump fails-closed against a 0x01 peer, which is the
  intended outcome until a co-ordinated upgrade lands.

  License unification: aead.{c,h} re-implemented under Apache 2.0 to
  keep the entire SOFA tree under one license. Behavior is byte-
  identical to the legacy library given the same AAD inputs.


--------------------------------------------------------------------------------
1.1 STANDARDS ALIGNMENT
--------------------------------------------------------------------------------

  SOFA is designed to be compatible with the following industrial
  cybersecurity references:

    - ISO/IEC 9798 (Entity Authentication)
        SOFA's secure read and write follow the shape of ISO/IEC
        9798-2 Mechanism 4 (three-pass mutual authentication using
        random challenges), truncated to two passes so that
        authentication is unilateral by construction. Both peers
        still contribute a random per single-shot transfer, which
        preserves the mutual-randomness pattern that diversifies
        the nonce. SOFA deviates from 9798-2 in five named ways
        (mirroring EmSA-WP-105 section 9.1):
          (i)   the third pass that would close mutual authentication
                is dropped, leaving a unilateral exchange;
          (ii)  the bare MAC of 9798-2 is replaced by an AEAD
                primitive that subsumes the MAC, adds optional
                confidentiality and requires an explicit nonce that
                9798 does not specify;
          (iii) the AEAD nonce is built from a base value and a
                strictly monotonic counter, a construction 9798
                leaves open;
          (iv)  entity identifiers and addressing context are carried
                in the AEAD's Associated Data rather than concatenated
                into a MAC input;
          (v)   a counter extension lets a single challenge cover a
                long run of cyclic continuations without a fresh
                round trip, an extension 9798-2 does not describe.

    - EU Cyber Resilience Act (Regulation (EU) 2024/2847)
        SOFA provides the minimum-viable secure-by-default behavior
        the CRA expects from a connected industrial component: AEAD
        protection of every secure verb, no plaintext fallback on the
        wire, freshness via per-session random + monotonic counter,
        bounded replay window via wire-counter cross-check, and a
        configurable encryption-vs-authentication-only knob so
        deployments can match their threat model. The protocol-version
        byte in the AAD prefix and the per-key role identifier give
        upgrade-without-downgrade guarantees that align with CRA
        Article 13's "secure update" expectations.

    - IEC 62443 (Industrial communication networks, Security)
        The (device_id, data_id, key_id) triple in the AAD prefix
        supports IEC 62443-3-3's CR 1.x (identification and
        authentication) and CR 3.x (system integrity) requirements
        per-frame. The per-entry SECURE_RO / SECURE_WO access flags
        and per-key role (Provisioning Session Key vs Integrator
        Session Key in the demo) map onto IEC 62443-4-2's role-based
        access for embedded
        devices. The cyclic-mode session boundary (arm + idle
        timeout + per-key use limit) is the unit of authentication
        episode that 62443-3-3 SR 1.13 expects.

    - ISO/IEC 9797-1 (MAC mechanisms using a block cipher)
        AES-GCM is a counter-mode encryption coupled with a GHASH-
        based MAC. The integrity tag is a 9797-1-class MAC over the
        AAD || ciphertext input; the FBSEC_AEAD_TAG_LEN_BYTES knob
        (4..16, default 8) maps onto 9797-1's truncated-MAC-output
        variants. With FBSEC_AEAD_ENCRYPTION = 0 the construction
        reduces to a pure 9797-1 MAC over an AAD-bound payload,
        useful for deployments that need integrity without
        confidentiality (debug builds, plaintext-on-the-wire
        regulatory regimes).

    - RFC 4279 / RFC 8446 §2.2 (TLS Pre-Shared Key) and IETF cTLS
      (Compact TLS, draft-ietf-tls-ctls)
        SOFA is functionally a stripped-down TLS-PSK record layer
        for fieldbus-grade footprints. Shared design points:
          * pre-shared symmetric secret with no PKI, no DHE / ECDHE,
            no certificate exchange;
          * identity selector on the wire (TLS-PSK psk_identity =
            SOFA wire keyid byte) authenticated into the AEAD;
          * HKDF-SHA256 session-key derivation from the PSK
            (RFC 5869, info string "FBSEC-SK-v1" || NUL || keyid);
          * AEAD record protection per frame (TLS 1.3 AES-GCM =
            SOFA AES-128-GCM by default).
        SOFA collapses TLS's multi-pass handshake to one challenge-
        response per single-shot transfer, and to one arming round-
        trip plus polled per-frame AEAD for cyclic mode (section
        11.1). This is the same compaction philosophy that motivates
        cTLS for constrained devices, applied here at the fieldbus
        addressing layer rather than the TCP record layer.
        Deliberate non-alignments: no session resumption (key slots
        are write-once-per-process; rotate by re-provisioning), no
        TLS 1.3 static-IV-XOR-sequence-number nonce (SOFA uses a
        mutual-random XOR base plus a counter increment uniformly
        for single-shot and cyclic), no version negotiation
        (FBSEC_AEAD_PROTOCOL_VERSION = 0x01 fails-closed against any
        future bump). This is intentional: a fieldbus secure tunnel
        is not a session protocol, and SOFA stops short of the
        TLS-PSK feature surface that would force one.

  The reference texts above set the requirements; SOFA is the
  reference implementation that demonstrates how to meet them on a
  small fieldbus-grade footprint.


--------------------------------------------------------------------------------
2. CRYPTOGRAPHIC PARAMETERS
--------------------------------------------------------------------------------

  All four parameters are pinned at compile time via
  shared/fbsec_config.h. Both client and server pick up the same
  config; mismatched configs between peers produce a clean
  "tag verify failed" on the first secure verb.

       Knob                  Default          Allowed values
       --------------------  ---------------  ------------------------
       FBSEC_AEAD_AES128_GCM   1                exactly one AEAD = 1
       FBSEC_AEAD_AES256_GCM   0
       FBSEC_AEAD_ASCON_128    0                reserved (build error)
       FBSEC_AEAD_CHACHA20_    0                reserved (build error)
                  POLY1305

       FBSEC_KDF_HKDF_SHA256   1                exactly one KDF = 1

       FBSEC_AEAD_TAG_LEN_     8                AES-GCM: 4..16
                  BYTES                       Ascon-128: 16 only

       FBSEC_AEAD_ENCRYPTION   1                0 = auth-only
                                              1 = encrypt-and-auth

       FBSEC_AEAD_RANDOM_SIZE 12                >= FBSEC_AEAD_NONCE_SIZE
                                              (per-peer challenge size; both
                                              client and server contribute
                                              this many bytes per single-
                                              shot transfer; nonce =
                                              client_random[0..11] XOR
                                              server_random[0..11])

       FBSEC_AEAD_DEV_ID_SIZE  1                1 (CANopen FD: node_id 1..127,
                                              current default) or 2 (uint16
                                              device_id, reserved for the future
                                              generic / CANopen CC / EtherCAT
                                              variants); fixes the width of the
                                              server_id and client_id fields in
                                              the AAD prefix. See section 4.1.
                                              Both ends of a session must agree;
                                              mismatched values produce a
                                              clean tag-verify failure on
                                              the first secure verb.

  Constants derived in fbsec_aead.h from the above:

       AES-128-GCM:   key 16, nonce 12, primitive_id 0x01
       AES-256-GCM:   key 32, nonce 12, primitive_id 0x02

  Other sizes:

       FBSEC_AEAD_RAND_SIZE          = FBSEC_AEAD_RANDOM_SIZE
       FBSEC_AEAD_MAX_PROTECTED        32
       FBSEC_AEAD_AAD_PREFIX_SIZE     12 (CANopen FD, current) / 14 (uint16 device_id, future)
       FBSEC_AEAD_PROTOCOL_VERSION   0x01


--------------------------------------------------------------------------------
3. MECHANISM BYTE AND WIRE KEYID BYTE
--------------------------------------------------------------------------------

  The mechanism byte (offset 1 of the AAD prefix) encodes the
  (encryption, primitive) pair as a high-bit toggle:

       bit  7    : encryption mode
                   0 = authenticate-only (data in AAD)
                   1 = encrypt-and-authenticate (data via GCM)
       bits 0-6  : primitive id

  Defined primitive ids:

       0x01   AES-128-GCM
       0x02   AES-256-GCM
       0x10   Ascon-128                   (reserved)
       0x11   Ascon-128a                  (reserved)
       0x20   ChaCha20-Poly1305           (reserved)

  Common build configurations:

       0x81   AES-128-GCM, encryption=1   (this build's default)
       0x01   AES-128-GCM, encryption=0   (auth-only debug build)
       0x82   AES-256-GCM, encryption=1
       0x02   AES-256-GCM, encryption=0
       0x90   Ascon-128, encryption=1     (reserved; always-AEAD)

  Wire keyid byte (offset 3 of the AAD prefix; also carried on the
  wire body of READ_CHALLENGE, the 1-byte cyclic-arm WRITE_CHALLENGE
  body, and WRITE_REQUEST). It is carried only on CLIENT REQUESTS
  (never on server responses) and where it appears it is the FIRST
  byte of the payload. The server consumes it from the first byte
  and never echoes it back; AAD-binding still authenticates it via
  the tag.

       bit  7    : encryption flag (mirrors mechanism byte bit 7)
       bit  6    : cyclic-arm flag
                   0 = single-shot challenge
                   1 = arm a cyclic-mode session for this entry
                       (meaningful only on READ_CHALLENGE 0x01 and
                        WRITE_CHALLENGE 0x03; ignored on other directions)
       bits 5-4  : reserved, must be 0; verifier rejects non-zero with
                   FBSEC_SOD_ABORT_UNSUPPORTED so future bit assignments
                   do not silently collide with old peers
       bits 3-0  : key-id base (1..15)

  The full byte goes verbatim into the AAD prefix at offset 3, so a
  downgrade attempt (flipping bit 6, repurposing the reserved bits,
  or swapping the base id) fails AEAD verification.


--------------------------------------------------------------------------------
4. AAD LAYOUT
--------------------------------------------------------------------------------

  The AAD has a variant-sized fixed prefix (12 bytes on CANopen FD,
  the current default; 14 bytes on the future 2-byte device_id
  variants) followed by a direction-specific tail that depends on the
  AEAD mode.

  4.1 Common prefix
  -----------------

  The prefix carries a server (responder) and a client (requester)
  identifier in two variant-sized fields. FBSEC_AEAD_DEV_ID_SIZE
  (section 2) selects the width: 1 byte (CANopen FD, current default)
  or 2 bytes (uint16 device_id, reserved for the future generic /
  CANopen CC / EtherCAT variants). The key_id stays at offset 3 on
  both layouts; the data_id and data_len fields shift by 2 bytes
  between variants.

  CANopen FD variant (FBSEC_AEAD_DEV_ID_SIZE = 1, prefix length 12;
  the only variant currently built):

      offset  size  field             value / range
      ------  ----  ----------------  --------------------------------
      0       1     protocol          0x01
      1       1     mechanism         see section 3
      2       1     direction         FBSEC_AEAD_DIR_*
      3       1     key_id            full wire byte (encrypt + cyclic
                                      + reserved + base id; see
                                      section 3)
      4       1     server_node_id    responder's node id 1..127
      5       1     client_node_id    requester's node id 1..127
      6-9     4     data_id           uint32 LE
      10-11   2     data_len          uint16 LE; plaintext length

  2-byte device_id variant (FBSEC_AEAD_DEV_ID_SIZE = 2, prefix length
  14; reserved for future variants, not currently built):

      offset  size  field             value / range
      ------  ----  ----------------  --------------------------------
      0       1     protocol          0x01
      1       1     mechanism         see section 3
      2       1     direction         FBSEC_AEAD_DIR_*
      3       1     key_id            full wire byte (see section 3)
      4-5     2     server_device_id  responder's uint16 LE
      6-7     2     client_device_id  requester's uint16 LE
      8-11    4     data_id           uint32 LE
      12-13   2     data_len          uint16 LE; plaintext length

  Binding both peer identifiers into the AAD is the realisation of
  WP-105's "client node ID, server node ID" requirement for AAD
  coverage: any in-flight tamper of either identifier fails the tag
  verify. Peers compiled with mismatched FBSEC_AEAD_DEV_ID_SIZE see
  a different prefix and fail-closed on the first secure verb,
  which is the intended behaviour and removes the need for a
  protocol-version byte bump on this AAD-layout change.

  4.2 Direction-specific tails
  ----------------------------

       Direction              encryption=1               encryption=0
       ---------------------  -------------------------  --------------------
       READ_CHALLENGE         (no AAD constructed)       (no AAD constructed)
       READ_RESPONSE          client_random[R]           client_random[R]
                              || server_random[R]        || server_random[R]
                                                         || plaintext[N]
       WRITE_CHALLENGE        (no AAD constructed)       (no AAD constructed)
       WRITE_REQUEST          client_random[R]           client_random[R]
                              || server_random[R]        || server_random[R]
                                                         || plaintext[N]
       READ_POLL_REQUEST      (no AAD tail)              (no AAD tail)
       READ_POLL_RESPONSE     (no AAD tail)              plaintext[N]
       WRITE_POLL_REQUEST     (no AAD tail)              plaintext[N]

  R = FBSEC_AEAD_RANDOM_SIZE (default 12). Both peers contribute a
  fresh R-byte random per single-shot transfer; the GCM nonce is
  derived as

       nonce[12] = client_random[0..11] XOR server_random[0..11]

  so nonce uniqueness is preserved as long as **at least one** side
  has a sound RNG. Both randoms ride in the AAD tail (always
  client_random first, then server_random, on both peers) so any
  in-flight tamper of either contribution fails AEAD verification.

  In encryption mode the data field leaves the AAD entirely; it is
  the GCM cipher input/output instead. This is the canonical real-
  AEAD construction. WRITE_CHALLENGE is the mirror of READ_CHALLENGE:
  the responder (server) emits a fresh random and no AAD is built
  for that leg; there is no key context yet on the responder side
  until the client's Pass-2 frame carries the keyid byte.

  Cyclic-mode polls (0x05, 0x06, 0x07) carry no AAD tail in
  encryption mode and only the plaintext on the auth-only path. The
  cyclic-mode counter is bound into the GCM nonce (section 11.1) and
  therefore needs no separate AAD coverage; the AAD prefix already
  binds key_id, server_id, client_id and data_id. A wire-level
  low-byte counter cross-check still rides on each poll frame as a
  desync probe (section 11.1) but plays no role in AEAD.


--------------------------------------------------------------------------------
5. DIRECTION CODES
--------------------------------------------------------------------------------

       Code   Name                     Status
       -----  ----------------------   --------------------------------
       0x01   READ_CHALLENGE           used (challenge body, no AAD)
       0x02   READ_RESPONSE            used
       0x03   WRITE_CHALLENGE          reserved (challenge body, no AAD;
                                        symmetric to READ_CHALLENGE,
                                        kept assigned to forbid reuse)
       0x04   WRITE_REQUEST            used
       0x05   READ_POLL_REQUEST        used (cyclic-mode read poll;
                                        see section 11.1)
       0x06   READ_POLL_RESPONSE       used (cyclic-mode read poll;
                                        see section 11.1)
       0x07   WRITE_POLL_REQUEST       used (cyclic-mode write poll;
                                        see section 11.1)
       0x08..0xFF                      available


--------------------------------------------------------------------------------
6. KEY DERIVATION (HKDF-SHA256)
--------------------------------------------------------------------------------

  When a host supplies a master key plus a session salt, the session
  key is derived locally:

      session_key = HKDF-SHA256(
        IKM    = main_key,
        salt   = session_salt,                    /* host-provided */
        info   = KDF_INFO_PREFIX || key_id,       /* 13 bytes */
        L      = FBSEC_AEAD_KEY_SIZE                /* 16 or 32 bytes */
      )

  The info string is the literal "FBSEC-SK-v1" (11 ASCII chars)
  followed by its trailing NUL terminator (12 bytes total) followed
  by the wire key_id byte (the 13th byte). Either side that derives
  keys must use the same info string and salt; mismatch results in
  tag verify failure on the first secure verb.

  When a host supplies a session key directly, the HKDF step is
  skipped and the bytes are fed straight to AEAD. This is the
  fastest path for embedded targets that store ready-made session
  keys per role.

  Output length tracks FBSEC_AEAD_KEY_SIZE: 16 bytes for AES-128-GCM,
  32 bytes for AES-256-GCM. HKDF can produce any L up to 8160 bytes
  per RFC 5869, so the same primitive serves both AES variants.


--------------------------------------------------------------------------------
7. ON-WIRE FLOWS
--------------------------------------------------------------------------------

  Two transport-frame round-trips per verb. Each variant supplies its
  own carrier: the CANopen FD variant uses an unsegmented SDO
  (USDO) expedited transfer (see doc/fieldbus_sim_canopen_fd_spec.txt);
  future variants
  will plug their own envelope underneath. The transport carrier
  peels off any envelope before passing bytes to
  fbsec_secure_proto / fbsec_secure_od.

  In the descriptions below "ciphertext[N]" is N bytes of GCM output
  when FBSEC_AEAD_ENCRYPTION == 1 and N bytes of unmodified plaintext
  when FBSEC_AEAD_ENCRYPTION == 0; either way the same N bytes precede
  the tag on the wire.

  7.1 Secure read (srd)
  ---------------------

    Both sides contribute fresh randomness (R = FBSEC_AEAD_RANDOM_SIZE
    bytes each); the per-frame GCM nonce is the XOR of the first
    FBSEC_AEAD_NONCE_SIZE bytes of each random.

    Pass 1 (client -> server):
      transport:     src=client, dst=server, data_id=X
                     payload = key_id[1] || client_random[R]
      server reply:  status=0, payload empty  (DEFER ACK)

    Pass 2 (client -> server):
      transport:     src=client, dst=server, data_id=X
                     payload = empty
      server reply:  status=0,
                     payload = server_random[R]
                            || ciphertext[N]
                            || tag[FBSEC_AEAD_TAG_SIZE]
                     (no echoed keyid; the client already knows it
                      and AAD-binding authenticates it via the tag.)

    Tag = AEAD(
            key   = session_key[key_id],
            nonce = client_random[0..NS-1] XOR
                    server_random[0..NS-1],            /* NS = nonce size */
            AAD   = prefix(direction=0x02, key_id,
                           server_dev, client_dev, X,
                           data_len=N)
                    || client_random[R]
                    || server_random[R]
                    || (plaintext[N] iff encryption==0)
          ).truncate_to(FBSEC_AEAD_TAG_SIZE)

  7.2 Secure write (swr)
  ----------------------

    Mirror of secure read with the random first-cut by the responder
    (server) and then combined with a fresh client_random in Pass 2.
    No tag rides on Pass 1 because the server has no key context
    bound to this transfer until the client's Pass-2 frame carries
    the keyid byte.

    Pass 1 (client -> server):
      transport:     src=client, dst=server, data_id=X
                     payload = empty
      server reply:  status=0,
                     payload = server_random[R]

    Pass 2 (client -> server):
      transport:     src=client, dst=server, data_id=X
                     payload = key_id[1]
                               || client_random[R]
                               || ciphertext[N]
                               || tag[FBSEC_AEAD_TAG_SIZE]
      server reply:  status=0, payload empty (ACK)

    Tag = AEAD(
            key   = session_key[entry->key_id],
            nonce = client_random[0..NS-1] XOR
                    server_random[0..NS-1],            /* NS = nonce size */
            AAD   = prefix(direction=0x04, key_id,
                           server_dev, client_dev, X,
                           data_len=N)
                    || client_random[R]
                    || server_random[R]
                    || (plaintext[N] iff encryption==0)
          ).truncate_to(FBSEC_AEAD_TAG_SIZE)

  Notes:
    - server_random is single-shot. The server discards its armed-
      write slot after one successful verify, regardless of write
      outcome.
    - Mutual-random contribution: the GCM nonce is the XOR of both
      randoms, so nonce uniqueness holds whenever EITHER side has a
      sound RNG. Both randoms are bound into the AAD tail (client
      first, then server, on both peers), so an in-flight tamper of
      either contribution fails AEAD verification.
    - The leading keyid byte on Pass 2 is the verbatim AAD prefix
      offset 3 byte (bit 7 = encrypt, bit 6 = cyclic-arm clear here,
      bits 5..4 reserved 0, bits 3..0 = base id). Because it is
      authenticated by the AAD, an in-flight tamper of the keyid
      fails the tag verify on the server side.
    - With FBSEC_AEAD_ENCRYPTION == 1, Pass 2 sends ciphertext on
      the wire; with == 0, the bytes are plaintext.


--------------------------------------------------------------------------------
8. PORT HOOKS (HOST OBLIGATIONS)
--------------------------------------------------------------------------------

  8.1 Client side (one hook):

      bool fbsec_secure_port_random(uint8_t *buf, uint16_t len);

    Cryptographic-quality random for client_random. Reference impl
    in client_common (Win32 CryptGenRandom via
    CryptAcquireContextA(PROV_RSA_FULL, CRYPT_VERIFYCONTEXT)), linked
    by every variant's client binary.

  8.2 Server side (six hooks, see fbsec_secure_od.h for signatures):

      uint16_t fbsec_sod_port_get_device_id(void);
      uint16_t fbsec_sod_port_get_time_ms(void);
      bool     fbsec_sod_port_random(uint8_t *buf, uint16_t len);
      bool     fbsec_sod_port_access_allowed(fbsec_sod_op_t op,
                                           uint32_t data_id);
      uint32_t fbsec_sod_port_read_before(uint32_t data_id,
                                        uint8_t *dst, uint16_t *len);
      uint32_t fbsec_sod_port_write_after(uint32_t data_id,
                                        const uint8_t *src,
                                        uint16_t len);

    The first three are environmental (identity, clock, RNG). The
    last three are the application-policy seam: pre-flight gate,
    plaintext assembly, side-effect commit. server_common installs
    a default-allow access gate, prefill behavior in read_before,
    and a write_after that stores into the SECURE_WO backing
    buffer; each variant's server binary picks these up by linking
    server_common.


--------------------------------------------------------------------------------
9. KEY ROLES AND THE DEFAULT KEY STORE
--------------------------------------------------------------------------------

  Implementation note. SOFA is a simulator. On a real embedded
  target, WP-104 section 3.4 specifies that each layer (Provisioning,
  Integrator, Operator) has a master key, and for each communication
  a per-session key is derived via HKDF(layer_master, salt, info).
  SOFA simulates both the masters and the derivation step out: what
  the CLI loads, what fbsec_sod_set_key() installs, and what the
  wire keyid byte selects are the already-derived session keys. The
  three slots are therefore labelled "Provisioning Session Key",
  "Integrator Session Key", and "Operator Session Key" - the layer
  name identifies which WP-104 layer's master would have produced
  the session key on a real device, not what is itself stored here.

  Key IDs are 4-bit numeric integers in the range 1..15 (bits 3..0
  of the wire keyid byte; bits 5..4 reserved, must be 0). The
  cryptographic core does not interpret them. The reference SOFA
  server pre-loads three hard-coded demo session keys for the smoke
  tests. Sized for AES-128-GCM (16 bytes); the constants are stored
  as 32-byte literals so the same source compiles for AES-256-GCM
  builds without an edit, with the trailing 16 bytes ignored on
  AES-128 builds.

      keyid 1  "Provisioning Session Key"  00112233445566778899AABBCCDDEEFF
      keyid 2  "Integrator Session Key"    AABBCCDDEEFF00112233445566778899
      keyid 3  "Operator Session Key"      FFEEDDCCBBAA99887766554433221100

  The reference SOFA client's --menu mode prompts the user to pick
  one of these three roles at startup; the chosen key is then used
  for every secure transfer in the session. The default role policy,
  enforced server-side via the fbsec_sod_port_role_allowed hook
  (see fbsec_secure_od.h):

      Provisioning Session Key (1)  read + write
      Integrator   Session Key (2)  read + write
      Operator     Session Key (3)  read only; writes are refused
                                    with abort FBSEC_SOD_ABORT_
                                    UNSUPPORTED (0x06010000)

  The default object table leaves every entry's bound key as
  FBSEC_SOD_KEY_NONE so the AEAD step accepts any of the three roles;
  the role hook is the single seam where the read/write policy lives,
  and integrators replace it to install their own mapping (e.g. a
  different role for each entry, multi-key bitmaps, lock-state-aware
  policies).

  These keys are PUBLIC by virtue of being in this document. They
  are demonstration material, not a security claim. Real deployments
  install their own keys through the host's key-store interface
  (port-layer hooks, factory provisioning, master-key + HKDF
  derivation; see the Integration Guide chapter in
  EmSA-UM-105-COP FBsec CANopen V01.docx).

  Both sides treat each key slot as write-once-per-process:
  fbsec_sod_set_key() refuses to overwrite a populated slot. To rotate
  a key, tear down the process / session and provision again.


--------------------------------------------------------------------------------
10. STATUS CODES AND EXIT MAPPING
--------------------------------------------------------------------------------

  Client (fbsec_secure_proto.h):

       FBSEC_SECP_OK         success
       FBSEC_SECP_TIMEOUT    no response within the host-supplied timeout
       FBSEC_SECP_TX         transmit / socket error
       FBSEC_SECP_PROTOCOL   malformed transport-envelope reply
       FBSEC_SECP_BUFSIZE    response too big for caller buffer
       FBSEC_SECP_ABORT      server returned non-zero envelope status
       FBSEC_SECP_TAG        AEAD tag verify failed locally
       FBSEC_SECP_RANDOM     port_random returned false

  Process exit code (uniform across variants):

       0   FBSEC_SECP_OK
       1   any local error (timeout / tx / protocol / bufsize / random)
       2   FBSEC_SECP_ABORT
       3   FBSEC_SECP_TAG

  Server-side abort codes (each variant maps these into its own
  transport envelope, e.g. CANopen FD SDO abort frames; emitted when
  fbsec_sod_dispatch returns FBSEC_SOD_ABORT):

       0x06070010  type / length mismatch
       0x06010000  unsupported access (entry not allowed)
       0x08000022  device-state / locked (port_access_allowed returned
                                          false)
       0x08000020  transfer aborted (challenge missing or stale)
       0x05030000  AEAD tag verify failed on server side
       0x08000000  generic / RNG failure


--------------------------------------------------------------------------------
11. EXTENSIONS
--------------------------------------------------------------------------------

  Section 11.1 (cyclic-mode sessions) is implemented in this
  revision; sections 11.2 - 11.4 remain sketches so a future
  implementer does not redo the design analysis.

  11.1 Cyclic-mode sessions (direction codes 0x05, 0x06, 0x07)
  ------------------------------------------------------------
  Use case: cyclic background liveness verification (read polls)
  and streaming setpoint updates (write polls). The first
  cyclic-capable access is byte-identical to a single secure read
  or write, with bit 6 of the wire keyid byte set; that single
  access establishes a session as a side effect, and the peers
  keep talking against the same secure entry on follow-up poll
  frames without re-issuing a fresh challenge per frame.
  IEC entity-authentication scheme aligned: ISO/IEC 9798-2
  Mechanism 4 truncated to two passes permits a single random-
  challenge exchange per access; cyclic mode amortizes that
  challenge across a long run of continuations by folding the
  arm into a regular single access. The counter extension is
  deviation (v) from WP-105 section 9.1.

  Either side may initiate cyclic mode (client polling reads,
  client polling writes). The protocol stays strictly client-server:
  exactly one client and one server share each session, identified
  by the (server_device_id, data_id) pair. There is no broadcast,
  no multicast, no third-party subscriber.

  Activation: client sets bit 6 of the wire keyid byte on the
  request that carries the keyid (READ_CHALLENGE 0x01 for srd,
  WRITE_REQUEST 0x04 for swr Pass-2). Bit 6 is part of the AAD
  prefix at offset 3, so a downgrade attempt fails AEAD verification.

  Wire flows. The cyclic-capable single access is byte-identical
  to a plain single SRD / SWR; the only wire difference is bit 6 of
  the keyid that the client sets to request session continuation. No
  session_id rides on the wire; both peers identify the session by
  (client_dev, data_id) and bind freshness through the GCM nonce.

    Cyclic-capable single SRD (sets up a session for poll-reads):
      Pass 1: client  --keyid[1] || client_random[R]   ; bit 6 set
                      -->  server
              server  --status=0, payload empty (DEFER ACK)
                      -->  client
      Pass 2: client  --(payload empty)
                      -->  server
              server  --status=0, payload =
                          server_random[R] || ciphertext[N] || tag[T]
                      -->  client

    Cyclic read poll (zero or more after the cyclic-capable single):
      client  --(payload = counter_low[1])    ; dir 0x05
              -->  server
      server  --status=0, payload =
                  counter_low[1] || ciphertext[N] || tag[T]
              -->  client                      ; dir 0x06

    Cyclic-capable single SWR (commits data and sets up a session
    for poll-writes):
      Pass 1: client  --(payload empty)
                      -->  server
              server  --status=0, payload = server_random[R]
                      -->  client                ; no tag (server has
                                                  no key context yet)
      Pass 2: client  --keyid[1] || client_random[R]
                          || ciphertext[N] || tag[T]   ; bit 6 set
                      -->  server                ; dir 0x04
              server  --status=0, payload empty
                      -->  client                ; ACK

    Cyclic write poll (zero or more after the cyclic-capable single):
      client  --(payload = counter_low[1] || ciphertext[N] || tag[T])
              -->  server                      ; dir 0x07
      server  --status=0, payload empty
              -->  client                      ; unauthenticated ACK

  Nonce construction (12 bytes for AES-GCM) is uniform across single-
  shot and cyclic continuations - the WP-105 section 4.2.6 canonical
  formula:

      nonce_base = client_random[0..11] XOR server_random[0..11]
      nonce      = nonce_base XOR (0^64 || counter_be32)

  For single-shot the counter is zero, so the nonce reduces to the
  XOR of the two arm-time randoms (sections 7.1 / 7.2). For cyclic
  continuations both peers retain nonce_base (12 bytes; original
  randoms can be discarded once it is computed) and increment the
  4-byte counter per poll. Birthday floor across re-arms is ~2^-48
  thanks to the 96-bit XOR base, matching the single-shot guarantee
  and removing the deviation from the canonical WP-105 construction.

  Wire counter cross-check:
    - Each poll frame carries counter_low = counter & 0xFF.
    - Receiver computes expected = stored.counter + 1 and requires
      counter_low == (expected & 0xFF). On mismatch the frame is
      dropped without advancing state and without tearing down the
      session, so a single spoofed frame cannot DoS the session.
    - On AEAD success the receiver advances stored.counter to
      expected and refreshes the slot's idle timer.
    - The wire byte is the cross-check; the AEAD nonce uses the
      reconstructed full 32-bit counter, never the wire byte alone.
    - Reliable carriers (current variants, including CANopen FD's
      USDO expedited transfer) require strict equality; a future
      lossy-link port (e.g. UDP) may widen acceptance to
      expected..expected+W within the receiver, where W is the
      lossy-link tolerance window.

  Per-(key, session) frame budget: the cyclic counter is checked
  against FBSEC_AEAD_KEY_USE_LIMIT (1 000 000 by default; see
  shared/fbsec_config.h). Reaching the limit tears the session down
  and requires a re-arm with a freshly rotated key. Server returns
  abort 0x08000020. This stays comfortably under the SP 800-38D
  invocations-per-key bound (section 13.4) and forces key refresh
  far before any cryptographic margin erodes.

  Re-arm: client may issue a fresh arming challenge with bit 6 set
  at any time. The server tears down any existing slot for the
  (client_dev, data_id) pair and recomputes nonce_base from the
  fresh randoms with counter=0. No explicit close frame is needed;
  the session ends on idle timeout
  (FBSEC_SOD_SESSION_IDLE_TIMEOUT_MS = 60 000 ms) or on re-arm.

  Failure modes:
    - Bad tag on a poll: server returns abort 0x05030000 without
      advancing counter or tearing down the session. This prevents
      replay-then-DoS: an attacker who replays an old frame at
      re-arm-time cannot kill the new session.
    - Stale slot (idle timeout or per-key use limit reached):
      server returns abort 0x08000020. Client must re-arm to
      recover.
    - Wire counter cross-check failure: same as bad tag (drop,
      preserve session).

  Demo target entries: the two 4-byte secure entries in the 0x20xx
  range (0x20100000 and 0x20200000). The 16-byte entries
  (0xC0080000 and 0xC0180000) keep the single-shot flow in the
  default demo, though the protocol allows cyclic mode on any
  SECURE_RO / SECURE_WO entry.

  11.2 Ascon-128
  --------------
  Reserved at the build-knob level (FBSEC_AEAD_ASCON_128). Adding it
  needs an Ascon implementation (libascon or hand-rolled) and
  trivial fbsec_aead.c plumbing changes; the AAD layout is unchanged
  except for the mechanism byte (0x90 for Ascon-128 + encryption).
  Tag is fixed at 16 bytes per NIST SP 800-232; the build-time
  guard in fbsec_config.h enforces this.

  11.3 ChaCha20-Poly1305
  ----------------------
  Reserved (FBSEC_AEAD_CHACHA20_POLY1305). Implementation needs a
  Chacha+Poly1305 backend (mbedtls supports it via separate modules
  if MBEDTLS_CHACHAPOLY_C is enabled). Same AAD shape; mechanism
  byte 0x20 + encryption.

  11.4 Confidentiality of WRITE_CHALLENGE / READ_CHALLENGE
  --------------------------------------------------------
  Currently both `client_random[R]` and `server_random[R]` ride
  plaintext on the wire (they are the per-frame XOR-nonce inputs by
  definition; either side needs both to derive the GCM nonce). Any
  future "stealth challenge" mode would have to layer a separate
  key-encrypting-key construction; out of scope for this spec.

  Note: an earlier draft reserved direction codes 0x07 / 0x08 for a
  producer-driven broadcast / pub-push extension. That design has
  been dropped: SOFA is strictly client-server with a single client
  and a single server per session. There are no third-party
  subscribers and no broadcast secure verbs. Direction code 0x07 was
  reassigned to WRITE_POLL_REQUEST as part of section 5; 0x08 is
  available.


--------------------------------------------------------------------------------
12. KNOWN LIMITATIONS (THIS RELEASE)
--------------------------------------------------------------------------------

  - One armed read state and one armed write state PER ENTRY (not
    per-client). Two clients racing the same SECURE_* entry will
    overwrite each other's challenges. Future revisions may carry a
    small per-(client_dev, data_id) slot table; the spec leaves the
    door open via the existing fbsec_sod_dispatch(client_dev, ...)
    parameter.

  - Plaintext cap pinned at FBSEC_AEAD_MAX_PROTECTED = 32 bytes per
    transfer. Larger payloads need a fragmentation layer that has
    not yet been specified.

  - Replay protection beyond AEAD-tag verification is the host's
    responsibility for one-shot transfers. The single-shot
    consumption of armed read and write slots is a partial defense;
    a freshness layer (sequence number, timestamp) is application-
    defined. The repetitive-read mode in section 11.1 will provide
    a per-session counter when implemented.


--------------------------------------------------------------------------------
13. SECURITY CLAIMS
--------------------------------------------------------------------------------

  This section is the auditable security posture of the SOFA secure
  tunnel. Section 13.1 is the only sentence cleared for verbatim
  reproduction in product documentation. Sections 13.2-13.5 spell
  out the standards alignment, the cryptographic property table, the
  NIST-specified usage bound for the default 64-bit tag, and the
  list of claims that are NOT supportable from this implementation.

  13.1 Defensible claim (verbatim)
  --------------------------------

      SOFA provides authenticated and encrypted per-object access
      using AES-128-GCM in accordance with NIST SP 800-38D, with
      session keys derived via HKDF-SHA256 (RFC 5869). Each transfer
      establishes per-message freshness through a unilateral
      challenge-response exchange aligned with ISO/IEC 9798-2
      Mechanism 4 truncated to two passes: the responder is
      authenticated to the requester on secure reads, and the
      requester is authenticated to the responder on secure writes.

  13.2 Standards alignment
  ------------------------

       Standard                                Caveat
       --------------------------------------  ---------------------------
       NIST SP 800-38D (AES-GCM)               64-bit tag bounded by
                                               FBSEC_AEAD_KEY_USE_LIMIT
                                               invocations per key (see
                                               13.4)
       FIPS 197 (AES algorithm)                mbedTLS module is NOT
                                               FIPS-140-3 validated
       FIPS 180-4 (SHA-2 algorithm)            same as above
       RFC 5869 (HKDF)                         HKDF-SHA256, info string
                                               "FBSEC-SK-v1" || NUL ||
                                               key_id (13 bytes)
       RFC 5116 (AEAD interface)               architectural alignment
                                               only (seal / open API)
       ISO/IEC 9798-2 Mechanism 4 truncated    each transfer is
       (three-pass mutual entity               unilateral by
       authentication using random numbers,    construction (the
       reduced here to two passes so that      third pass that would
       authentication is unilateral by         close mutual auth is
       construction)                           dropped); five named
                                               deviations from 9798-2
                                               listed in section 1.1
       ISO/IEC 9797-1                          architectural alignment;
       (MAC mechanisms using a block cipher)   FBSEC_AEAD_TAG_LEN_BYTES
                                               4..16 maps to 9797-1
                                               truncated-MAC variants
       RFC 4279 / RFC 8446 §2.2                architectural alignment;
       (TLS Pre-Shared Key, including          PSK identity = wire
       TLS 1.3 PSK-only mode)                  keyid byte, AEAD record
                                               = SOFA frame, HKDF-
                                               SHA256 = both. NO
                                               session resumption, NO
                                               version negotiation, NO
                                               TLS-1.3 nonce derivation
       IETF cTLS                               architectural alignment;
       (draft-ietf-tls-ctls,                   compaction philosophy
       Compact TLS for constrained devices)    applied at the fieldbus
                                               layer (single-pass
                                               challenge, arm-then-poll
                                               for cyclic). SOFA is
                                               not on-the-wire-
                                               compatible with cTLS.

  13.3 Cryptographic property table
  ---------------------------------

       Property                              Status   Notes
       ------------------------------------  -------  ----------------
       Confidentiality of data payload       YES      with FBSEC_AEAD_
                                                       ENCRYPTION=1
       Integrity / origin auth of data       YES      AAD-bound to
                                                       (device_id,
                                                       data_id, key_id,
                                                       direction)
       Per-transfer replay protection        YES      mutual fresh
                                                       randoms (client_
                                                       random AND
                                                       server_random)
                                                       per transfer
       Cross-transfer replay protection      PARTIAL  YES within a
                                                       cyclic-mode
                                                       session (counter-
                                                       bound; section
                                                       11.1); NO across
                                                       sessions or for
                                                       single-shot
       Mutual authentication                 NO       each transfer is
                                                       unilateral
       Forward secrecy                       NO       pre-shared keys
       Long-term session                     PARTIAL  cyclic-mode
                                                       (section 11.1);
                                                       up to 60 s idle
                                                       or FBSEC_AEAD_KEY_
                                                       USE_LIMIT frames
       Identity beyond device_id             NO       no PKI, no certs
       Downgrade resistance                  PARTIAL  mechanism byte is
                                                       in AAD; config is
                                                       compile-time
       DoS resistance                        NO       server commits
                                                       crypto state on
                                                       first challenge

  13.4 Quantitative usage bound (NIST SP 800-38D)
  -----------------------------------------------
  With FBSEC_AEAD_TAG_LEN_BYTES = 8 (the default 64-bit truncated tag),
  per NIST SP 800-38D Appendix C / Table 2:

    - Single-attempt forgery probability      <= 2^-59
    - Aggregate forgery probability over N
      invocations under the same key          <= N * 2^-59

  SOFA enforces a per-(key, session) hard ceiling well below the
  SP 800-38D 2^32 guidance:

    - FBSEC_AEAD_KEY_USE_LIMIT                  1 000 000 frames
                                              (shared/fbsec_config.h)
    - Aggregate forgery prob at the limit     <= 1e6 * 2^-59
                                              ~= 2^-39

  Reaching the limit tears the cyclic session down and forces the
  host to re-arm with a freshly rotated key, far before any
  cryptographic margin erodes. The single-shot single-shot fresh-
  random-per-transfer flow is not bounded by a counter and is
  similarly safe at default use.

  Deployments needing a wider per-key budget can also raise
  FBSEC_AEAD_TAG_LEN_BYTES (12 bytes -> ~2^48 cap; 16 bytes ->
  effectively unbounded for any realistic deployment lifetime),
  but the recommended path is to leave the tag at 8 and lean on
  the 1 000 000-frame rotation cadence.

  Key rotation cadence is the host's responsibility; no automatic
  rotation in the protocol. The HKDF info string includes a key_id
  byte, so multiple keys can coexist in the key store; rotation is
  achieved by introducing a new key_id and migrating clients to it.

  13.5 What we do NOT claim
  -------------------------
  The following terms are NOT supportable from this implementation
  and must NOT appear in product documentation. When in doubt,
  prefix any property claim with "per transfer" or "within a single
  secure verb invocation".

       Term                          Why not
       ----------------------------  -------------------------------
       "secure session"              Only the limited cyclic-mode
                                     session of section 11.1 (single
                                     entry, single client_dev, up to
                                     60 s idle, up to FBSEC_AEAD_KEY_
                                     USE_LIMIT frames). Anything
                                     broader (multi-entry virtual
                                     channel, key rotation, forward
                                     secrecy) is not provided.
       "replay-protected"            Single-shot transfers protect
       (without qualification)       only per-transfer freshness;
                                     cross-transfer replay needs the
                                     cyclic-mode session of
                                     section 11.1, and even then
                                     replay protection is bounded by
                                     the session's lifetime.
       "mutually authenticated"      Each transfer is unilateral.
                                     Exercising both read AND write
                                     does not constitute mutual auth
                                     in the 9798-2 sense.
       "forward-secret"              Pre-shared keys. Key compromise
                                     reveals all past traffic.
       "FIPS 140 validated"          Primitives are FIPS-spec-
                                     conformant; the mbedTLS *module*
                                     is not FIPS-140-3 validated.
       "TLS-equivalent" /            No key agreement, no PKI, no
       "TLS-grade"                   negotiation, no session resump-
                                     tion.
       "DoS-resistant"               Server commits crypto state on
                                     the first byte of a challenge.


================================================================================
  EOF
================================================================================
