diff --git a/ADR.md b/ADR.md index 03a85d6..f944000 100644 --- a/ADR.md +++ b/ADR.md @@ -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-` 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. diff --git a/README.md b/README.md index 0591ab8..1b019d3 100644 --- a/README.md +++ b/README.md @@ -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=` | 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-`; 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-`; 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. diff --git a/debian-package/debian/changelog b/debian-package/debian/changelog index 2e612a1..79294ce 100644 --- a/debian-package/debian/changelog +++ b/debian-package/debian/changelog @@ -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 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). diff --git a/debian-package/src/usr/lib/vpn-router/vpnrouter_passkeys.py b/debian-package/src/usr/lib/vpn-router/vpnrouter_passkeys.py index 3200d4b..c4ab409 100644 --- a/debian-package/src/usr/lib/vpn-router/vpnrouter_passkeys.py +++ b/debian-package/src/usr/lib/vpn-router/vpnrouter_passkeys.py @@ -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 diff --git a/debian-package/src/usr/lib/vpn-router/vpnrouter_web/__init__.py b/debian-package/src/usr/lib/vpn-router/vpnrouter_web/__init__.py index cd2b7e7..2849c2f 100644 --- a/debian-package/src/usr/lib/vpn-router/vpnrouter_web/__init__.py +++ b/debian-package/src/usr/lib/vpn-router/vpnrouter_web/__init__.py @@ -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// reads, saves and closes a file the shell opened with vpn-router-editor (vre) over EDITOR_SOCKET (see shell_editor() and editor_request()) diff --git a/debian-package/src/usr/lib/vpn-router/vpnrouter_web/handler.py b/debian-package/src/usr/lib/vpn-router/vpnrouter_web/handler.py index db6b6ae..9cb32ae 100644 --- a/debian-package/src/usr/lib/vpn-router/vpnrouter_web/handler.py +++ b/debian-package/src/usr/lib/vpn-router/vpnrouter_web/handler.py @@ -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"]}') diff --git a/debian-package/src/usr/lib/vpn-router/vpnrouter_web/shell.py b/debian-package/src/usr/lib/vpn-router/vpnrouter_web/shell.py index 656dfa6..426e099 100644 --- a/debian-package/src/usr/lib/vpn-router/vpnrouter_web/shell.py +++ b/debian-package/src/usr/lib/vpn-router/vpnrouter_web/shell.py @@ -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']: