Skip to main content
Version: 3.1.15 (stable)

Security model

This document describes the security boundaries luadch enforces, the trust assumptions it makes about its operating environment, and the design rationale behind the at-rest data protections introduced in the Phase 7 security audit.

It is the authoritative reference for operators deciding how to deploy luadch and for contributors reviewing security-relevant changes. The detailed audit findings live in docs/phases/PHASE_7_FINDINGS.md.


1. Threat model

The hub serves untrusted ADC clients on the public internet. The classes of attacker we defend against:

AttackerCapabilityDefended against?
Network attackerCan send arbitrary bytes to the listening portsyes
Authenticated userHas valid credentials, opens a normal sessionyes
Backup thiefReads cfg/, certs/ from a snapshot or unprotected backup, no host accessyes
World-readable shareHub data ends up on a misconfigured Samba / NFS / Dropbox mountyes
Local file-writeHas filesystem write to cfg/ but no shell on the hostpartially (RCE eliminated; file-tamper still causes load-fail)
Host-level RCE / shellCan read process memory, exec arbitrary codeno
Plugin authorWrites a malicious script and the operator installs itno (see §2)

The "host-level RCE" row is the one that matters most: ADC's password challenge protocol forces the hub to keep a password-equivalent secret in process memory while the hub runs (see §3). Anyone who breaks out of the host's OS-level isolation can read it. The same trade-off applies to Firefox's primary password, Telegram's local DB, and Apple Keychain in its unlocked state. We accept it as a protocol constraint.


2. Plugin trust contract

Plugins under scripts/ are trusted by design.

core/scripts.lua builds each plugin's _ENV by copying every entry of the global table. Plugins have direct access to os, io, debug, package, load*, require, the file system, and the ability to spawn subprocesses. The cfg.no_global_scripting setting only adds an error-on-undeclared-globals metatable; it does not restrict the library surface.

This is intentional. Plugins are part of the hub's privileged code base; the boundary is between "operator-installed plugin" and "untrusted ADC client", not between "plugin" and "core".

Operator responsibilities:

  1. Audit every plugin you install. Read the source. Do not pull plugins from random forums.
  2. Treat the scripts/ directory like the rest of the hub binary: write-protect it from non-admin users on the host.
  3. A compromised plugin trivially exfiltrates cfg/master.key, certs/serverkey.pem, and the in-RAM cleartext of user.tbl. No in-hub mechanism prevents that.

The corresponding audit finding is F-SAND-1 (info-level; documented, not "fixed" because tightening the sandbox would break the existing plugin ecosystem).


3. Password storage and the ADC BASE constraint

luadch implements the ADC BASE extension's HPAS challenge-response:

Server: IGPA <fresh_per_login_salt>
Client: HPAS base32(Tiger(password || salt))
Server: must compute Tiger(stored, salt) and match

For the server's hash to match the client's, the stored value must equal the client's password input. Any one-way KDF (Argon2id, bcrypt, scrypt, PBKDF2) makes the server unable to produce the same Tiger output, so login breaks. This constraint is shared by the entire ADC ecosystem (ADCH++ stores cleartext in XML, uHub in users.conf, …) and there is no published ADC extension that lifts it. The audit research is recorded in issue #52.

What luadch actually does

  1. In RAM, while the hub runs: plaintext passwords are present as field values on each registered user's profile object. There is no way around this within standard ADC.
  2. On disk: cfg/user.tbl is AES-256-GCM encrypted under a host-bound master key (Phase 7f, F-AUTH-1).

Wire format on disk

offset bytes
0 4 magic "LDC1"
4 12 nonce (96-bit, fresh per write via OpenSSL RAND_bytes)
16 N ciphertext
16+N 16 GCM authentication tag

GCM authentication is the security-critical signal: a tampered file fails the tag check and loadusers returns an error. The hub does not silently accept tampered input.

Master key

  • Default path: cfg/master.key (set the master_key_path cfg key to override; see "Backup separation" below)
  • Size: 32 raw bytes (AES-256)
  • Generation: automatic on first boot via OpenSSL RAND_bytes
  • POSIX permissions: chmod 600. The hub refuses to start if the existing key file has any other mode (modeled on OpenSSH's ~/.ssh/id_rsa strict-mode check).
  • Windows permissions: see §4 below.

Backup separation - required for the encryption to be meaningful

The default cfg/master.key location was chosen for first-boot convenience and backwards compatibility, not for production security. With the default, a routine tar czf backup.tar.gz cfg/ bundles both the encrypted user.tbl AND its decryption key into one archive. An attacker who exfiltrates that backup decrypts everything offline; the at-rest encryption provides zero protection in that scenario.

For production deployments, set the master_key_path cfg key in cfg/cfg.tbl to an absolute path outside the install directory:

master_key_path = "/etc/luadch/master.key" -- POSIX
master_key_path = "C:/ProgramData/luadch/master.key" -- Windows

Then handle that path the same way you handle certs/serverkey.pem:

  • exclude it from the routine cfg/ backup, or
  • back it up to a separate destination (different host, different storage tier, or pass-phrase-encrypted archive).

The hub still enforces 0600 on the configured path on POSIX. On Windows, apply icacls to the new path - see §4.

What the on-disk encryption protects against

  • Backup / snapshot exfiltration of cfg/ without the host - only if master_key_path points outside cfg/ per the section above
  • World-readable cfg/user.tbl from a default umask
  • File-system-only read primitive (read-only mount, share, lost laptop, …)

What it does NOT protect against

  • On-host RCE / Lua-sandbox escape (see §1, §2)
  • Plugin compromise (see §2)
  • Master-key file theft. If the attacker exfiltrates both cfg/master.key and cfg/user.tbl, they have all the credentials.

OS-bound key wrapping (TPM, DPAPI machine-scope, libsecret, macOS Keychain) would harden the master-key-theft case and is tracked as a Phase 8+ candidate in #48.

Operator opt-out: encrypt_usertbl = false (#128)

Some deployments do not need disk-level confidentiality and prefer the operational convenience of a plaintext user.tbl (custom backup scripts, third-party admin UIs that read the file directly, ad-hoc inspection with a text editor, recovery without the master key as a hard requirement). For those, the cfg toggle encrypt_usertbl can be flipped to false in cfg/cfg.tbl:

encrypt_usertbl = false,

Default: true. New deployments and upgrades from earlier v3.1.x keep encryption on.

What you give up by setting it to false:

  • Backup confidentiality. A routine tar czf cfg.tar.gz cfg/ exfiltrates plaintext user passwords. ADC mandates the hub hold password-equivalents in RAM; with the toggle off, the same values land on disk in return { ... } Lua source.
  • Stolen-disk protection. An attacker who walks off with the host's disk reads user.tbl directly.
  • The forced-confidentiality default that makes a casual tar / scp / cloud-sync transfer non-leaky.

What you keep regardless of the toggle:

  • chmod 600 on user.tbl on POSIX (still set by saveusers).
  • The atomic-write + always-fresh .bak flow (closes upstream luadch/luadch#189).
  • Sandboxed loadtable on the plain-Lua-source path (the loadfile(path, "t", { }) empty-_ENV from Phase 7e blocks RCE on a tampered user.tbl regardless of the encryption toggle).

Migration is automatic in both directions:

  • true -> false: the next saveusers writes user.tbl as plain Lua source. Until then, the encrypted file on disk still decrypts on read via the existing master.key (the key file is loaded as long as it exists on disk, regardless of the toggle).
  • false -> true: the next saveusers writes an LDC1 blob using master.key (auto-generated if missing).
  • Existing user.tbl files in either format auto-detect on load via the LDC1 magic prefix, so no operator action is required during the toggle flip itself.

Pick the toggle based on your threat model. Public-facing hub on a shared host: keep the default (true). Single-user home hub on a private host where the disk-level threat model is "if my disk leaves my house I have bigger problems": false is reasonable. The hub does not assume which one applies.


4. File-permission baseline

The hub automatically chmod 600s every secret file it writes on POSIX (Phase 7b, F-SEC-1). That covers:

  • cfg/user.tbl (registered-user database, encrypted blob)
  • cfg/user.tbl.bak
  • cfg/master.key
  • certs/serverkey.pem and certs/cakey.pem are 0600'd by examples/certs/make_cert.sh at generation time.

Linux / BSD - one-time migration

Existing deployments that pre-date Phase 7b should run once:

chmod 600 cfg/user.tbl cfg/user.tbl.bak certs/serverkey.pem certs/cakey.pem
# master.key is created by Phase 7f and ships pre-chmod'd. If you
# moved it via master_key_path, chmod that path too.

Windows - manual ACL setup

NTFS does not have POSIX permission bits, so the hub does not attempt to enforce permissions automatically. After install, run:

icacls "cfg\user.tbl" /inheritance:r /grant:r "%USERNAME%:F"
icacls "cfg\user.tbl.bak" /inheritance:r /grant:r "%USERNAME%:F"
icacls "cfg\master.key" /inheritance:r /grant:r "%USERNAME%:F"
icacls "certs\serverkey.pem" /inheritance:r /grant:r "%USERNAME%:F"
icacls "certs\cakey.pem" /inheritance:r /grant:r "%USERNAME%:F"

Replace %USERNAME% with the dedicated service account if the hub runs as LocalService or similar. If master_key_path points to a different location (e.g. C:\ProgramData\luadch\master.key), apply the same icacls line there. The same recipe lives in docs/BUILDING.md.


5. Network-level defenses

DefenseWhereTunable via cfg
Per-IP parallel-socket capcore/server.lua acceptratelimit_perip_max_conns (default 16)
Per-IP accept-rate capsameratelimit_perip_conn_rate / _burst
TLS handshake wallclock deadlinesameratelimit_handshake_timeout (default 10s)
Per-IP failed-auth tracking + sticky lockoutcore/hub_dispatch.lua HPASratelimit_perip_authfail_*, ratelimit_authfail_lockout
Per-account bad-password lockout (independent of per-IP)samemax_bad_password, bad_pass_timeout
Per-user mainchat rate-limitcore/hub_dispatch.lua BMSGratelimit_user_msg_*
Per-user PM rate-limit (#80)core/hub_dispatch.lua DMSG/EMSGratelimit_user_pm_*
Per-user BINF-update rate-limit (#80)core/hub_dispatch.lua BINFratelimit_user_inf_*
Per-user CTM/RCM rate-limit (#80)core/hub_dispatch.lua DCTM/DRCMratelimit_user_ctm_*
Per-user search rate-limitcore/hub_dispatch.lua BSCHratelimit_user_search_*
Per-userlevel tier overlay (#80)core/ratelimit.lua initratelimit_tiers, ratelimit_tier_for_level
Op-level bypass of per-user limitscore/ratelimit.luaratelimit_bypass_level (default 60)
Parser-side message-size capcore/adc.lua parsehardcoded 64 KiB
Connection read-buffer capcore/server.luahardcoded 1 MiB
INF-IP consistency check (kick on TCP-source vs INF-claim mismatch)core/hub_dispatch.lua BINF + scripts/hub_inf_manager.luakill_wrong_ips (default true since v3.1.4)

The full DoS-hardening rationale is in Phase 7c (#56).

kill_wrong_ips operator note (#97)

The kill_wrong_ips default flipped from false to true in v3.1.4: a connecting client whose INF advertises an I4 / I6 value different from the TCP source IP is now disconnected. Same check fires on onInf updates in normal state via scripts/hub_inf_manager.lua - post-login a user cannot re-stamp their advertised IP either.

The legitimate passive-mode I40.0.0.0 case is handled before the kill check (the hub fills in the real IP at core/hub_dispatch.lua), so passive clients are unaffected.

When to opt out (kill_wrong_ips = false): deployments where clients legitimately advertise an IP different from the TCP source. Mostly:

  • users behind symmetric NAT or carrier-grade NAT (CGNAT) where the client cannot determine its public IP and falls back to a stale cached value
  • bridged / dual-stack setups where the user's own selection of I4 vs I6 does not match what the kernel chose for the outbound TCP connection
  • corporate proxies / TLS-terminating reverse proxies in front of the hub that rewrite the source IP

The cost of opting out: per-IP rate limits, GeoIP / unified blocklist matches, abuse logs, and any plugin reading user:ip() operate on the TCP source IP anyway, so the check is purely defence-in-depth against IP-spoofing INFs - the rest of the stack stays sound.

Rate-limit and plugin contract (#80)

Per-user rate limits fire before the plugin listener chain inside core/hub_dispatch.lua. When a bucket is exhausted, the dispatcher returns from the handler with true (handled), which suppresses both the rest of the dispatch and the plugin onBroadcast / onPrivateMessage / onInf / onConnectToMe / onRevConnectToMe listeners. Throttled messages do not reach plugins at all.

For most plugins this is the correct semantic and matches the pre-#80 behaviour for BMSG (which was already throttled). The edge cases worth knowing about:

  • Plugins doing count-based heuristics on per-user messages (e.g. "block after N suspicious CTMs from one user") see only the pre-throttle subset of traffic. Attackers exceeding the bucket hit the hub-level drop and never reach the plugin's counter. Operationally that's still a defence (hub drops the abuse) but the plugin's own logs / counters undercount.
  • Bundled plugins audited for #80 are unaffected in practice: etc_trafficmanager does first-hit blocklist lookup (static, not cumulative); hub_inf_manager writes user state on each BINF and just sees a slightly stale state for one bucket-cycle until the next legitimate BINF; usr_uptime is timer-driven, not BINF- driven; the rest of the usr_* / etc_* plugins reading INF fields tolerate stale state until the next non-throttled update.

If you write a plugin and need exact message accounting, do not rely on the dispatcher's listener fan-out alone - it is rate- limited at the hub boundary by design.

onSearchResult contract widening for F-class (#147 T1.6)

Before #147 the onSearchResult listener only fired on D-class (DRES) - single-recipient search results. Returning a truthy value (return PROCESSED) from the listener suppressed delivery to the one target SID.

After #147 T1.6 the same listener also fires on F-class (FRES) - feature-filtered fan-out where the message is delivered to any client matching a feature mask. Returning truthy on the F-class path suppresses delivery to the entire set of matching recipients, not just one.

Plugins differentiate the two cases by checking the targetuser arg: nil = F-class fan-out (wide impact), non-nil = D-class single recipient.

hub.setlistener( "onSearchResult", { },
function( user, targetuser, adccmd )
if not targetuser then
-- F-class. Returning PROCESSED here drops the whole
-- feature-filtered fan-out. Use with care.
else
-- D-class. Returning PROCESSED drops one delivery only.
end
return nil -- let it through
end
)

The bundled hub_cmd_manager.lua only reads user:level() and returns PROCESSED unconditionally on level mismatch; it tolerates the new arg shape but operators using it should be aware that unauthorised F-class results are now dropped for the whole recipient set instead of per-recipient.


6. TLS configuration

luadch supports plain ADC and TLS-wrapped ADCS in parallel. Default TLS configuration in core/cfg_defaults.lua:

  • Protocol: TLS 1.3 (tlsv1_3)
  • Cipher list: "HIGH"
  • Disabled: SSLv2, SSLv3
  • Peer-cert verify: off (correct for the server-side ADC role - clients are unauthenticated at the TLS layer; auth happens at the ADC HPAS layer)

The ADC KEYP extension lets clients pin the hub's TLS certificate fingerprint; operators can publish their fingerprint via the +hubinfo command and clients that support KEYP will reject mismatching certs.


7. CVE / dependency tracking

luadch bundles all native dependencies as source. Operators should subscribe to upstream releases:

  • Lua - currently 5.4.8
  • LuaSec - currently 1.3.2
  • LuaSocket - currently 3.1.0
  • aiq/basexx - vendored from v0.4.1, upstream essentially abandoned
  • OpenSSL - linked dynamically; find_package(OpenSSL 3.0 REQUIRED) enforces the floor

Quarterly checklist: query osv.dev and the GitHub Advisory Database for each of the above. Record the bundled SHA / version of every dep in docs/BUILDING.md so audits compare to a written truth, not to greps.


8. Reporting a security issue

Open a private security advisory at https://github.com/luadch-ng/luadch/security/advisories/new rather than a public issue, especially for issues that:

  • enable RCE without prior authentication
  • bypass auth or login throttling
  • leak master.key, plaintext credentials, or TLS keys

Public issues are fine for findings that are already documented in docs/phases/PHASE_7_FINDINGS.md or that require operator misconfiguration to exploit (e.g. a world-readable master.key because the operator skipped §4).


9. Audit history

PhaseScopeDoc
Phase 7aRead-only audit of every surface listed in CLAUDE.md §5 Phase 7docs/phases/PHASE_7_FINDINGS.md
Phase 7b - 7gEach finding either fixed or filed with a documented dispositiondocs/phases/PHASE_7_FINDINGS.md §5

A future phase may re-audit. Until then, this file plus PHASE_7_FINDINGS.md is the security baseline for v3.0.x.