227 lines
8.3 KiB
Python
227 lines
8.3 KiB
Python
#!/usr/bin/env python3
|
|
"""SysDeck web edition — PAM authentication helper (v0.4.0).
|
|
|
|
This is the same mechanism Cockpit uses for its login: hand the
|
|
username + password to the host's PAM stack (pam_unix and friends) and
|
|
let the system decide. No password database of our own, no hashing
|
|
scheme of our own — the *host's* account system is the source of truth.
|
|
|
|
Protocol
|
|
stdin : one JSON document {"user": "...", "password": "...", "service": "sysdeck"}
|
|
stdout: one JSON document {"ok": bool, "user": str?, "reason": str?, "code": int?}
|
|
exit : 0 when authentication succeeded, 1 otherwise (JSON still printed)
|
|
|
|
Design notes (they matter):
|
|
* The password arrives on STDIN, never argv — argv is world-readable
|
|
in /proc/<pid>/cmdline. The document is read once with a size cap
|
|
and scrubbed after use where practical.
|
|
* The PAM conversation callback answers only PAM_PROMPT_ECHO_OFF
|
|
(password) and PAM_PROMPT_ECHO_ON (user) messages; anything else
|
|
(info/error) is acknowledged with an empty reply so exotic stacks
|
|
cannot fish for extra data.
|
|
* pam_acct_mgmt() runs after authenticate — the account must also be
|
|
allowed to log in right now (expiry / lock / time-of-day rules that
|
|
the stack enforces).
|
|
* Service name: SYSDECK_PAM_SERVICE / the request's "service" field
|
|
(default "sysdeck") first, so operators can ship a tailored
|
|
/etc/pam.d/sysdeck stack; when that stack does not exist the helper
|
|
falls back to "login" — the stack the console TTY uses, which is
|
|
what "log in like at the console" means on a stock distro.
|
|
* stdlib only (ctypes + json + sys). If libpam is missing, report
|
|
{"ok": false, "reason": "pam-unavailable"} so the caller can route
|
|
to the local-account fallback.
|
|
|
|
Root notes: pam_unix needs root to read /etc/shadow for ARBITRARY
|
|
users. A non-root process goes through unix_chkpwd, which only verifies
|
|
the INVOKING user's password (a pam_unix guarantee, not ours). So: run
|
|
the web console as root (systemd unit) to accept logins from any Unix
|
|
account — exactly how cockpit-ws is deployed — or use
|
|
SYSDECK_AUTH_MODE=pam+local / local for unprivileged installs.
|
|
"""
|
|
import ctypes
|
|
import json
|
|
import sys
|
|
|
|
MAX_DOC_BYTES = 64 * 1024 # a JSON doc with a password is never this big
|
|
|
|
PAM_SUCCESS = 0
|
|
PAM_PROMPT_ECHO_OFF = 1 # password
|
|
PAM_PROMPT_ECHO_ON = 2 # username
|
|
PAM_ERROR_MSG = 3
|
|
PAM_TEXT_INFO = 4
|
|
PAM_CONV_ERR = 19
|
|
|
|
EXIT_OK = 0
|
|
EXIT_FAIL = 1
|
|
|
|
|
|
def load_libc():
|
|
for name in ("libc.so.6", "libc.so"):
|
|
try:
|
|
libc = ctypes.CDLL(name)
|
|
libc.strdup.restype = ctypes.c_void_p
|
|
libc.strdup.argtypes = [ctypes.c_char_p]
|
|
libc.malloc.restype = ctypes.c_void_p
|
|
libc.malloc.argtypes = [ctypes.c_size_t]
|
|
return libc
|
|
except OSError:
|
|
continue
|
|
return None
|
|
|
|
|
|
def load_libpam():
|
|
for name in ("libpam.so.0", "libpam.so"):
|
|
try:
|
|
return ctypes.CDLL(name)
|
|
except OSError:
|
|
continue
|
|
return None
|
|
|
|
|
|
class PamMessage(ctypes.Structure):
|
|
_fields_ = [("msg_style", ctypes.c_int), ("msg", ctypes.c_char_p)]
|
|
|
|
|
|
class PamResponse(ctypes.Structure):
|
|
_fields_ = [("resp", ctypes.c_char_p), ("resp_retcode", ctypes.c_int)]
|
|
|
|
|
|
PAM_CONV_FUNC = ctypes.CFUNCTYPE(
|
|
ctypes.c_int, # return
|
|
ctypes.c_int, # num_msg
|
|
ctypes.POINTER(ctypes.POINTER(PamMessage)),
|
|
ctypes.POINTER(ctypes.POINTER(PamResponse)),
|
|
ctypes.c_void_p, # appdata_ptr
|
|
)
|
|
|
|
|
|
class PamConv(ctypes.Structure):
|
|
_fields_ = [("conv", PAM_CONV_FUNC), ("appdata_ptr", ctypes.c_void_p)]
|
|
|
|
|
|
def out(doc, code):
|
|
sys.stdout.write(json.dumps(doc))
|
|
sys.stdout.write("\n")
|
|
sys.stdout.flush()
|
|
sys.exit(code)
|
|
|
|
|
|
def main():
|
|
# 1. credentials arrive on stdin (never argv)
|
|
try:
|
|
raw = sys.stdin.buffer.read(MAX_DOC_BYTES + 1)
|
|
except Exception:
|
|
out({"ok": False, "reason": "stdin-read-error"}, EXIT_FAIL)
|
|
if not raw or len(raw) > MAX_DOC_BYTES:
|
|
out({"ok": False, "reason": "bad-request"}, EXIT_FAIL)
|
|
try:
|
|
doc = json.loads(raw.decode("utf-8"))
|
|
except Exception:
|
|
out({"ok": False, "reason": "bad-json"}, EXIT_FAIL)
|
|
|
|
user = doc.get("user")
|
|
password = doc.get("password")
|
|
service = doc.get("service") or "sysdeck"
|
|
if not isinstance(user, str) or not isinstance(password, str) or not user:
|
|
out({"ok": False, "reason": "bad-request"}, EXIT_FAIL)
|
|
if len(user) > 64 or len(password) > 512:
|
|
out({"ok": False, "reason": "oversized-credential"}, EXIT_FAIL)
|
|
|
|
# 2. libpam + libc must be present (libc owns the reply allocation —
|
|
# Linux-PAM free()s the conversation replies itself, so they must
|
|
# come from the C allocator, never from Python-managed buffers)
|
|
libpam = load_libpam()
|
|
libc = load_libc()
|
|
if libpam is None or libc is None:
|
|
out({"ok": False, "reason": "pam-unavailable"}, EXIT_FAIL)
|
|
|
|
libpam.pam_start.restype = ctypes.c_int
|
|
libpam.pam_start.argtypes = [
|
|
ctypes.c_char_p, # const char *service_name
|
|
ctypes.c_char_p, # const char *user
|
|
ctypes.POINTER(PamConv), # const struct pam_conv *
|
|
ctypes.POINTER(ctypes.c_void_p), # pam_handle_t **
|
|
]
|
|
libpam.pam_authenticate.restype = ctypes.c_int
|
|
libpam.pam_authenticate.argtypes = [ctypes.c_void_p, ctypes.c_int]
|
|
libpam.pam_acct_mgmt.restype = ctypes.c_int
|
|
libpam.pam_acct_mgmt.argtypes = [ctypes.c_void_p, ctypes.c_int]
|
|
libpam.pam_end.restype = ctypes.c_int
|
|
libpam.pam_end.argtypes = [ctypes.c_void_p, ctypes.c_int]
|
|
|
|
c_user = user.encode("utf-8")
|
|
c_pass = password.encode("utf-8")
|
|
|
|
# The conversation callback answers from the C allocator: Linux-PAM
|
|
# free()s both the reply array and the reply strings after the
|
|
# exchange, so Python-managed buffers would corrupt the heap. The
|
|
# callback closure captures c_user / c_pass directly — no
|
|
# appdata_ptr plumbing needed.
|
|
|
|
@PAM_CONV_FUNC
|
|
def conv(num_msg, msg, resp, appdata_ptr):
|
|
try:
|
|
RESP_SZ = ctypes.sizeof(PamResponse)
|
|
arr = libc.malloc(num_msg * RESP_SZ)
|
|
if not arr:
|
|
return PAM_CONV_ERR
|
|
for i in range(num_msg):
|
|
style = msg[i].contents.msg_style
|
|
text = None
|
|
if style == PAM_PROMPT_ECHO_OFF:
|
|
text = c_pass
|
|
elif style == PAM_PROMPT_ECHO_ON:
|
|
text = c_user
|
|
# PAM_ERROR_MSG / PAM_TEXT_INFO → acknowledged empty
|
|
s = libc.strdup(text) if text is not None else None
|
|
entry = PamResponse(resp=ctypes.cast(s, ctypes.c_char_p), resp_retcode=0)
|
|
ctypes.memmove(
|
|
ctypes.c_void_p(arr + i * RESP_SZ),
|
|
ctypes.byref(entry),
|
|
RESP_SZ,
|
|
)
|
|
resp[0] = ctypes.cast(arr, ctypes.POINTER(PamResponse))
|
|
return PAM_SUCCESS
|
|
except Exception:
|
|
return PAM_CONV_ERR
|
|
|
|
conv_struct = PamConv(conv=conv, appdata_ptr=None)
|
|
|
|
def attempt(svc_name):
|
|
handle = ctypes.c_void_p()
|
|
rc = libpam.pam_start(
|
|
svc_name.encode("utf-8"), c_user, ctypes.byref(conv_struct), ctypes.byref(handle)
|
|
)
|
|
if rc != PAM_SUCCESS:
|
|
return None, rc
|
|
try:
|
|
rc = libpam.pam_authenticate(handle, 0)
|
|
if rc == PAM_SUCCESS:
|
|
rc = libpam.pam_acct_mgmt(handle, 0)
|
|
return rc == PAM_SUCCESS, rc
|
|
finally:
|
|
libpam.pam_end(handle, rc)
|
|
|
|
# 3. authenticate — pick the PAM stack explicitly. An unconfigured
|
|
# service name silently routes through /etc/pam.d/other (often a
|
|
# deny-or-worse default), so the stack is only used when its file
|
|
# exists; otherwise "login" — the stack the console TTY uses — is
|
|
# the honest stand-in for "log in like at the console".
|
|
import os
|
|
|
|
pam_d_candidates = ("/etc/pam.d", "/usr/etc/pam.d", "/lib/live/service/pam.d")
|
|
have_stack = any(
|
|
os.path.isfile(os.path.join(d, service)) for d in pam_d_candidates
|
|
)
|
|
chosen = service if have_stack else "login"
|
|
ok, rc = attempt(chosen)
|
|
if ok is None:
|
|
out({"ok": False, "reason": "pam-start-error", "code": int(rc)}, EXIT_FAIL)
|
|
if ok:
|
|
out({"ok": True, "user": user}, EXIT_OK)
|
|
out({"ok": False, "reason": "auth-failed", "code": int(rc)}, EXIT_FAIL)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|