Python’s spwd module (Unix only, requires root, deprecated Python 3.11, removed Python 3.13) wraps the getspnam(3) / getspent(3) C library calls that read the shadow password database (/etc/shadow). import spwd. Single user: spwd.getspnam("alice") → spwd.struct_spwd. All users: spwd.getspall() → list[spwd.struct_spwd]. Fields: .sp_namp (username), .sp_pwdp (hashed password), .sp_lstchg (days since epoch of last change), .sp_min (min days between changes), .sp_max (max days before expiry), .sp_warn (days before expiry to warn), .sp_inact (days after expiry before disable), .sp_expire (absolute expiry day from epoch), .sp_flag (reserved). Requires root or membership in the shadow group. Hash check: use crypt.crypt(password, hash) or passlib for constant-time comparison against .sp_pwdp. Expiry: sp_expire days × 86400 → UNIX timestamp; compare with time.time(). Claude Code generates custom Unix password validators, password age checkers, account expiry monitors, and shadow file auditors.
CLAUDE.md for spwd
## spwd Stack
- Stdlib: import spwd, crypt, time (Unix/root, deprecated 3.11, removed 3.13)
- Single: entry = spwd.getspnam("alice") # requires root/shadow group
- All: entries = spwd.getspall()
- Fields: .sp_namp login name (str)
- .sp_pwdp hashed password (str)
- .sp_lstchg last-change day (int, days from epoch)
- .sp_min/.sp_max/.sp_warn password aging (days)
- .sp_inact days after expire before lock (int)
- .sp_expire account expiry day (int, days from epoch)
- Hash: crypt.crypt(password, entry.sp_pwdp) == entry.sp_pwdp
- Note: Removed 3.13; use subprocess+getent or PAM for new code
spwd Shadow Password Pipeline
# app/spwdutil.py — lookup, verify, age-check, expiry, audit, parse-shadow
from __future__ import annotations
import os
import time
from dataclasses import dataclass, field
from pathlib import Path
_SPWD_AVAILABLE = False
try:
import spwd as _spwd
_SPWD_AVAILABLE = True
except ImportError:
pass
_CRYPT_AVAILABLE = False
try:
import crypt as _crypt
_CRYPT_AVAILABLE = True
except ImportError:
try:
# Python 3.13+: crypt removed; try passlib
from passlib.hash import sha512_crypt as _sha512 # type: ignore
_CRYPT_AVAILABLE = False # use passlib path
except ImportError:
pass
_EPOCH = 0 # seconds per day
_DAY_SECONDS = 86400
# ─────────────────────────────────────────────────────────────────────────────
# 1. Shadow entry dataclass (mirrors spwd.struct_spwd)
# ─────────────────────────────────────────────────────────────────────────────
@dataclass
class ShadowEntry:
username: str
password: str # hashed; "!" or "*" = locked/disabled
last_change: int # days since epoch
min_days: int # min days between changes
max_days: int # max days password is valid
warn_days: int # days before expiry to warn
inact_days: int # days after expiry before lock
expire_day: int # account expiry (days since epoch); -1 = never
flag: int = 0
def from_spwd_struct(s: Any) -> ShadowEntry:
"""Convert a spwd.struct_spwd to a ShadowEntry."""
return ShadowEntry(
username=s.sp_namp,
password=s.sp_pwdp,
last_change=s.sp_lstchg,
min_days=s.sp_min,
max_days=s.sp_max,
warn_days=s.sp_warn,
inact_days=s.sp_inact,
expire_day=s.sp_expire,
flag=s.sp_flag,
)
from typing import Any
def get_shadow_entry(username: str) -> ShadowEntry | None:
"""
Return the shadow entry for a specific user. Requires root.
Returns None if not found or spwd is unavailable.
Example:
entry = get_shadow_entry("alice")
if entry:
print(entry.username, entry.last_change)
"""
if not _SPWD_AVAILABLE:
return None
try:
return from_spwd_struct(_spwd.getspnam(username))
except (KeyError, PermissionError, Exception):
return None
def get_all_shadow_entries() -> list[ShadowEntry]:
"""
Return all shadow entries. Requires root.
Example:
entries = get_all_shadow_entries()
for e in entries:
print(e.username, e.max_days)
"""
if not _SPWD_AVAILABLE:
return []
try:
return [from_spwd_struct(s) for s in _spwd.getspall()]
except (PermissionError, Exception):
return []
# ─────────────────────────────────────────────────────────────────────────────
# 2. Password hash verification
# ─────────────────────────────────────────────────────────────────────────────
def is_locked(entry: ShadowEntry) -> bool:
"""Return True if the account is locked (hash starts with '!' or '*')."""
p = entry.password
return p.startswith("!") or p.startswith("*") or p in ("x", "")
def verify_password(username: str, password: str) -> bool:
"""
Verify a plaintext password against the shadow hash.
Returns False if root access is denied, the account is locked,
or the hash doesn't match.
Example:
if verify_password("alice", "s3cr3t"):
print("authenticated")
"""
entry = get_shadow_entry(username)
if entry is None or is_locked(entry):
return False
if not _CRYPT_AVAILABLE:
return False
try:
hashed = _crypt.crypt(password, entry.password)
# constant-time comparison
import hmac
return hmac.compare_digest(hashed, entry.password)
except Exception:
return False
# ─────────────────────────────────────────────────────────────────────────────
# 3. Password age and expiry helpers
# ─────────────────────────────────────────────────────────────────────────────
def _today_days() -> int:
"""Return today as integer days since the Unix epoch."""
return int(time.time() // _DAY_SECONDS)
def days_since_last_change(entry: ShadowEntry) -> int:
"""Return the number of days since the password was last changed."""
if entry.last_change <= 0:
return 0
return _today_days() - entry.last_change
def password_expired(entry: ShadowEntry) -> bool:
"""Return True if the password has exceeded its maximum age."""
if entry.max_days <= 0:
return False
return days_since_last_change(entry) >= entry.max_days
def days_until_expiry(entry: ShadowEntry) -> int | None:
"""
Return days until the password expires, or None if it never expires.
Example:
d = days_until_expiry(entry)
if d is not None and d < 7:
print(f"Password expires in {d} days!")
"""
if entry.max_days <= 0:
return None
age = days_since_last_change(entry)
return max(0, entry.max_days - age)
def account_expired(entry: ShadowEntry) -> bool:
"""Return True if the account has passed its expiry date."""
if entry.expire_day <= 0:
return False
return _today_days() >= entry.expire_day
# ─────────────────────────────────────────────────────────────────────────────
# 4. /etc/shadow file parser (fallback — no root needed if file is readable)
# ─────────────────────────────────────────────────────────────────────────────
def parse_shadow_file(path: str = "/etc/shadow") -> list[ShadowEntry]:
"""
Parse /etc/shadow directly (useful in containers where the spwd module
may not be available or the file is accessible without root).
Returns [] if the file cannot be read.
Example:
entries = parse_shadow_file()
for e in entries:
print(e.username, "locked=" + str(is_locked(e)))
"""
entries: list[ShadowEntry] = []
try:
text = Path(path).read_text(encoding="utf-8", errors="replace")
except OSError:
return entries
for line in text.splitlines():
line = line.strip()
if not line or line.startswith("#"):
continue
parts = line.split(":")
if len(parts) < 9:
continue
def _int(s: str, default: int = -1) -> int:
try:
return int(s) if s else default
except ValueError:
return default
entries.append(ShadowEntry(
username=parts[0],
password=parts[1],
last_change=_int(parts[2], 0),
min_days=_int(parts[3], 0),
max_days=_int(parts[4], 99999),
warn_days=_int(parts[5], 7),
inact_days=_int(parts[6], -1),
expire_day=_int(parts[7], -1),
flag=_int(parts[8], 0),
))
return entries
# ─────────────────────────────────────────────────────────────────────────────
# 5. Shadow database auditor
# ─────────────────────────────────────────────────────────────────────────────
@dataclass
class ShadowAuditReport:
total_users: int = 0
locked_users: int = 0
expired_pwds: int = 0
no_expiry: int = 0
account_expired: int = 0
expiring_soon: list[str] = field(default_factory=list) # warn within 7 days
issues: list[str] = field(default_factory=list)
def audit_shadow(entries: list[ShadowEntry],
warn_days: int = 7) -> ShadowAuditReport:
"""
Produce a summary audit of shadow password entries.
Example:
entries = parse_shadow_file()
report = audit_shadow(entries)
print(report.expired_pwds, report.issues)
"""
report = ShadowAuditReport(total_users=len(entries))
for e in entries:
if is_locked(e):
report.locked_users += 1
continue
if password_expired(e):
report.expired_pwds += 1
report.issues.append(
f"{e.username}: password expired "
f"({days_since_last_change(e)} days old)")
elif e.max_days <= 0:
report.no_expiry += 1
else:
d = days_until_expiry(e)
if d is not None and d <= warn_days:
report.expiring_soon.append(
f"{e.username} ({d} days)")
if account_expired(e):
report.account_expired += 1
report.issues.append(
f"{e.username}: account expired "
f"(expire_day={e.expire_day})")
return report
# ─────────────────────────────────────────────────────────────────────────────
# Demo
# ─────────────────────────────────────────────────────────────────────────────
if __name__ == "__main__":
print("=== spwd demo ===")
print(f" spwd available: {_SPWD_AVAILABLE}")
print(f" running as uid: {os.getuid()}")
# Try spwd first, then fall back to parsing /etc/shadow
if _SPWD_AVAILABLE and os.getuid() == 0:
entries = get_all_shadow_entries()
print(f"\n loaded {len(entries)} entries from spwd.getspall()")
else:
entries = parse_shadow_file()
print(f"\n loaded {len(entries)} entries from /etc/shadow parse")
if entries:
print("\n--- first 5 shadow entries ---")
for e in entries[:5]:
locked = "LOCKED" if is_locked(e) else "active"
exp = "no-expiry" if e.max_days <= 0 else f"max={e.max_days}d"
print(f" {e.username:20s} {locked:8s} {exp}")
print("\n--- password age & expiry ---")
for e in entries[:5]:
if is_locked(e):
continue
age = days_since_last_change(e)
d_left = days_until_expiry(e)
exp_str = f"{d_left}d left" if d_left is not None else "never"
acc_exp = "EXPIRED" if account_expired(e) else "ok"
print(f" {e.username:20s} age={age:5d}d pwd={exp_str:12s} acct={acc_exp}")
print("\n--- audit_shadow ---")
report = audit_shadow(entries)
print(f" total = {report.total_users}")
print(f" locked = {report.locked_users}")
print(f" expired = {report.expired_pwds}")
print(f" no-expiry= {report.no_expiry}")
print(f" acc-exp = {report.account_expired}")
if report.expiring_soon:
print(f" soon = {report.expiring_soon}")
if report.issues:
for issue in report.issues[:3]:
print(f" ISSUE: {issue}")
else:
print(" no issues found")
else:
print(" (no entries accessible — may need root or /etc/shadow read access)")
print("\n=== done ===")
For the pwd stdlib alternative — pwd.getpwnam("alice") and pwd.getpwall() return /etc/passwd entries without the hashed password field (which is in /etc/shadow) — use pwd when you only need UID/GID/home information and don’t need password hashes or aging data; use spwd (or parse_shadow_file()) when you specifically need access to password age or hash data. For the python-pam / simplepam (PyPI) alternative — pam.pam().authenticate(username, password) invokes the system PAM (Pluggable Authentication Modules) stack, which handles shadow passwords, LDAP, Kerberos, and MFA transparently — use PAM for production authentication; never roll your own crypt.crypt() comparisons in production code. The spwd module was deprecated in Python 3.11 and removed in 3.13. The Claude Skills 360 bundle includes spwd skill sets covering ShadowEntry dataclass, get_shadow_entry()/get_all_shadow_entries() wrappers, is_locked()/verify_password() auth helpers, days_since_last_change()/password_expired()/days_until_expiry()/account_expired() aging helpers, parse_shadow_file() root-free fallback, and ShadowAuditReport/audit_shadow() auditor. Start with the free tier to try shadow password patterns and spwd pipeline code generation.