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.
The hub serves untrusted ADC clients on the public internet. The classes of attacker we defend against:
| Attacker | Capability | Defended against? |
|---|---|---|
| Network attacker | Can send arbitrary bytes to the listening ports | yes |
| Authenticated user | Has valid credentials, opens a normal session | yes |
| Backup thief | Reads cfg/, certs/ from a snapshot or unprotected backup, no host access |
yes |
| World-readable share | Hub data ends up on a misconfigured Samba / NFS / Dropbox mount | yes |
| Local file-write | Has filesystem write to cfg/ but no shell on the host |
partially (RCE eliminated; file-tamper still causes load-fail) |
| Host-level RCE / shell | Can read process memory, exec arbitrary code | no |
| Plugin author | Writes a malicious script and the operator installs it | no (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.
Plugins under scripts/ are trusted by design.
The trust boundary is between "operator-installed plugin" and
"untrusted ADC client", not between "plugin" and "core".
core/scripts.lua builds each plugin's
_ENV from an explicit SANDBOX_GLOBALS whitelist (added in
#206). os and
io are curated shims: os exposes only time / date /
difftime; io exposes only a path-restricted open (relative
paths, no .. traversal) - io.popen, io.lines, package,
require, and debug are NOT reachable. Subprocess access lives
in a separate core/sysinfo.lua module
that exposes a small audited surface for the bundled
cmd_hubinfo plugin.
The same path-restriction gate covers the plugin-callable I/O
functions exported by core/util.lua
(checkfile, loadtable, savetable, savearray, maketable,
atomic_write) via the shared util.safe_path helper - closed
in #266 where
util had previously captured the unsandboxed io.open at module
load and bypassed _io_safe.
These mechanisms are defence-in-depth, not a hard boundary. A malicious plugin can still:
- Read any file under the hub's working directory via the
permitted relative-path range (e.g.
cfg/master.key,certs/serverkey.pem). - Write
.tblcontent to any permitted relative location. - Reach all hub-internal data: in-RAM cleartext of
user.tbl, the full plugin sandbox table, every other loaded plugin.
The gate raises the floor for accidental escapes by buggy
plugins; it does not protect against an actively malicious one.
cfg.no_global_scripting adds an error-on-undeclared-globals
metatable on top, also defence-in-depth.
Operator responsibilities:
- Audit every plugin you install. Read the source. Do not pull plugins from random forums.
- Treat the
scripts/directory like the rest of the hub binary: write-protect it from non-admin users on the host. - A compromised plugin trivially exfiltrates
cfg/master.key,certs/serverkey.pem, and the in-RAM cleartext ofuser.tbl. No in-hub mechanism prevents that.
The corresponding audit finding is F-SAND-1 (info-level; #206 / #213 / #266 are incremental hardening on top, not a "fix" - tightening the sandbox further would break the existing plugin ecosystem).
An admin-scope HTTP API token can do everything +masteruser can
do, including:
- Read its own and every other admin token's audit-log bodies via
GET /v1/log/api. Bodies for routes that opt into the §6.8 redact mechanism (audit_redact_body = true- currently the two password endpoints) log as[redacted]even to admin readers, but everything else is plaintext. - Issue
POST /v1/restart/POST /v1/shutdown/POST /v1/reload. - Toggle plugins (
PUT /v1/plugins/{name}/enabled). - Mutate any non-denylisted cfg key (
PUT /v1/config/{key}). - Bypass ADC
+unbanlevel checks (HTTP-created bans persist withby_level = 100).
Treat the admin token like the +masteruser password. Rotate it
on operator turnover; never embed it in a non-loopback-reachable
process; use comment to label tokens so audit lines are
traceable.
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.
- 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.
- On disk:
cfg/user.tblis AES-256-GCM encrypted under a host-bound master key (Phase 7f, F-AUTH-1).
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.
- Default path:
cfg/master.key(set themaster_key_pathcfg 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_rsastrict-mode check). - Windows permissions: see §4 below.
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" -- WindowsThen 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.
- Backup / snapshot exfiltration of
cfg/without the host - only ifmaster_key_pathpoints outsidecfg/per the section above - World-readable
cfg/user.tblfrom a default umask - File-system-only read primitive (read-only mount, share, lost laptop, …)
- On-host RCE / Lua-sandbox escape (see §1, §2)
- Plugin compromise (see §2)
- Master-key file theft. If the attacker exfiltrates both
cfg/master.keyandcfg/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 inreturn { ... }Lua source. - Stolen-disk protection. An attacker who walks off with the
host's disk reads
user.tbldirectly. - The forced-confidentiality default that makes a casual
tar/scp/ cloud-sync transfer non-leaky.
What you keep regardless of the toggle:
chmod 600onuser.tblon POSIX (still set bysaveusers).- The atomic-write + always-fresh
.bakflow (closes upstreamluadch/luadch#189). - Sandboxed
loadtableon the plain-Lua-source path (theloadfile(path, "t", { })empty-_ENVfrom Phase 7e blocks RCE on a tampereduser.tblregardless of the encryption toggle).
Migration is automatic in both directions:
true->false: the nextsaveuserswritesuser.tblas plain Lua source. Until then, the encrypted file on disk still decrypts on read via the existingmaster.key(the key file is loaded as long as it exists on disk, regardless of the toggle).false->true: the nextsaveuserswrites an LDC1 blob usingmaster.key(auto-generated if missing).- Existing
user.tblfiles 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.
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.bakcfg/master.keycerts/serverkey.pemandcerts/cakey.pemare 0600'd byexamples/certs/make_cert.shat generation time.
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.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.
| Defense | Where | Tunable via cfg |
|---|---|---|
| Per-IP parallel-socket cap | core/server.lua accept |
ratelimit_perip_max_conns (default 16) |
| Per-IP accept-rate cap | same | ratelimit_perip_conn_rate / _burst |
| TLS handshake wallclock deadline | same | ratelimit_handshake_timeout (default 10s) |
| Per-IP failed-auth tracking + sticky lockout | core/hub_dispatch.lua HPAS |
ratelimit_perip_authfail_*, ratelimit_authfail_lockout |
| Per-account bad-password lockout (independent of per-IP) | same | max_bad_password, bad_pass_timeout |
| Per-user mainchat rate-limit | core/hub_dispatch.lua BMSG |
ratelimit_user_msg_* |
| Per-user PM rate-limit (#80) | core/hub_dispatch.lua DMSG/EMSG |
ratelimit_user_pm_* |
| Per-user BINF-update rate-limit (#80) | core/hub_dispatch.lua BINF |
ratelimit_user_inf_* |
| Per-user CTM/RCM rate-limit (#80) | core/hub_dispatch.lua DCTM/DRCM |
ratelimit_user_ctm_* |
| Per-user search rate-limit | core/hub_dispatch.lua BSCH |
ratelimit_user_search_* |
| Per-userlevel tier overlay (#80) | core/ratelimit.lua init |
ratelimit_tiers, ratelimit_tier_for_level |
| Op-level bypass of per-user limits | core/ratelimit.lua |
ratelimit_bypass_level (default 60) |
| Parser-side message-size cap | core/adc.lua parse |
hardcoded 64 KiB |
| Connection read-buffer cap | core/server.lua |
hardcoded 1 MiB |
| INF-IP consistency check (kick on TCP-source vs INF-claim mismatch) | core/hub_dispatch.lua BINF + scripts/hub_inf_manager.lua |
kill_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
I4vsI6does 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.
Dual-stack secondary-address verification (#214, HBRI)
Since v3.2.x luadch accepts a BINF that carries BOTH I4 and I6
in one frame, so a dual-stack peer can advertise both
(#147 T3.1). The
hub can only authenticate the field matching the connecting TCP
source's family against the actual TCP source IP - it has no socket
on the other family through which to verify the secondary address.
Broadcasting an unverified secondary would be a DC++ DDoS-amplification vector: a dishonest client could advertise an arbitrary victim IP as its secondary, and other clients would then direct CTM / RCM connection attempts at that address (the historical DC++ DDoS pattern - Wikipedia). This is not an unavoidable trade-off; luadch closes it two ways:
-
Strip by default (Gap 1). For every client, the unverified secondary family's address (
I4/I6), UDP port (U4/U6) and transport SU flags (TCP4/UDP4/TCP6/UDP6) are stripped before the INF is stored or broadcast, incore/hub_dispatch.lua's BINF handler. Only the authenticated primary family is ever advertised to other users. A dishonest secondary claim never reaches the wire. -
Verify, then restore (HBRI, opt-in). With
hbri_enabledAND a listener on both families ANDhbri_advertise_v4/hbri_advertise_v6set, the hub advertisesADHBRIand validates a supporting client's secondary over a second-family side-channel (core/hbri.lua): it mints a CSPRNG token, sends anITCPpointer, and only commits + broadcasts the secondary once the client connects back on the other family and presents the token. The committed address is always the side-channel's authenticated TCP source - never a client-supplied value, and a connection from the claimed address is proof of reachability. A client may advertise either a concrete secondary or the spec placeholder (I6::/I40.0.0.0, the common auto-detect case): the placeholder makes the hub discover the address from the side-channel getpeername (#291); a concrete value is accepted only if it equals that source. On validation failure or ahbri_timeout-second timeout the user enters the hub normally with the secondary left stripped (the Gap-1 default). The side-channel rides the normal accept path, so it uses the advertised port's transport - a client connecting back to a TLS / autossl port does TLS on the side-channel too (matching its main connection). HBRI needs a listener (plain or TLS) on both families; a family with no listener disables it (#298).
Either path guarantees the broadcast INF only ever carries an address
the hub authenticated. A client that advertises its secondary only in
a post-login INF update (not the initial BINF) is handled the same
way (#286): the
unverified I4 / I6 is still stripped from that update before
broadcast (the #97 / #222 closeout in
scripts/hub_inf_manager.lua stays
in force), and only a side-channel-validated secondary is then
broadcast - the user is never removed from the normal state for the
re-validation. An unverified post-login I4 / I6 therefore still
never reaches the wire.
The primary-family sibling of this vector - kill_wrong_ips = false
letting a NAT-weird client's wrong primary claim broadcast - was
closed under #214 Gap 2: the mismatched primary claim is overwritten
with the authenticated user:ip() rather than forwarded.
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_trafficmanagerdoes first-hit blocklist lookup (static, not cumulative);hub_inf_managerwrites user state on each BINF and just sees a slightly stale state for one bucket-cycle until the next legitimate BINF;usr_uptimeis timer-driven, not BINF- driven; the rest of theusr_*/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.
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.
Recommendation: leave zlif_over_tls = false on production hubs.
The bandwidth saving stacking compression UNDER TLS is usually not
worth the residual CRIME-class risk. Plain-ADC connections see
ZLIF unconditionally when zlif_enabled = true; the flag below
only matters for ADCS / TLS connections.
Phase 8 S4b adds optional ADC-EXT ZLIF stream compression. ZLIF is
off by default (zlif_enabled = false); when an operator enables
it, a separate flag (zlif_over_tls, also default false) gates
whether ZLIF activates on TLS-wrapped connections in addition to
plain ADC.
The rationale for the second flag is the CRIME-class chosen-plaintext-length leak that applies to any scheme of "compress then encrypt". In the luadch + ZLIF + TLS deployment the shape is:
- An attacker on the same hub PMs a victim chosen plaintext.
- The hub forwards the PM on the victim's TLS-wrapped connection, mixed with whatever else that connection carries (broadcast chat, user lists, PMs from other peers).
- The hub deflates the per-connection stream BEFORE TLS encrypts it, so the ciphertext length depends on the compressed length, which depends on the dictionary similarity between the attacker's chosen plaintext and the victim's other contents.
- A wire-level eavesdropper (LAN/ISP) observing length deltas can in principle infer whether the chosen plaintext matched something else in the victim's stream.
In practice the exploit is weak: broadcast traffic adds noise, the
attacker needs eavesdropper access on the victim's network, and
distinguishing 1-bit length deltas in a busy hub is hard. But the
mitigation cost is one cfg flag, so the safe default is false -
operators who want the bandwidth saving and accept the residual
risk set zlif_over_tls = true. Plain-ADC connections see ZLIF
unconditionally when zlif_enabled = true; only TLS is gated.
ZLIF also has two transport-level hardening properties enforced by
the binding (zlib_stream/zlib_stream.c):
- Decompression-bomb cap. Each inflate call caps decompressed
output at 4 MiB. Exceeding the cap raises a Lua error which the
inbound inflate stage propagates as the pipeline's overflow
signal, and
core/server.lua's read loop closes the connection. A 1 KB compressed payload that expands to GiB on the wire cannot drive runaway memory usage on the hub. - Malformed-input close. zlib
Z_DATA_ERROR/Z_NEED_DICTon a corrupted compressed stream is also surfaced as overflow; the hub closes rather than continuing on poisoned state.
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 - zlib - linked dynamically;
find_package(ZLIB REQUIRED). Used by thezlib_streamADC-EXT ZLIF binding (Phase 8 S4b); only matters at runtime whenzlif_enabled = true
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.
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).
| Phase | Scope | Doc |
|---|---|---|
| Phase 7a | Read-only audit of every surface listed in CLAUDE.md §5 Phase 7 |
docs/phases/PHASE_7_FINDINGS.md |
| Phase 7b - 7g | Each finding either fixed or filed with a documented disposition | docs/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.