From f1026b4427604d0937a0b5c1eb17c89d34efd0f8 Mon Sep 17 00:00:00 2001 From: anti Date: Sat, 25 Apr 2026 04:52:35 -0400 Subject: [PATCH] feat(telnet): same PAM cred-capture, /etc/pam.d/login MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Promotes auth-helper.c to decnet/templates/_shared/auth-helper/ and adds _sync_auth_helper_sources() — mirrors the existing sessrec sync pattern that keeps shared sources in step with per-template build contexts. Telnet's image grows the same multi-stage musl build, COPY of the static helper into /usr/sbin/auth-helper, and prepended pam_exec line in /etc/pam.d/login. Pulls in the `login` package (real Debian PAM-aware /bin/login, replacing busybox's PAM-less applet) and libpam-modules transitively for pam_exec.so. Verified inside the rebuilt telnet image: - /bin/login is the real 53KB Debian binary (PAM-aware) - /etc/pam.d/login top line is the auth-helper hook - pam_exec.so present at /usr/lib/x86_64-linux-gnu/security/pam_exec.so - helper smoke-run emits correct RFC 5424 line for `telnetpw` → password_b64="dGVsbmV0cHc=" SSH Dockerfile updated to read auth-helper.c from auth-helper/ subdirectory so both templates use the synced layout. The canonical source lives in _shared/; per-template copies are tracked in git AND synced at deploy time so a drift on either side rebases on the next deploy. Closes the telnet half of DEBT-038's #5 follow-up. --- decnet/engine/deployer.py | 34 ++++ .../auth-helper}/auth-helper.c | 0 decnet/templates/ssh/Dockerfile | 2 +- .../templates/ssh/auth-helper/auth-helper.c | 173 ++++++++++++++++++ decnet/templates/telnet/Dockerfile | 29 +++ .../telnet/auth-helper/auth-helper.c | 173 ++++++++++++++++++ 6 files changed, 410 insertions(+), 1 deletion(-) rename decnet/templates/{ssh => _shared/auth-helper}/auth-helper.c (100%) create mode 100644 decnet/templates/ssh/auth-helper/auth-helper.c create mode 100644 decnet/templates/telnet/auth-helper/auth-helper.c diff --git a/decnet/engine/deployer.py b/decnet/engine/deployer.py index f73be847..4482024e 100644 --- a/decnet/engine/deployer.py +++ b/decnet/engine/deployer.py @@ -53,6 +53,8 @@ _CANONICAL_LOGGING = Path(__file__).parent.parent / "templates" / "syslog_bridge _CANONICAL_INSTANCE_SEED = Path(__file__).parent.parent / "templates" / "instance_seed.py" _CANONICAL_SESSREC_DIR = Path(__file__).parent.parent / "templates" / "_shared" / "sessrec" _SESSREC_SERVICES = {"ssh", "telnet"} +_CANONICAL_AUTH_HELPER_DIR = Path(__file__).parent.parent / "templates" / "_shared" / "auth-helper" +_AUTH_HELPER_SERVICES = {"ssh", "telnet"} def _sync_logging_helper(config: DecnetConfig) -> None: @@ -75,6 +77,37 @@ def _sync_logging_helper(config: DecnetConfig) -> None: shutil.copy2(src, dest) +def _sync_auth_helper_sources(config: DecnetConfig) -> None: + """Copy auth-helper.c into SSH/Telnet build contexts as auth-helper/. + + The static cred-capture binary (compiled in a multi-stage Dockerfile + layer via musl-gcc) is service-agnostic — same source compiles for + both sshd's PAM stack (/etc/pam.d/sshd) and busybox-telnetd's + /bin/login PAM stack (/etc/pam.d/login). Mirrors the sessrec sync + pattern below. + """ + from decnet.services.registry import get_service + sources = [_CANONICAL_AUTH_HELPER_DIR / "auth-helper.c"] + seen: set[Path] = set() + for decky in config.deckies: + for svc_name in decky.services: + if svc_name not in _AUTH_HELPER_SERVICES: + continue + svc = get_service(svc_name) + if svc is None: + continue + ctx = svc.dockerfile_context() + if ctx is None or ctx in seen: + continue + seen.add(ctx) + dest_dir = ctx / "auth-helper" + dest_dir.mkdir(exist_ok=True) + for src in sources: + dest = dest_dir / src.name + if not dest.exists() or dest.read_bytes() != src.read_bytes(): + shutil.copy2(src, dest) + + def _sync_sessrec_sources(config: DecnetConfig) -> None: """Copy sessrec.c + Makefile into SSH/Telnet build contexts as sessrec/.""" from decnet.services.registry import get_service @@ -403,6 +436,7 @@ def deploy(config: DecnetConfig, dry_run: bool = False, no_cache: bool = False, _sync_logging_helper(config) _sync_sessrec_sources(config) + _sync_auth_helper_sources(config) compose_path = write_compose(config, COMPOSE_FILE) console.print(f"[bold cyan]Compose file written[/] → {compose_path}") diff --git a/decnet/templates/ssh/auth-helper.c b/decnet/templates/_shared/auth-helper/auth-helper.c similarity index 100% rename from decnet/templates/ssh/auth-helper.c rename to decnet/templates/_shared/auth-helper/auth-helper.c diff --git a/decnet/templates/ssh/Dockerfile b/decnet/templates/ssh/Dockerfile index a8db0863..00373f21 100644 --- a/decnet/templates/ssh/Dockerfile +++ b/decnet/templates/ssh/Dockerfile @@ -7,7 +7,7 @@ ARG BASE_IMAGE=debian:bookworm-slim FROM debian:bookworm-slim AS auth-helper-build RUN apt-get update && apt-get install -y --no-install-recommends musl-tools \ && rm -rf /var/lib/apt/lists/* -COPY auth-helper.c /tmp/auth-helper.c +COPY auth-helper/auth-helper.c /tmp/auth-helper.c RUN musl-gcc -static -O2 -s -Wall -Wextra \ -o /auth-helper /tmp/auth-helper.c diff --git a/decnet/templates/ssh/auth-helper/auth-helper.c b/decnet/templates/ssh/auth-helper/auth-helper.c new file mode 100644 index 00000000..4454497d --- /dev/null +++ b/decnet/templates/ssh/auth-helper/auth-helper.c @@ -0,0 +1,173 @@ +/* + * auth-helper — RFC 5424 cred-capture helper invoked via pam_exec.so. + * + * Wired into /etc/pam.d/sshd as: + * auth optional pam_exec.so expose_authtok stdout /usr/sbin/auth-helper + * + * Behaviour: + * - Reads $PAM_USER and $PAM_RHOST from environ (set by pam_exec). + * - Reads PAM_AUTHTOK from stdin (NUL-terminated, written by pam_exec + * when invoked with `expose_authtok`). + * - Emits a single RFC 5424 line on /proc/1/fd/1 in the same shape as + * templates/syslog_bridge.py:syslog_line() — facility local0, PEN + * 55555, MSGID `auth_attempt` (matches FTP's existing event type so + * the parser + dashboard pick it up with zero changes). + * + * Two password fields ride in the SD-block: + * password RFC 5424-escaped ASCII-printable, '?' for non-printables. + * FTP-compatible; consumed by existing dashboard rendering. + * password_b64 base64 of the exact PAM_AUTHTOK bytes. Lossless. + * Preserves NUL/0xff/control bytes that the plain field + * would silently drop — useful fingerprinting signal. + * + * Fail-open: every error path silently exits 0. The PAM line is `optional` + * so a malfunctioning helper must never break sshd auth. + * + * PII discipline: the password value is attacker-supplied bytes. Decky + * services are not for admin SSH; throwaway creds (root:admin) are the + * convention. Limitations tracked in development/DEBT.md (DEBT-038). + */ +#define _GNU_SOURCE +#include +#include +#include +#include +#include +#include + +#define MAX_USER 256 +#define MAX_HOST 256 +#define MAX_PW 1024 +#define LINE_BUF 8192 + +static const char B64[] = + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + +/* Standard base64 with '=' padding. NUL-terminates *out*. Returns bytes + * written (excluding the NUL). On overflow returns 0 and NUL-terminates. */ +static size_t b64_encode(const unsigned char *in, size_t inlen, + char *out, size_t outcap) { + size_t i = 0, o = 0; + while (i + 3 <= inlen) { + if (o + 4 >= outcap) { out[0] = '\0'; return 0; } + unsigned x = ((unsigned)in[i] << 16) | + ((unsigned)in[i+1] << 8) | + (unsigned)in[i+2]; + out[o++] = B64[(x >> 18) & 0x3f]; + out[o++] = B64[(x >> 12) & 0x3f]; + out[o++] = B64[(x >> 6) & 0x3f]; + out[o++] = B64[ x & 0x3f]; + i += 3; + } + if (i < inlen) { + if (o + 4 >= outcap) { out[0] = '\0'; return 0; } + unsigned x = (unsigned)in[i] << 16; + if (i + 1 < inlen) x |= (unsigned)in[i+1] << 8; + out[o++] = B64[(x >> 18) & 0x3f]; + out[o++] = B64[(x >> 12) & 0x3f]; + out[o++] = (i + 1 < inlen) ? B64[(x >> 6) & 0x3f] : '='; + out[o++] = '='; + } + out[o] = '\0'; + return o; +} + +/* RFC 5424 §6.3.3: in SD-PARAM-VALUE, escape \\ → \\\\, " → \", ] → \]. + * Non-printables become '?' so the line stays parser-safe. */ +static size_t sd_escape(const unsigned char *in, size_t inlen, + char *out, size_t outcap) { + size_t o = 0; + for (size_t i = 0; i < inlen; i++) { + unsigned char c = in[i]; + if (c == '\\' || c == '"' || c == ']') { + if (o + 3 >= outcap) break; + out[o++] = '\\'; + out[o++] = c; + } else if (c >= 0x20 && c < 0x7f) { + if (o + 2 >= outcap) break; + out[o++] = c; + } else { + if (o + 2 >= outcap) break; + out[o++] = '?'; + } + } + out[o] = '\0'; + return o; +} + +int main(void) { + const char *user = getenv("PAM_USER"); + const char *rhost = getenv("PAM_RHOST"); + if (!user) user = ""; + if (!rhost) rhost = ""; + + /* Read password until NUL (pam_exec's expose_authtok contract) or EOF. */ + unsigned char pw_raw[MAX_PW]; + size_t pw_len = 0; + while (pw_len < sizeof(pw_raw)) { + ssize_t n = read(0, pw_raw + pw_len, sizeof(pw_raw) - pw_len); + if (n <= 0) break; + for (ssize_t i = 0; i < n; i++) { + if (pw_raw[pw_len + i] == 0) { + pw_len += (size_t)i; + goto pw_done; + } + } + pw_len += (size_t)n; + } +pw_done:; + + /* Timestamp: YYYY-MM-DDThh:mm:ss.uuuuuu+00:00 — matches the shape + * datetime.now(timezone.utc).isoformat() emits in syslog_bridge.py. */ + struct timespec ts; + if (clock_gettime(CLOCK_REALTIME, &ts) != 0) return 0; + struct tm tm; + if (gmtime_r(&ts.tv_sec, &tm) == NULL) return 0; + char tsbuf[40]; + snprintf(tsbuf, sizeof(tsbuf), + "%04d-%02d-%02dT%02d:%02d:%02d.%06ld+00:00", + tm.tm_year + 1900, tm.tm_mon + 1, tm.tm_mday, + tm.tm_hour, tm.tm_min, tm.tm_sec, + (long)(ts.tv_nsec / 1000)); + + char host[MAX_HOST]; + if (gethostname(host, sizeof(host) - 1) != 0) { + host[0] = '-'; host[1] = '\0'; + } else { + host[sizeof(host) - 1] = '\0'; + } + + /* Escape / encode the dynamic fields. Buffers sized 2x source to + * survive worst-case escape expansion. */ + char user_esc [MAX_USER * 2]; + char rhost_esc[MAX_HOST * 2]; + char pw_esc [MAX_PW * 2]; + char pw_b64 [MAX_PW * 2]; + + sd_escape((const unsigned char *)user, strlen(user), user_esc, sizeof(user_esc)); + sd_escape((const unsigned char *)rhost, strlen(rhost), rhost_esc, sizeof(rhost_esc)); + sd_escape(pw_raw, pw_len, pw_esc, sizeof(pw_esc)); + b64_encode(pw_raw, pw_len, pw_b64, sizeof(pw_b64)); + + /* Priority: facility=local0(16), severity=INFO(6) → <16*8+6> = <134>. + * Matches the syslog_bridge.py default exactly. */ + char line[LINE_BUF]; + int n = snprintf(line, sizeof(line), + "<134>1 %s %s auth-helper - auth_attempt " + "[relay@55555 username=\"%s\" password=\"%s\" " + "password_b64=\"%s\" src_ip=\"%s\"]\n", + tsbuf, host, user_esc, pw_esc, pw_b64, rhost_esc); + if (n <= 0 || (size_t)n >= sizeof(line)) return 0; + + /* /proc/1/fd/1 is the entrypoint's stdout — the fd Docker captures + * for `docker logs`. Same channel rsyslog forwards auth.* into via + * the existing template; we bypass rsyslog entirely so behaviour is + * deterministic across rsyslog config drift. */ + int fd = open("/proc/1/fd/1", O_WRONLY | O_APPEND); + if (fd < 0) return 0; + ssize_t w = write(fd, line, (size_t)n); + (void)w; + close(fd); + + return 0; +} diff --git a/decnet/templates/telnet/Dockerfile b/decnet/templates/telnet/Dockerfile index 8d66fb78..43f421c6 100644 --- a/decnet/templates/telnet/Dockerfile +++ b/decnet/templates/telnet/Dockerfile @@ -1,8 +1,28 @@ ARG BASE_IMAGE=debian:bookworm-slim + +# ── Stage 1: build the static auth-helper credential-capture binary ────────── +# Same source the SSH template builds — generic over PAM service. Wired +# into /etc/pam.d/login below so every busybox-telnetd → /bin/login auth +# attempt is captured before pam_unix runs. Static + musl: ~38 KB ELF, +# zero libc version coupling, runs anywhere. +FROM debian:bookworm-slim AS auth-helper-build +RUN apt-get update && apt-get install -y --no-install-recommends musl-tools \ + && rm -rf /var/lib/apt/lists/* +COPY auth-helper/auth-helper.c /tmp/auth-helper.c +RUN musl-gcc -static -O2 -s -Wall -Wextra \ + -o /auth-helper /tmp/auth-helper.c + +# ── Stage 2: the actual telnet decky image ─────────────────────────────────── FROM ${BASE_IMAGE} +# `login` (real Debian /bin/login, PAM-aware) replaces busybox's PAM-less +# login applet. libpam-modules ships pam_exec.so transitively. Both are +# needed for the auth-helper hook to fire — without them the PAM stack +# can't load pam_exec or call into a real PAM service. RUN apt-get update && apt-get install -y --no-install-recommends \ busybox-static \ + login \ + libpam-modules \ rsyslog \ procps \ net-tools \ @@ -41,6 +61,15 @@ RUN sed -i \ -e 's|^auth,authpriv\.\*|#auth,authpriv.*|' \ /etc/rsyslog.conf +# auth-helper: drop the static binary into /usr/sbin and wire pam_exec +# into the login PAM stack so every busybox-telnetd password attempt +# (success or fail) is captured before pam_unix runs. Same `optional` +# fail-open semantics as the SSH template. +COPY --from=auth-helper-build /auth-helper /usr/sbin/auth-helper +RUN chmod 755 /usr/sbin/auth-helper && \ + sed -i '1i auth optional pam_exec.so expose_authtok stdout /usr/sbin/auth-helper' \ + /etc/pam.d/login + # Realistic motd and issue banner RUN echo "Ubuntu 20.04.6 LTS" > /etc/issue.net && \ echo "Welcome to Ubuntu 20.04.6 LTS (GNU/Linux 5.4.0-150-generic x86_64)" > /etc/motd && \ diff --git a/decnet/templates/telnet/auth-helper/auth-helper.c b/decnet/templates/telnet/auth-helper/auth-helper.c new file mode 100644 index 00000000..4454497d --- /dev/null +++ b/decnet/templates/telnet/auth-helper/auth-helper.c @@ -0,0 +1,173 @@ +/* + * auth-helper — RFC 5424 cred-capture helper invoked via pam_exec.so. + * + * Wired into /etc/pam.d/sshd as: + * auth optional pam_exec.so expose_authtok stdout /usr/sbin/auth-helper + * + * Behaviour: + * - Reads $PAM_USER and $PAM_RHOST from environ (set by pam_exec). + * - Reads PAM_AUTHTOK from stdin (NUL-terminated, written by pam_exec + * when invoked with `expose_authtok`). + * - Emits a single RFC 5424 line on /proc/1/fd/1 in the same shape as + * templates/syslog_bridge.py:syslog_line() — facility local0, PEN + * 55555, MSGID `auth_attempt` (matches FTP's existing event type so + * the parser + dashboard pick it up with zero changes). + * + * Two password fields ride in the SD-block: + * password RFC 5424-escaped ASCII-printable, '?' for non-printables. + * FTP-compatible; consumed by existing dashboard rendering. + * password_b64 base64 of the exact PAM_AUTHTOK bytes. Lossless. + * Preserves NUL/0xff/control bytes that the plain field + * would silently drop — useful fingerprinting signal. + * + * Fail-open: every error path silently exits 0. The PAM line is `optional` + * so a malfunctioning helper must never break sshd auth. + * + * PII discipline: the password value is attacker-supplied bytes. Decky + * services are not for admin SSH; throwaway creds (root:admin) are the + * convention. Limitations tracked in development/DEBT.md (DEBT-038). + */ +#define _GNU_SOURCE +#include +#include +#include +#include +#include +#include + +#define MAX_USER 256 +#define MAX_HOST 256 +#define MAX_PW 1024 +#define LINE_BUF 8192 + +static const char B64[] = + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + +/* Standard base64 with '=' padding. NUL-terminates *out*. Returns bytes + * written (excluding the NUL). On overflow returns 0 and NUL-terminates. */ +static size_t b64_encode(const unsigned char *in, size_t inlen, + char *out, size_t outcap) { + size_t i = 0, o = 0; + while (i + 3 <= inlen) { + if (o + 4 >= outcap) { out[0] = '\0'; return 0; } + unsigned x = ((unsigned)in[i] << 16) | + ((unsigned)in[i+1] << 8) | + (unsigned)in[i+2]; + out[o++] = B64[(x >> 18) & 0x3f]; + out[o++] = B64[(x >> 12) & 0x3f]; + out[o++] = B64[(x >> 6) & 0x3f]; + out[o++] = B64[ x & 0x3f]; + i += 3; + } + if (i < inlen) { + if (o + 4 >= outcap) { out[0] = '\0'; return 0; } + unsigned x = (unsigned)in[i] << 16; + if (i + 1 < inlen) x |= (unsigned)in[i+1] << 8; + out[o++] = B64[(x >> 18) & 0x3f]; + out[o++] = B64[(x >> 12) & 0x3f]; + out[o++] = (i + 1 < inlen) ? B64[(x >> 6) & 0x3f] : '='; + out[o++] = '='; + } + out[o] = '\0'; + return o; +} + +/* RFC 5424 §6.3.3: in SD-PARAM-VALUE, escape \\ → \\\\, " → \", ] → \]. + * Non-printables become '?' so the line stays parser-safe. */ +static size_t sd_escape(const unsigned char *in, size_t inlen, + char *out, size_t outcap) { + size_t o = 0; + for (size_t i = 0; i < inlen; i++) { + unsigned char c = in[i]; + if (c == '\\' || c == '"' || c == ']') { + if (o + 3 >= outcap) break; + out[o++] = '\\'; + out[o++] = c; + } else if (c >= 0x20 && c < 0x7f) { + if (o + 2 >= outcap) break; + out[o++] = c; + } else { + if (o + 2 >= outcap) break; + out[o++] = '?'; + } + } + out[o] = '\0'; + return o; +} + +int main(void) { + const char *user = getenv("PAM_USER"); + const char *rhost = getenv("PAM_RHOST"); + if (!user) user = ""; + if (!rhost) rhost = ""; + + /* Read password until NUL (pam_exec's expose_authtok contract) or EOF. */ + unsigned char pw_raw[MAX_PW]; + size_t pw_len = 0; + while (pw_len < sizeof(pw_raw)) { + ssize_t n = read(0, pw_raw + pw_len, sizeof(pw_raw) - pw_len); + if (n <= 0) break; + for (ssize_t i = 0; i < n; i++) { + if (pw_raw[pw_len + i] == 0) { + pw_len += (size_t)i; + goto pw_done; + } + } + pw_len += (size_t)n; + } +pw_done:; + + /* Timestamp: YYYY-MM-DDThh:mm:ss.uuuuuu+00:00 — matches the shape + * datetime.now(timezone.utc).isoformat() emits in syslog_bridge.py. */ + struct timespec ts; + if (clock_gettime(CLOCK_REALTIME, &ts) != 0) return 0; + struct tm tm; + if (gmtime_r(&ts.tv_sec, &tm) == NULL) return 0; + char tsbuf[40]; + snprintf(tsbuf, sizeof(tsbuf), + "%04d-%02d-%02dT%02d:%02d:%02d.%06ld+00:00", + tm.tm_year + 1900, tm.tm_mon + 1, tm.tm_mday, + tm.tm_hour, tm.tm_min, tm.tm_sec, + (long)(ts.tv_nsec / 1000)); + + char host[MAX_HOST]; + if (gethostname(host, sizeof(host) - 1) != 0) { + host[0] = '-'; host[1] = '\0'; + } else { + host[sizeof(host) - 1] = '\0'; + } + + /* Escape / encode the dynamic fields. Buffers sized 2x source to + * survive worst-case escape expansion. */ + char user_esc [MAX_USER * 2]; + char rhost_esc[MAX_HOST * 2]; + char pw_esc [MAX_PW * 2]; + char pw_b64 [MAX_PW * 2]; + + sd_escape((const unsigned char *)user, strlen(user), user_esc, sizeof(user_esc)); + sd_escape((const unsigned char *)rhost, strlen(rhost), rhost_esc, sizeof(rhost_esc)); + sd_escape(pw_raw, pw_len, pw_esc, sizeof(pw_esc)); + b64_encode(pw_raw, pw_len, pw_b64, sizeof(pw_b64)); + + /* Priority: facility=local0(16), severity=INFO(6) → <16*8+6> = <134>. + * Matches the syslog_bridge.py default exactly. */ + char line[LINE_BUF]; + int n = snprintf(line, sizeof(line), + "<134>1 %s %s auth-helper - auth_attempt " + "[relay@55555 username=\"%s\" password=\"%s\" " + "password_b64=\"%s\" src_ip=\"%s\"]\n", + tsbuf, host, user_esc, pw_esc, pw_b64, rhost_esc); + if (n <= 0 || (size_t)n >= sizeof(line)) return 0; + + /* /proc/1/fd/1 is the entrypoint's stdout — the fd Docker captures + * for `docker logs`. Same channel rsyslog forwards auth.* into via + * the existing template; we bypass rsyslog entirely so behaviour is + * deterministic across rsyslog config drift. */ + int fd = open("/proc/1/fd/1", O_WRONLY | O_APPEND); + if (fd < 0) return 0; + ssize_t w = write(fd, line, (size_t)n); + (void)w; + close(fd); + + return 0; +}