Compare commits

..
1 Commits
Author SHA1 Message Date
slawek 3ed79c7563 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
2026-10-29 23:13:11 +01:00
7 changed files with 129 additions and 36 deletions

No files matched your search

+12 -5
View File
@@ -908,8 +908,14 @@ Decision: `/shell` of the web service is off until `web.shell` is set on the rou
Manager cannot set it. The shell runs as the console account of the signed-in user (ADR-34), so
it is never root and each administrator's shell is the administrator's own; a user without
console login gets none. `POST /shell` signs the user in again with the password
and TOTP code and gives a one-time ticket valid 30 seconds; a WebSocket on `GET /shell` with that
ticket gets the shell. Asking for the password and code at every opening of the page is tiresome,
and TOTP code, or with one of the user's own passkeys (ADR-36), and gives a one-time ticket valid 30
seconds; a WebSocket on `GET /shell` with that ticket gets the shell. A passkey with user
verification is a possession and a PIN or biometric in one step and cannot be phished, so it is
as good as the password and the code; `POST /shell/passkey` begins a proof bound to the signed-in
user and its credentials, and python3-fido2 refuses an answer without the user-verified flag. The
TOTP enrolment stays required with a passkey: the PAM login session of the shell, through the
`pam_authz_search` of nslcd for a directory account, and sudo in it need it, and loosening that
filter would let such a user log in over SSH with the password alone. Asking for the password and code at every opening of the page is tiresome,
so within `web.shell_auth_timeout` minutes of the user's last sign-in or keystroke a ticket is given
without them; the times are kept in the web service's memory only, so a restart asks again, and 0
keeps the sign-in at every opening. The WebSocket of RFC 6455 is implemented on the standard library inside the
@@ -920,9 +926,10 @@ makes the terminal its controlling terminal so window sizes reach the shell. A s
Manager checks the session and the WebSocket's origin, and relays the bytes to the router after
both handshakes.
Consequences: Who can sign in as a member of `admins` and knows the password and the current code
can get a shell when the router allows one and the user's console login is on; a session alone
cannot. The ticket travels in the URL and appears in request logs, used up. The shell's sessions
Consequences: Who can sign in as a member of `admins` and knows the password and the current code,
or holds one of the user's passkeys and unlocks it, can get a shell when the router allows one and
the user's console login is on; a session alone cannot. A failed passkey proof is refused but not
counted as a failed login, as with the passkey sign-in. The ticket travels in the URL and appears in request logs, used up. The shell's sessions
show up as `vpn-router-shell-<id>` units and logind sessions of the console account. The shell's
traffic is as protected as the Router Manager's: HTTPS to Caddy, plain HTTP on the internal
network to the web service.
+20 -10
View File
@@ -1080,8 +1080,9 @@ every service and tunnel while the public status page shows less.
### The shell
While `web.shell` is `true`, `/shell` gives a member of `admins` whose console login is on and
who is enrolled for TOTP (see Console login and SSH) a login shell on the router, as the user's console account, for the
Router Manager's Shell page. It is off by default and switched on and off on the router only:
who is enrolled for TOTP (see Console login and SSH) a login shell on the router, as the user's
console account, for the Router Manager's Shell page. It is off by default and switched on and
off on the router only:
```sh
vpn-router-user console alice --posix
@@ -1090,20 +1091,29 @@ vpn-config-edit set web.shell true
| Request | Body | Answer |
| --- | --- | --- |
| `POST /shell` | `{"password", "code"}`; `{}` within `web.shell_auth_timeout` | `{"ticket", "expires_in": 30}` |
| `POST /shell/passkey` | `{}` | `{"id", "options"}`, `options` those of `navigator.credentials.get` for the user's own passkeys |
| `POST /shell` | `{"password", "code"}`, or `{"passkey": {"id", "credential"}}`; `{}` within `web.shell_auth_timeout` | `{"ticket", "expires_in": 30}` |
| `GET /shell?ticket=<ticket>` | a WebSocket handshake | the terminal |
`POST /shell` needs an access token of `/auth`, not one of `vpn-router-token`, and signs its user
in again: a wrong password or code gets 403 and counts as a failed login; it gets 403 too while
`web.shell` is `false`, the user's console login is off or the user is not enrolled for TOTP. Without a password it gives a ticket
while the user's last sign-in of `POST /shell` or keystroke in a shell is less than
`web.shell_auth_timeout` minutes ago, and `{"ticket": null}` otherwise. The ticket opens one
in again, with the password and the TOTP code, or with a passkey: `POST /shell/passkey` begins a
proof for one of the user's own passkeys with user verification, which the browser answers within
two minutes, and `POST /shell` takes its `id` and the browser's answer as `credential`. A passkey
with user verification is two factors in one, so it takes the place of the password and the code.
A wrong password or code gets 403 and counts as a failed login, and a passkey that is not accepted
gets 403. `POST /shell` gets 403 too while `web.shell` is `false`, the user's console login is off
or the user is not enrolled for TOTP: also with a passkey, the PAM login session of the shell,
`pam_authz_search` of nslcd for a directory account, and sudo need the enrolment. Without a
password or a passkey it gives a ticket while the user's last sign-in of
`POST /shell` or keystroke in a shell is less than `web.shell_auth_timeout` minutes ago, and
`{"ticket": null}` otherwise. The ticket opens one
shell for the same user within 30 seconds. Over the WebSocket, binary messages of the client
are keystrokes, a text message `{"cols", "rows"}` sets the window size, and binary messages of
the router are the terminal's output. The shell is a PAM login session of the console account,
run by `systemd-run` in a transient unit `vpn-router-shell-<id>`; sudo works as for SSH. It is closed after `web.shell_idle_minutes` without a keystroke, when the shell
exits, when the connection closes, or within a minute of the user's console login being switched
off or the TOTP enrolment removed; the router then sends a close frame with the reason. The
run by `systemd-run` in a transient unit `vpn-router-shell-<id>`; sudo works as for SSH, with the
password and the TOTP code. It is closed after `web.shell_idle_minutes` without a keystroke, when
the shell exits, when the connection closes, or within a minute of the user's console login being
switched off or the TOTP enrolment removed; the router then sends a close frame with the reason. The
journal of `vpn-router-web` records who opened each shell, from which address, in which unit,
when it ended and why, and each refused ticket or shell with the reason.
+14
View File
@@ -1,3 +1,17 @@
vpn-router (0.36.0-1) unstable; urgency=medium
feature: a shell opens with one of the user's passkeys in place of the password and the TOTP code.
* POST /shell/passkey begins a proof for one of the signed-in user's own
passkeys, with user verification; POST /shell takes the browser's
answer as {"passkey": {"id", "credential"}} in place of the password
and the code, and gives the ticket of the shell.
* A passkey that is not accepted is refused with 403. The shell still
needs the user's TOTP enrolment, which its login session and sudo in
it need.
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Thu, 29 Oct 2026 23:09:43 +0100
vpn-router (0.35.2-2) unstable; urgency=medium
update: the package is published for Ubuntu 26.04 (resolute) as well as 24.04 (noble).
@@ -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']: