Architecture¶
passwolf is three console tools over one shared layer cake. The tools, passwolf change, passwolf reset, and passwolf policy, share a model, a transport binder, the crypto, and the hand-built NDR types, but they never share operation-selection logic: a change, a reset, and a read are kept strictly apart so the security model of each is obvious at the call site. This page walks the module layout, the import boundaries that enforce that separation, the AUTO method-selection logic, and one invocation from argument to rendered outcome. Every path below is real and lives under src/passwolf/.
Module layout¶
The package splits into a shared core, a set of per-protocol method modules, a separate read surface, the rendering layer, and three CLI entry points.
| Layer | Module | Role |
|---|---|---|
| Shared core | model.py |
Transport-agnostic types and enums: ChangeMethod, ResetMethod, OutputFormat, TransportKind, AccountKind, the Target and Secret records, and the --target-domain / --target-user / --dc resolver. |
| Shared core | transport.py |
The DCE/RPC channel binder: BindIdentity (the principal that authenticates the bind) and open_channel, which returns a Channel carrying the bound dce and, over SMB, the session key. |
| Shared core | crypto.py |
The password cryptography for every wire format: DES OWF, the RC4 SAM buffers, the AEAD-AES-256-CBC-HMAC-SHA512 SAM and LSA buffers, the PBKDF2 content-encryption key, and the AES-128-CFB8 Netlogon buffer. |
| Shared core | ndr.py |
The NDR wire structures impacket does not model, plus the dispatch and union splices that let impacket carry them. |
| Shared core | constants.py |
Opnum numbers, info-class values, feature bits, and UAC flags, each cited to its spec section. |
| Change/reset methods | samr.py |
The SAMR change and reset operations, and the SamrConnect5 AES capability preflight. |
| Change/reset methods | netlogon.py |
The Netlogon machine and trust change over the sealed secure channel (opnums 30 and 6). |
| Change/reset methods | lsa.py |
The LSA trust-secret set (LsarSetSecret2 opnum 138, LsarSetSecret opnum 29). |
| Change/reset methods | kpasswd.py |
The Kerberos change (no targname/targrealm) and set (with targname/targrealm) protocols, both framed with protocol version 0xFF80. |
| Change/reset methods | ldap.py |
The LDAP unicodePwd change (delete-old + add-new) and reset (single replace). |
| Change/reset methods | rap.py |
The legacy RAP NetUserPasswordSet2 change (opcode 115) and OEM change (opcode 214) over SMB1. |
| Read surface | policy.py |
One function per policy-read wire method, importing nothing from the change/reset modules. |
| Read surface | policymodel.py |
The normalized policy records (PasswordPolicy, PsoPolicy, GptTmplPolicy, UserPolicyView, PolicyReadResult) and their decoders. |
| Rendering | console.py |
Outcome, render, and render_policy: the text, JSON, and pretty formatters for both the change/reset outcome and the policy read. |
| Errors | nterror.py |
NTSTATUS decoding: symbolic name, operator-facing description, and the success test. |
| Errors | errors.py |
The two control-flow exceptions: MethodUnavailable (drives AUTO fallback) and OperationFailed (a definite non-NTSTATUS failure). |
| Entry points | change.py |
The passwolf change CLI. |
| Entry points | reset.py |
The passwolf reset CLI. |
| Entry points | pwpolicy.py |
The passwolf policy CLI. |
The netlogon module
The machine and trust change lives in netlogon.py. It binds the Netlogon RPC interface (NRPC) directly rather than going through transport.open_channel, because the secure channel has its own NetrServerReqChallenge + NetrServerAuthenticate3 handshake and must be upgraded to RPC_C_AUTHN_NETLOGON sign+seal before the write. See transport for why it sits outside the shared channel binder.
Import DAG¶
The dependency graph is a DAG with the shared core at the bottom and the three entry points at the top. Nothing in the core imports a method module, and no method module imports an entry point.
model.py constants.py (no intra-package imports)
| |
crypto.py ------+ (crypto -> constants)
| |
ndr.py --------/ (ndr -> constants)
|
samr / netlogon / lsa / kpasswd / ldap / rap -> crypto, ndr, constants, errors, nterror, model
|
change.py reset.py -> the change/reset method modules + console + transport + model + nterror + errors
policy.py -> crypto, ndr, constants, policymodel (imports NO change/reset module)
policymodel.py -> (standalone records)
pwpolicy.py -> policy, policymodel, console, transport, model, errors
console.py depends only on nterror, model, and (lazily, inside the pretty formatter) rich and policymodel. It is imported by all three entry points and imports none of them.
The change / reset / read boundary¶
The split between the three operations is not a convention, it is an import boundary.
The change-versus-reset distinction is encoded as two separate enums in model.py, ChangeMethod and ResetMethod, that never overlap. A change carries an old-secret proof on the wire; a reset is a privileged overwrite that proves nothing. The two CLIs build different immutable configs (ChangeConfig in change.py, ResetConfig in reset.py) and dispatch through different functions (_run_change vs _run_reset). They share samr.py, but samr.py exposes change entry points (change_aes, change_rc4, ...) and reset entry points (reset_aes, reset_rc4, reset_hash, ...) as distinct functions, so a reset code path can never accidentally send a change.
The policy read is a third operation with no path that can mutate a secret. policy.py reuses crypto.py and ndr.py but imports nothing from any change/reset module. The read vocabulary is deliberately kept out of model.py: the policy method identifiers are plain string constants local to pwpolicy.py (SAMR_QUERY, LDAP_DOMAIN, SYSVOL, ...), and the normalized records live in policymodel.py, which the module docstring describes as kept apart from model.py "so the read vocabulary never mixes with the change/reset model." Even the methods that probe a change-failure path (the opnum-63 oracle, the kpasswd SOFTERROR probe) read only: they prove identity and harvest the returned policy without ever applying a change.
Why this matters
Conflating change and reset is the root cause of the bundled changepasswd.py confusion where a wrong-old-password change is indistinguishable from a policy block, and where a self-change blocked by minimum age looks like a permission error. Keeping the operations apart as separate tools, separate enums, and separate dispatch makes the required privilege and the policy-bypass semantics explicit. See Choosing a method for the user-facing version of this split.
AUTO method selection¶
Both passwolf change and passwolf reset default to --method auto, but the two AUTOs differ. passwolf change AUTO stays within the SAMR change and falls back only when a method is genuinely unavailable (the opnum faulted out of range, the transport cannot carry the method, or a required input is missing), signalled by a MethodUnavailable exception, not an NTSTATUS rejection. passwolf reset AUTO is a cross-protocol ladder: it tries kpasswd, then ldaps (636), then ldap (389), then the SAMR rungs (samr-aes, samr-rc4, samr-rc4-unsalted, samr-hash), taking the first that returns STATUS_SUCCESS. Because the rungs span protocols with different rights and policy semantics, a passwolf reset rung is abandoned on any failure (an unavailable method or a non-success NTSTATUS alike) and the next is tried; samr-hash is the last resort, applying even when a password policy rejected every cleartext rung.
The AES vs RC4 preflight¶
The AES-vs-RC4 decision is made deterministically, not by guessing from OS build numbers. samr.supports_aes issues a SamrConnect5 (opnum 64) and reads the SupportedFeatures field. It returns True when the server advertises the AES feature bit (0x10, per [MS-SAMR] 2.2.7.15 / 3.2.2.4), False when it explicitly does not, and None when SamrConnect5 is unavailable (pre-Vista) or the response cannot be read.
_auto_samr_change in change.py:
- Call
samr.supports_aes. If it returnsFalse, go straight to the RC4 change (SamrUnicodeChangePasswordUser2, opnum 55). - Otherwise try the AES change (
SamrUnicodeChangePasswordUser4, opnum 73). - Keep the fault fallback as a safety net: if the AES attempt raises
MethodUnavailable, or returnsSTATUS_NOT_SUPPORTED(0xC00000BB), fall back to the RC4 change. - For machine and trust accounts,
_auto_machine_changeruns a separate ladder:NETLOGON_AES(opnum 30) first; then, for a machine account only, the SAMR AES cleartext change (SAMR_AES), which hands the DC the plaintext so it regenerates every Kerberos key; thenNETLOGON_DES(opnum 6) as the floor. A trust account skips the SAMR rung (it is not SAMR-changeable that way), so its ladder isNETLOGON_AES->NETLOGON_DES. Each rung yields to the next onMethodUnavailable.
_run_auto_reset (and its SAMR tail _auto_samr_ladder) in reset.py walk a cross-protocol ladder, taking the first rung that returns STATUS_SUCCESS:
- If the operator supplied only a new NT hash rather than a cleartext password, AUTO skips every cleartext rung and goes straight to the set-hash reset (
SAMR_HASH,UserInternal1level 18, opnum 37, the identical sibling of SamrSetInformationUser2 opnum 58), because that is the only rung that can write a raw hash.UserAllInformation(level 21) can also carry the NT/LM OWF fields, so the dedicatedUserInternal1class is a deliberate choice rather than a wire necessity. See reset methods for the op37/op58 equivalence. - For a cleartext new password, try kpasswd (Kerberos set), then ldaps (636), then ldap (389). Each is abandoned on any failure (a closed port, an auth error, or a non-success status) and the next is tried.
- Open one SAMR named-pipe channel and call
samr.supports_aes. Try samr-aes (UserInternal7) unless the preflight returnedFalse, then samr-rc4 (UserInternal4InformationNew), then samr-rc4-unsalted (UserInternal4Information). - Fall back last to samr-hash (
UserInternal1), which writes the NT OWF derived from the cleartext and so still applies when a password policy rejected every cleartext rung. If even this fails, AUTO raisesMethodUnavailablewith the accumulated per-rung reasons.
Pinning a method disables fallback
When --method names a concrete method instead of auto, AUTO is skipped entirely and that method runs once. A pinned method that the DC rejects reports the real error rather than silently trying another, which is what you want when you are testing whether a specific opcode is enabled. The full method names are in change methods and reset methods, and the opnum-to-spec mapping is in the method matrix.
One invocation, end to end¶
A passwolf change run flows through six stages. The other two tools follow the same shape with their own config and dispatch.
-
Parse arguments.
change.mainbuilds the argument parser and parsesargv. The target is taken from--target-domain,--target-user, and--dc(with--dcdefaulting to--target-domain) and assembled bymodel.parse_target, and any--target-old-hashis decoded bymodel.parse_hash_pairinto raw LM/NT bytes. -
Build the immutable config.
_build_configresolves the parsed namespace into a frozenChangeConfig: theTarget, the new password, the oldSecret(cleartext or NT hash), theAccountKind, the selectedChangeMethod, theTransportKind, theBindIdentityfor the RPC bind, the NetBIOS name for the Netlogon channel, the LDAPS flag, and theOutputFormat. The config is@dataclass(frozen=True), so nothing downstream can mutate the resolved request. Missing inputs raiseValueErrorhere and exit with status 2. -
Open the channel. For the SAMR methods,
_run_samr_changecallstransport.open_channelwith the SAMR interface UUID, the\samrpipe, and the chosen transport. Over SMB the SMB session key is captured into theChannel(the reset cleartext info levels need it as their content-encryption key); over TCP there is no session key. The Kerberos, LDAP, RAP, and Netlogon methods open their own transport inside their modules rather than through the shared binder. -
Run the selected (or AUTO-selected) method.
_run_changedispatches by account kind first (machine and trust go to_run_netlogon_change), then by method. SAMR methods go through_run_samr_change, which either runs the pinned method once or runs_auto_samr_change. The method function builds its wire structure (fromndr.pyfor the AES and undocumented paths), encrypts the buffer (crypto.py), sends the request, and returns the raw NTSTATUS as anint. -
Decode the NTSTATUS. The returned status, method name, target label, and DC are packed into a single
console.Outcome.Outcome.successcallsnterror.is_success, and the formatters callnterror.describeto turn the code into an operator-facing line, distinguishing a wrong old password (STATUS_WRONG_PASSWORD) from a policy rejection (STATUS_PASSWORD_RESTRICTION) from a disabled method (STATUS_ACCESS_DENIED). See errors and NTSTATUS. -
Render the outcome.
console.render(outcome, cfg.output)selects the formatter for the chosenOutputFormat. The default is pretty: arichpanel, green on success and red on failure, withrichimported lazily only inside that formatter so the text and JSON paths carry no import cost.textis a single greppable status line;jsonis one object. The process exits 0 onSTATUS_SUCCESS, 1 on a failed or unavailable method, and 2 on a usage error.
Where MethodUnavailable and OperationFailed surface
MethodUnavailable is caught both inside AUTO (to trigger fallback) and at the top of main (an unavailable pinned method exits 1 with a clear log line). OperationFailed, raised by the non-NTSTATUS protocols (Kerberos kpasswd and LDAP) when they report a definite failure, is turned into a failed Outcome carrying the protocol's own detail string, so the result still renders in the chosen format rather than crashing. A raw DCERPCException that escapes a method is logged and exits 1.
The output formats themselves, including the default change to pretty, are covered in Output formats. The cryptographic constructions each method uses are in crypto and password buffers, and the per-method wire detail is in change methods, reset methods, and policy read methods.