Authentication
Authentication is fail-closed. WijjitSSH raises
at construction unless you pass an auth policy that actually authenticates,
or explicitly pass allow_anonymous=True:
>>> WijjitSSH(make_app, host_keys=host_keys)
ValueError: WijjitSSH requires an auth policy. Pass auth=... ...
Serving an unauthenticated SSH server should be something you typed, not something you inherited by forgetting an argument.
The check is on the outcome, not on whether auth= was passed. A policy
that waives authentication serves exactly the server that omitting the policy
would have, so it has to clear the same bar:
>>> WijjitSSH(make_app, host_keys=host_keys, auth=OpenAuth())
ValueError: OpenAuth requires no authentication, so this server would let
anyone connect as any username. ...
That covers an OpenAuth buried inside a
ChainAuth as well, since a chain waives authentication
whenever any of its members does.
asyncssh drives authentication through a handful of callbacks on its
SSHServer object. Wiring credentials straight into those
callbacks works, but couples every deployment to the server glue. Instead the
server delegates to an AuthPolicy, so how a
deployment authenticates is a value you pass in rather than code you edit.
The presets
Public keys
AuthorizedKeys is the recommended policy: no shared
secret ever crosses the wire.
from wijjit_ssh import AuthorizedKeys
# One file for everyone, OpenSSH format.
auth = AuthorizedKeys("~/.ssh/authorized_keys")
# Or one file per user - the username selects the file.
auth = AuthorizedKeys({"alice": "keys/alice.pub", "bob": "keys/bob.pub"})
A username with no entry is denied. In the per-user form that is the whole access-control list: adding a user means adding a file.
Passwords
PasswordAuth delegates the check to your callback, so
the credential store is yours - LDAP, a database, a hash table.
from wijjit_ssh import PasswordAuth
from wijjit_ssh.auth import check_password
async def check(username: str, password: str) -> bool:
expected = await lookup(username) # your database; async is fine
return expected is not None and check_password(password, expected)
auth = PasswordAuth(check)
The callback may be sync or async. Prefer async for anything that talks to a network or runs a KDF: a policy is consulted on the event loop, so a blocking lookup stalls every other session in the process.
Important
Use check_password() rather than == when comparing
plaintext secrets - it compares in constant time, so an attacker cannot
recover the secret one byte at a time from response timing. For anything
stored at rest, use a real password hash (argon2, bcrypt) and verify
with that library instead.
Chaining
ChainAuth accepts if any of its policies accepts:
from wijjit_ssh import ChainAuth
auth = ChainAuth(
AuthorizedKeys("~/.ssh/authorized_keys"),
PasswordAuth(check),
)
The client picks a method and the chain answers for it, so this is “keys or a password”, not “keys then a password”. There is no multi-factor mode.
No authentication
OpenAuth lets anyone connect as any username. The
username is whatever the client typed and is not verified in any way.
Danger
Development and demos only. WijjitSSH refuses to use this policy unless
allow_anonymous=True is also passed, and logs a loud warning at startup
when it does. Never expose it on an untrusted network.
Writing your own
Subclass AuthPolicy and override only the methods for
the mechanisms you support. The base class denies everything - it requires
authentication and supports no method - so a subclass that forgets to enable a
method fails closed rather than open.
The surface is three pairs, one per SSH mechanism:
Mechanism |
“Do you offer it?” |
“Is this credential good?” |
|---|---|---|
Password |
|
|
Public key |
|
|
Keyboard-interactive |
|
|
Public keys work differently from the other two: rather than verifying a
credential, you return the list of keys a user is allowed to use, and
asyncssh performs the signature check itself. Return None for an unknown
user.
from wijjit_ssh.auth import AuthPolicy, check_password
class TokenAuth(AuthPolicy):
"""Accept a bearer token typed at a keyboard-interactive prompt."""
def kbdint_supported(self) -> bool:
return True
def kbdint_prompts(self, username: str):
return [("Token: ", False)] # echo=False hides typing
async def verify_kbdint(self, username: str, responses: list[str]) -> bool:
expected = await self.store.token_for(username)
return expected is not None and check_password(responses[0], expected)
Note
A policy is consulted per connection attempt and may be shared across
connections, so implementations should be stateless (or internally
idempotent) and must not block the event loop. Do slow work - database
lookups, KDF verification - in the async methods or an executor.
What the app sees
The authenticated username is handed to your factory as session.username, so
apps can personalise and authorise per user:
def make_app(session: SSHSession) -> Wijjit:
if session.username not in ADMINS:
return build_readonly_app(session)
return build_admin_app(session)
Note that authorisation inside the app is your job. wijjit-ssh answers “is this person who they say they are”; it has no notion of what they are then allowed to do.
Timeouts and logging
login_timeout (default 30 seconds, tightening asyncssh’s own 120) bounds how
long an unauthenticated peer can hold resources. Per-IP connection limits are
applied before authentication - see Resource limits.
Credentials are never logged. Outcomes are: auth.ok and auth.failed
records carry the username, peer address, and method, and the same pair is
emitted to the on_event metrics hook. See Logging and metrics.