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,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']: