feature: a shell opens with one of the user's passkeys in place of the password and the TOTP code.
Release / release (push) Successful in 17s

This commit is contained in:
slawek committed 2026-10-29 23:13:11 +01:00
1 parent 3e78d520ae
commit 3ed79c7563
7 files changed
+129 -36

No files matched your search

@@ -1,5 +1,5 @@
"""The passkeys of the router's users, for signing in to the Router Manager and the OpenID Provider (vpnrouter_oidc),
with python3-fido2.
and for opening a shell, with python3-fido2.
A passkey is a discoverable WebAuthn credential with user verification, of the relying party
wan.local_fqdn, so the browser offers it at the HTTPS addresses on that name.
@@ -8,10 +8,11 @@ user entry: a JSON object {"id", "credential", "label", "created"}, id the crede
credential the attested credential data (with the public key), both base64url. A user has at
most PASSKEYS_MAX passkeys; service accounts have none.
Registration and sign-in are two steps each: begin answers the options for the browser and a
pending id, whose state the web service keeps in memory for PENDING_SECONDS; complete takes the
id and the browser's answer. A request that cannot be done raises vpnrouter_users.Failure, with
the statuses of that module; a sign-in that fails gives status 5 whatever the reason.
Registration, sign-in and the proof of a signed-in user for a shell are two steps each: begin
answers the options for the browser and a pending id, whose state the web service keeps in memory
for PENDING_SECONDS; complete takes the id and the browser's answer. A request that cannot be done
raises vpnrouter_users.Failure, with the statuses of that module; a sign-in or a proof that fails
gives status 5 whatever the reason.
"""
import json
@@ -172,3 +173,30 @@ def login_complete(connection, pending, response):
except (ValueError, KeyError, TypeError):
raise refused
return name
def shell_begin(connection, name):
"""Begin the proof of the signed-in user for a shell: {"id", "options"}, options those of navigator.credentials.get
for the user's own passkeys, with user verification."""
own = credentials(vpnrouter_users.entry(connection, name))
if not own:
raise vpnrouter_users.Failure(f'{name} has no passkey; add one on the Account page', 2)
options, state = server().authenticate_begin(own, user_verification=UserVerificationRequirement.REQUIRED)
return {'id': keep('shell', name, state), 'options': dict(options)}
def shell_complete(connection, name, pending, response):
"""Check the browser's answer to a begun proof of the user for a shell: one of the user's own passkeys, with the
user verified, while the user is enabled and not locked."""
refused = vpnrouter_users.Failure('the passkey is not accepted', 5)
state = take('shell', name, pending)
if state is None or not isinstance(response, dict):
raise refused
current = vpnrouter_users.entry(connection, name)
user = vpnrouter_users.summary(current)
if not user['enabled'] or user['locked']:
raise refused
try:
server().authenticate_complete(state, credentials(current), response)
except (ValueError, KeyError, TypeError):
raise refused
@@ -34,9 +34,10 @@ Pages:
the DER certificate and CRL of the root (root) and of each issuing CA, which the
certificates name in their AIA and CRL distribution points; no authentication
/shell while web.shell is true, a login shell of the user's console account on a terminal, for
a member of admins whose console login is on and who has a TOTP token: POST signs the user in again, unless
the user used a shell within web.shell_auth_timeout minutes, for a one-time ticket
(see shell_ticket()), GET with it is a WebSocket
a member of admins whose console login is on and who has a TOTP token: POST signs the user in again,
with the password and the TOTP code or with a passkey whose proof POST /shell/passkey begins,
unless the user used a shell within web.shell_auth_timeout minutes, for a one-time ticket
(see shell_ticket() and shell_passkey()), GET with it is a WebSocket
that carries the terminal (see Handler.shell()); /shell/editor/<session>/<id> reads, saves and
closes a file the shell opened with vpn-router-editor (vre) over EDITOR_SOCKET (see
shell_editor() and editor_request())
@@ -24,7 +24,7 @@ from .identities import account, groups, service_accounts, users
from .logs import logs
from .metrics import metrics_history, metrics_settings
from .pki import PUBLISHED, ca
from .shell import EDITOR_BODY_LIMIT, shell, shell_editor, shell_ticket
from .shell import EDITOR_BODY_LIMIT, shell, shell_editor, shell_passkey, shell_ticket
from .sites import SITE_FILES, site_files, sites
from .status import cached, details, html_of, postgresql_databases, shown, text_of
@@ -120,6 +120,7 @@ class Handler(BaseHTTPRequestHandler):
body, kind = json.dumps(document).encode() + b'\n', 'application/json'
elif directory == 'shell':
code, document = shell_ticket(claims.get('sub'), payload) if (path, self.command) == ('/shell', 'POST') \
else shell_passkey(claims.get('sub')) if (path, self.command) == ('/shell/passkey', 'POST') \
else (404, {'error': f'no route {self.command} {path}'})
if code != 200:
log(f'shell ticket of {claims.get("sub")} from {self.client()} refused: {document["error"]}')
@@ -1,5 +1,6 @@
"""/shell: the ticket, the WebSocket of the terminal (shell), and the editor of the shell: the socket vpn-router-editor
names files on (editor_listen) and /shell/editor/... (shell_editor)."""
"""/shell: the ticket (shell_ticket), with the passkey proof it may take (shell_passkey), the WebSocket of the terminal
(shell), and the editor of the shell: the socket vpn-router-editor names files on (editor_listen) and /shell/editor/...
(shell_editor)."""
import base64
import fcntl
@@ -20,6 +21,7 @@ import urllib.parse
import ldap
import vpnrouter
import vpnrouter_passkeys
import vpnrouter_users
from vpnrouter_status import run
@@ -74,11 +76,14 @@ _shell_sessions = {}
def shell_ticket(name, body):
"""Answer POST /shell: the HTTP status and the JSON document.
The user named by the token signs in again with {"password", "code"?}; a wrong password or code is 403 and counts
as a failed login. Without a password, the user is not asked to sign in while its last use of a shell (see
_shell_used) is less than web.shell_auth_timeout minutes ago. The answer is {"ticket", "expires_in"}: a ticket that
opens one shell (GET /shell) for the same user within SHELL_TICKET_SECONDS, or a null ticket when the user must
sign in. 403 while web.shell is false, the user's console login is not on or the user has no TOTP token.
The user named by the token signs in again with {"password", "code"}, its password and TOTP code, or with
{"passkey": {"id", "credential"}}, the answer of the browser to a proof begun by POST /shell/passkey (see
shell_passkey) with one of its own passkeys and the user verified. A wrong password or code counts as a failed
login; it and a passkey that is not accepted are 403. Without either, the user is not asked to sign in while its
last use of a shell (see _shell_used) is less than web.shell_auth_timeout minutes ago. The answer is {"ticket",
"expires_in"}: a ticket that opens one shell (GET /shell) for the same user within SHELL_TICKET_SECONDS, or a null
ticket when the user must sign in. 403 while web.shell is false, the user's console login is not on or the user has
no TOTP token.
"""
if not isinstance(name, str):
return 403, {'error': 'the shell needs a token of a signed-in user'}
@@ -91,16 +96,20 @@ def shell_ticket(name, body):
refusal = console_account(name)[1]
if refusal:
return 403, {'error': refusal}
if 'password' not in body:
if 'passkey' in body:
passkey = body['passkey'] if isinstance(body['passkey'], dict) else {}
with vpnrouter_users.session() as connection:
vpnrouter_passkeys.shell_complete(connection, name, passkey.get('id'), passkey.get('credential'))
elif 'password' in body:
vpnrouter_users.login(name, body.get('password'), body.get('code'))
else:
with _shell_lock:
used = _shell_used.get(name)
if used is None or time.monotonic() - used >= options['shell_auth_timeout'] * 60:
return 200, {'ticket': None, 'expires_in': 0}
else:
vpnrouter_users.login(name, body.get('password'), body.get('code'))
except vpnrouter_users.Failure as failure:
if failure.status == 5:
return 403, {'error': 'the password or the code is wrong'}
return 403, {'error': 'the passkey is not accepted' if 'passkey' in body else 'the password or the code is wrong'}
return USER_STATUS.get(failure.status, 500), {'error': str(failure)}
except (ldap.LDAPError, OSError) as exc:
return 500, {'error': f'the user directory failed: {exc}'}
@@ -116,9 +125,32 @@ def shell_ticket(name, body):
return 200, {'ticket': ticket, 'expires_in': SHELL_TICKET_SECONDS}
def shell_passkey(name):
"""Answer POST /shell/passkey: begin the proof of the signed-in user with one of its own passkeys for POST /shell,
{"id", "options"}, options those of navigator.credentials.get; the HTTP status and the JSON document. 403 while
the user may not use a shell (console_account), 400 for a user without a passkey."""
if not isinstance(name, str):
return 403, {'error': 'the shell needs a token of a signed-in user'}
try:
if not vpnrouter.load_config()['web']['shell']:
return 403, {'error': 'the shell is disabled; vpn-config-edit set web.shell true enables it'}
refusal = console_account(name)[1]
if refusal:
return 403, {'error': refusal}
with vpnrouter_users.session() as connection:
return 200, vpnrouter_passkeys.shell_begin(connection, name)
except vpnrouter_users.Failure as failure:
return USER_STATUS.get(failure.status, 500), {'error': str(failure)}
except (ldap.LDAPError, OSError) as exc:
return 500, {'error': f'the user directory failed: {exc}'}
except SystemExit as exc:
return 500, {'error': str(exc.code)}
def console_account(name):
"""The system account the console of the user runs as and None, or None and the reason the user may not use a
console: its console login is off, or it has no TOTP token."""
console: its console login is off, or it has no TOTP token, which the PAM login session of the shell and sudo need
also when a passkey opens the shell."""
with vpnrouter_users.session() as connection:
user = vpnrouter_users.summary(vpnrouter_users.entry(connection, name))
if not user['console'] or not user['console']['enabled']: