/* * 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). * * SD-block carries the standardized credential shape (matches * decnet/web/db/models/logs.py:Credential). Universal keys consumed * directly by the ingester's native-shape branch: * principal the human-meaningful identity the attacker sent * (username for SSH/Telnet; would be a domain for * SMTP, a DN for LDAP, etc.) * secret_printable RFC 5424-escaped ASCII-printable, '?' for non- * printables. Best-effort display form; may be * lossy on non-UTF8 bytes. * secret_b64 base64 of the exact PAM_AUTHTOK bytes. Lossless. * Preserves NUL/0xff/control bytes that the plain * field would silently drop — useful fingerprinting * signal that survives display sanitization. * * `username` rides alongside as a service-specific identity field for * SSH/Telnet (mirrors `principal`); future emitters (SMTP, LDAP, …) * drop `username` in favor of their service-native identity field. * * 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. * * SD-block keys match the Credential storage model: principal + * secret_printable + secret_b64 are the universal keys the ingester * keys off; username is emitted alongside principal so existing * dashboards that read SSH/Telnet `username=` keep working until * the cred-reuse UI lands. */ char line[LINE_BUF]; int n = snprintf(line, sizeof(line), "<134>1 %s %s auth-helper - auth_attempt " "[relay@55555 username=\"%s\" principal=\"%s\" " "secret_printable=\"%s\" secret_b64=\"%s\" src_ip=\"%s\"]\n", tsbuf, host, user_esc, 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; }