diff --git a/ADR.md b/ADR.md index a07422c..24fda0a 100644 --- a/ADR.md +++ b/ADR.md @@ -247,3 +247,27 @@ serves them under `/groups`. Group names follow the rules of user names. Consequences: A group always satisfies the schema without a placeholder member, and an empty group cannot exist. Removing a user or a member can need another step first. A later use of groups, such as allowing OpenVPN only to some, can read them as they are. + +## ADR-12: TOTP secrets are kept with the OATH-LDAP schema of slapd's otp module + +Status: Accepted + +Context: Users are to be enrolled for TOTP one-time passwords, with nothing checking the codes +yet. Ubuntu's slapd ships the `otp` module, which holds the OATH-LDAP schema and an overlay that +checks a code appended to the password at bind time. Ubuntu packages `python3-pyotp`. An +authenticator app is enrolled from a QR code of an `otpauth://` URI. + +Decision: The `otp` module is loaded for its schema; its overlay is not configured. The TOTP +token is the user entry itself: enrolment adds `oathTOTPUser` and `oathTOTPToken` to it, with +`oathTOTPToken` naming the entry, the secret in `oathSecret` and `oathTOTPParams` naming one +shared parameter entry, `cn=totp,ou=policies`, with the parameters authenticator apps assume +(6 digits, HMAC-SHA1, 30 seconds). An ACL lets the user manager account set `oathSecret` and +no one read it. Enrolment is confirmed: `otp_new` makes a secret with `pyotp` and returns it +with its URI, storing nothing, and `otp_enrol` stores the secret it is given back only when a +code of it verifies, so a secret is never stored that the user's app does not produce. The +router answers the URI; the client renders the QR code. + +Consequences: Removing a user removes its token. Configuring the overlay later makes slapd +check the stored tokens without moving data. The secret passes through the client between +`otp_new` and `otp_enrol`, so the router keeps no pending state; enrolling again replaces the +secret only once a code of the new one verifies. diff --git a/README.md b/README.md index 78331e1..00ac766 100644 --- a/README.md +++ b/README.md @@ -331,6 +331,11 @@ is switched on: `/etc/vpn-router/ldap/user-manager.secret` (root only); - passwords are stored as ARGON2 hashes, and a disabled user is locked with the password policy (`pwdAccountLockedTime`); +- slapd's `otp` module is loaded for the OATH-LDAP schema; a user enrolled for TOTP is also an + `oathTOTPUser` and `oathTOTPToken` whose `oathTOTPToken` names the entry itself, with the + secret in `oathSecret`, which the user manager account may set and no one can read, and + `oathTOTPParams` naming `cn=totp,ou=policies` (6 digits, HMAC-SHA1, 30 second steps, one step + of tolerance). The module's overlay is not configured, so binds take the password alone; - slapd listens on its local socket only (`SLAPD_SERVICES` in `/etc/default/slapd`). The directory is kept on purge. @@ -340,8 +345,9 @@ The directory is kept on purge. `vpn-router-user` manages the users. A user is the directory entry, with a first name, a last name and an e-mail address (`givenName`, `sn` and `mail`; `cn` is the first and the last name), and a client certificate with the user name as its common name, in `/etc/vpn-router/pki/users`. -Passwords are read from the first line of standard input. The answer is text: a table of the -users, the client profile or a short message, and a failure is a message on standard error. +Passwords and TOTP secrets are read from the first line of standard input. The answer is text: a +table of the users, the client profile, a TOTP secret or a short message, and a failure is a +message on standard error. `--json`, before the command, gives the answer as JSON on standard output, and a failure as `{"error": ...}` there too; the web service uses it. `-h` or `--help` shows the commands, and after a command its options. @@ -356,21 +362,29 @@ echo 'n3w-pass' | vpn-router-user passwd alice vpn-router-user disable alice vpn-router-user enable alice vpn-router-user remove alice +vpn-router-user otp-new alice +echo 'JBSWY3DPEHPK3PXPJBSWY3DPEHPK3PXP' | vpn-router-user otp-enrol alice 123456 +vpn-router-user otp-remove alice ``` | Command | Effect | | --- | --- | -| `list` | The users with their names and e-mail addresses, whether each is enabled and has a certificate | +| `list` | The users with their names and e-mail addresses, whether each is enabled, has a certificate and is enrolled for TOTP | | `add --first-name F --last-name L --email E` | Adds the user and issues the certificate; all three are required | | `update [--first-name F] [--last-name L] [--email E]` | Changes the names or the e-mail address | | `passwd ` | Sets the password | | `disable `, `enable ` | Locks the user out of OpenVPN, and lets the user in again | | `remove ` | Takes the user out of its groups, revokes the certificate, renews the CRL and removes the user; refused while the user is the last member of a group | | `profile ` | The OpenVPN client profile, with the CA, the certificate and the key inline | +| `otp-new ` | A new TOTP secret and its `otpauth://` URI, issued by `wan.local_fqdn`, for an authenticator app; nothing is stored | +| `otp-enrol ` | Stores the secret read from standard input when the code is a current code of it, replacing a secret the user has | +| `otp-remove ` | Removes the user's TOTP secret | A user name is 1 to 32 lowercase letters, digits and hyphens, starting with a letter; a password has 8 to 128 characters; a first or last name has 1 to 64 printable characters; an -e-mail address has one `@`, no spaces and a dot in its domain. In the answers a user added +e-mail address has one `@`, no spaces and a dot in its domain; a TOTP secret is 32 base32 +characters, as `otp-new` makes it, and a code 6 digits. Enrolment does not change how users log +in. In the answers a user added without the names or the e-mail address has them as `null`. The exit status is 0 on success, 2 for an invalid request, 3 when the user does not exist, 4 when the user exists and 1 for any other failure. @@ -521,7 +535,7 @@ and answer bodies are JSON; a request body is at most 4096 bytes. | Request | Body | Answer | | --- | --- | --- | -| `GET /users` | | `{"users": [{"name", "enabled", "certificate", "first_name", "last_name", "email"}, ...]}` | +| `GET /users` | | `{"users": [{"name", "enabled", "certificate", "otp", "first_name", "last_name", "email"}, ...]}` | | `POST /users` | `{"name", "password", "first_name", "last_name", "email"}`, all required | 201 and the user | | `PUT /users/` | any of `{"first_name", "last_name", "email"}` | the user | | `PUT /users//password` | `{"password": ...}` | the user | @@ -529,11 +543,14 @@ and answer bodies are JSON; a request body is at most 4096 bytes. | `POST /users//enable` | | the user | | `DELETE /users/` | | `{"name", "removed": true}` | | `GET /users//profile` | | `{"name", "profile"}`, the OpenVPN client profile | +| `POST /users//otp` | | `{"name", "secret", "uri"}`, a new TOTP secret; nothing is stored | +| `PUT /users//otp` | `{"secret", "code"}` | the user, once the code matches the secret | +| `DELETE /users//otp` | | the user | A failure is `{"error": ...}` with 400 for an invalid request, 404 for a user that does not exist, 409 for a user that exists and 500 otherwise. `/users` works whether or not OpenVPN or -road-warrior access is enabled; the profile needs OpenVPN. Passwords and private keys cross the internal -network in plain HTTP, like the token. +road-warrior access is enabled; the profile needs OpenVPN. Passwords, private keys and TOTP +secrets cross the internal network in plain HTTP, like the token. ```bash curl -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \ diff --git a/debian-package/debian/changelog b/debian-package/debian/changelog index 563709d..0173bf5 100644 --- a/debian-package/debian/changelog +++ b/debian-package/debian/changelog @@ -1,3 +1,19 @@ +vpn-router (0.6.0-1) unstable; urgency=medium + + feature: users can be enrolled for TOTP one-time passwords. + + * vpn-router-user otp-new makes a TOTP secret and its otpauth URI, + otp-enrol stores the secret, read from standard input, given a code of + it, and otp-remove removes it. list and every answer show whether a user + is enrolled, as otp. + * The web service enrols with POST, PUT and DELETE /users//otp. + * The secret is kept in the user's entry with the OATH-LDAP schema of + slapd's otp module; only the user manager account may set it, and no + one can read it. Logins do not ask for the codes. + * The package depends on python3-pyotp. + + -- Slawomir Koszewski Sat, 24 Oct 2026 23:30:00 +0200 + vpn-router (0.5.9-1) unstable; urgency=medium fix: package upgrades no longer ask the configuration questions. diff --git a/debian-package/debian/control b/debian-package/debian/control index 754da71..7d66314 100644 --- a/debian-package/debian/control +++ b/debian-package/debian/control @@ -21,6 +21,7 @@ Depends: ${misc:Depends}, python3, python3-jinja2, python3-ldap, + python3-pyotp, slapd, openvpn, openvpn-auth-ldap, diff --git a/debian-package/src/usr/lib/vpn-router/vpnrouter_ldap.py b/debian-package/src/usr/lib/vpn-router/vpnrouter_ldap.py index 46f400d..ace6430 100644 --- a/debian-package/src/usr/lib/vpn-router/vpnrouter_ldap.py +++ b/debian-package/src/usr/lib/vpn-router/vpnrouter_ldap.py @@ -4,7 +4,12 @@ The package keeps its own database, dc=vpn-router,dc=local in /var/lib/ldap/vpn- next to the one slapd's installation creates. Root manages it through the local socket with SASL EXTERNAL: the database's root DN is root's peer-credential DN, so there is no administrator password. Users and groups are changed by the user-manager service account, -which the ACLs allow to write ou=users and ou=groups and to set, never read, passwords. +which the ACLs allow to write ou=users and ou=groups and to set, never read, passwords and +TOTP secrets. + +slapd's otp module is loaded for the OATH-LDAP schema; its overlay is not configured on the +database, so binds do not check one-time passwords. cn=totp,ou=policies holds the TOTP +parameters the token of every user points to. """ import os @@ -24,6 +29,7 @@ GROUPS = f'ou=groups,{BASE}' SERVICES = f'ou=services,{BASE}' POLICIES = f'ou=policies,{BASE}' DEFAULT_POLICY = f'cn=default,{POLICIES}' +TOTP_PARAMS = f'cn=totp,{POLICIES}' MANAGER = f'cn=user-manager,{SERVICES}' ROOT = 'gidNumber=0+uidNumber=0,cn=peercred,cn=external,cn=auth' @@ -33,12 +39,19 @@ MANAGER_SECRET = SECRET_DIR / 'user-manager.secret' SLAPD_DEFAULTS = pathlib.Path('/etc/default/slapd') SLAPD_SERVICES = 'SLAPD_SERVICES="ldapi:///"' -MODULES = ('argon2', 'ppolicy') +MODULES = ('argon2', 'ppolicy', 'otp') PASSWORD_HASH = '{ARGON2}' +#: The TOTP parameters: digits, the OID of HMAC-SHA1, seconds per time step, and the time steps accepted before and after now. +TOTP_DIGITS = 6 +TOTP_ALGORITHM = '1.2.840.113549.2.7' +TOTP_PERIOD = 30 +TOTP_WINDOW = 1 + #: The ACLs of the database, in order. ACCESS = ( f'to attrs=userPassword by dn.exact="{MANAGER}" =wx by self =wx by anonymous auth by * none', + f'to attrs=oathSecret by dn.exact="{MANAGER}" =w by * none', f'to dn.subtree="{USERS}" by dn.exact="{MANAGER}" manage by * read', f'to dn.subtree="{GROUPS}" by dn.exact="{MANAGER}" manage by * read', 'to * by * read', @@ -179,6 +192,14 @@ def provision(): 'pwdLockout': [b'TRUE'], 'pwdMaxFailure': [b'0'], }) + _ensure_entry(connection, TOTP_PARAMS, { + 'objectClass': [b'organizationalRole', b'oathTOTPParams'], + 'cn': [b'totp'], + 'oathOTPLength': [str(TOTP_DIGITS).encode()], + 'oathHMACAlgorithm': [TOTP_ALGORITHM.encode()], + 'oathTOTPTimeStepPeriod': [str(TOTP_PERIOD).encode()], + 'oathTOTPTimeStepWindow': [str(TOTP_WINDOW).encode()], + }) _ensure_manager(connection) finally: connection.unbind_s() diff --git a/debian-package/src/usr/lib/vpn-router/vpnrouter_users.py b/debian-package/src/usr/lib/vpn-router/vpnrouter_users.py index 7c6d689..c12fbff 100644 --- a/debian-package/src/usr/lib/vpn-router/vpnrouter_users.py +++ b/debian-package/src/usr/lib/vpn-router/vpnrouter_users.py @@ -10,22 +10,31 @@ optional description and at least one member; a group cannot be empty, so the la cannot leave it and a user who is the last member of a group cannot be removed. Groups only record membership; nothing on the router reads them. +A user may have a TOTP token: the user entry itself is an oathTOTPUser and oathTOTPToken whose +oathTOTPToken points to itself, with the secret in oathSecret and the parameters of +vpnrouter_ldap.TOTP_PARAMS. Enrolment stores nothing until a code of the new secret is +verified: otp_new makes a secret and its otpauth URI, and otp_enrol stores the secret given a +code of it. Nothing on the router checks the codes at login. + Every operation runs in a session(), which holds the lock that serializes changes and the connection of the user manager account. A request that cannot be done raises Failure, whose status says why: 2 the request is invalid, 3 the user does not exist, 4 the user exists, 1 anything else. Answers are dicts with the keys of the JSON answers. """ +import base64 import contextlib import fcntl import pathlib import re import subprocess import sys +import time import ldap import ldap.filter import ldap.modlist +import pyotp import vpnrouter import vpnrouter_ldap @@ -42,7 +51,12 @@ EMAIL_MAX = 254 #: The keys of the person attributes and their LDAP attributes. PERSON = (('first_name', 'givenName'), ('last_name', 'sn'), ('email', 'mail')) #: The attributes read for a user. -ATTRIBUTES = ['uid', 'pwdAccountLockedTime', 'givenName', 'sn', 'mail'] +ATTRIBUTES = ['uid', 'pwdAccountLockedTime', 'givenName', 'sn', 'mail', 'oathTOTPToken'] +#: The readable attributes of a TOTP token; oathSecret is never readable. +TOKEN_ATTRIBUTES = ['oathTOTPToken', 'oathTOTPParams', 'oathSecretTime', 'oathTOTPLastTimeStep', 'oathTOTPTimeStepDrift', + 'oathLastLogin', 'oathLastFailure', 'oathFailureCount'] +#: A TOTP secret as otp_new makes it: 32 base32 characters, 160 bits. +TOTP_SECRET = re.compile(r'^[A-Z2-7]{32}$') #: The attributes read for a group. GROUP_ATTRIBUTES = ['cn', 'description', 'member'] #: The longest group description, in characters. @@ -145,6 +159,7 @@ def summary(attributes): 'name': name, 'enabled': 'pwdAccountLockedTime' not in attributes, 'certificate': certificate(name)[0].is_file(), + 'otp': 'oathTOTPToken' in attributes, } for key, attribute in PERSON: values = attributes.get(attribute) @@ -218,6 +233,49 @@ def enable(connection, name): return summary(entry(connection, name)) +def totp(secret): + return pyotp.TOTP(secret, digits=vpnrouter_ldap.TOTP_DIGITS, interval=vpnrouter_ldap.TOTP_PERIOD) + + +def otp_new(connection, name): + """A new TOTP secret for the user and its otpauth URI, issued by the router's wan.local_fqdn; nothing is stored.""" + entry(connection, name) + secret = pyotp.random_base32() + issuer = vpnrouter.load_config()['wan']['local_fqdn'].strip() + return {'name': name, 'secret': secret, 'uri': totp(secret).provisioning_uri(name=name, issuer_name=issuer or None)} + + +def otp_enrol(connection, name, secret, code): + """Store the secret of otp_new as the user's TOTP token, replacing one the user has, when code is a code of it.""" + current = entry(connection, name) + if not isinstance(secret, str) or not TOTP_SECRET.match(secret): + raise Failure('the secret must be 32 base32 characters, as a new secret has them', 2) + if not isinstance(code, str) or not re.fullmatch(rf'\d{{{vpnrouter_ldap.TOTP_DIGITS}}}', code): + raise Failure(f'the code must be {vpnrouter_ldap.TOTP_DIGITS} digits', 2) + if not totp(secret).verify(code, valid_window=vpnrouter_ldap.TOTP_WINDOW): + raise Failure('the code does not match the secret; check the clock of the authenticator', 2) + changes = [] if 'oathTOTPToken' in current else [(ldap.MOD_ADD, 'objectClass', [b'oathTOTPUser', b'oathTOTPToken'])] + changes += [ + (ldap.MOD_REPLACE, 'oathTOTPToken', [dn_of(name).encode()]), + (ldap.MOD_REPLACE, 'oathTOTPParams', [vpnrouter_ldap.TOTP_PARAMS.encode()]), + (ldap.MOD_REPLACE, 'oathSecret', [base64.b32decode(secret)]), + (ldap.MOD_REPLACE, 'oathSecretTime', [time.strftime('%Y%m%d%H%M%SZ', time.gmtime()).encode()]), + ] + connection.modify_s(dn_of(name), changes) + return summary(entry(connection, name)) + + +def otp_remove(connection, name): + """Remove the user's TOTP token, when the user has one.""" + if 'oathTOTPToken' in entry(connection, name): + token = connection.search_s(dn_of(name), ldap.SCOPE_BASE, attrlist=TOKEN_ATTRIBUTES)[0][1] + changes = [(ldap.MOD_DELETE, 'objectClass', [b'oathTOTPUser', b'oathTOTPToken']), + (ldap.MOD_DELETE, 'oathSecret', None)] + changes += [(ldap.MOD_DELETE, attribute, None) for attribute in token] + connection.modify_s(dn_of(name), changes) + return summary(entry(connection, name)) + + def remove(connection, name): """Remove the user from its groups and the directory, and revoke the certificate. diff --git a/debian-package/src/usr/lib/vpn-router/web b/debian-package/src/usr/lib/vpn-router/web index d18bff5..bd68264 100644 --- a/debian-package/src/usr/lib/vpn-router/web +++ b/debian-package/src/usr/lib/vpn-router/web @@ -11,8 +11,8 @@ Pages: /config the configuration without its secrets, and what the router resolved and runs, as JSON /users the users in the router's directory, whether or not a P2S system is enabled: list, - add, change, set the password, disable, enable, remove, and the OpenVPN client - profile (see users()) + add, change, set the password, disable, enable, remove, the OpenVPN client + profile, and TOTP enrolment (see users()) /groups the groups of the router's directory: list, add, rename or describe, remove, and add and take out members (see groups()) @@ -422,6 +422,9 @@ def users(state, method, path, body): POST /users//enable let the user in again DELETE /users/ remove the user and revoke the certificate GET /users//profile the OpenVPN client profile + POST /users//otp a new TOTP secret and its otpauth URI; nothing is stored + PUT /users//otp store a TOTP secret: {"secret", "code"}, code a code of the secret + DELETE /users//otp remove the TOTP token """ if body == 'invalid' or (body is not None and not isinstance(body, dict)): return 400, {'error': 'the body must be a JSON object'} @@ -438,6 +441,9 @@ def users(state, method, path, body): ('POST', 3, 'enable'): lambda connection: vpnrouter_users.enable(connection, name), ('DELETE', 2, None): lambda connection: vpnrouter_users.remove(connection, name), ('GET', 3, 'profile'): lambda connection: vpnrouter_users.profile(connection, name), + ('POST', 3, 'otp'): lambda connection: vpnrouter_users.otp_new(connection, name), + ('PUT', 3, 'otp'): lambda connection: vpnrouter_users.otp_enrol(connection, name, body.get('secret'), body.get('code')), + ('DELETE', 3, 'otp'): lambda connection: vpnrouter_users.otp_remove(connection, name), } if route not in actions: return 404, {'error': f'no route {method} {path}'} diff --git a/debian-package/src/usr/sbin/vpn-router-user b/debian-package/src/usr/sbin/vpn-router-user index 396a19b..4bf620e 100755 --- a/debian-package/src/usr/sbin/vpn-router-user +++ b/debian-package/src/usr/sbin/vpn-router-user @@ -1,7 +1,8 @@ #!/usr/bin/python3 """Manage the users of the router's directory from the command line, with vpnrouter_users. -Passwords are read from the first line of standard input, never from the command line. The +Passwords and TOTP secrets are read from the first line of standard input, never from the +command line. The answer is text for people on standard output, and a failure is a message on standard error. With --json the answer is JSON on standard output, and a failure is {"error": ...} there too. The exit status says what failed: 2 the request is invalid, 3 the user does not exist, 4 the @@ -15,6 +16,9 @@ user exists, 1 anything else. -h or --help shows the commands and their options. vpn-router-user [--json] enable vpn-router-user [--json] remove vpn-router-user [--json] profile + vpn-router-user [--json] otp-new + vpn-router-user [--json] otp-enrol (secret on stdin) + vpn-router-user [--json] otp-remove """ import argparse @@ -38,6 +42,9 @@ HELP = { 'enable': 'let the user in again', 'remove': 'revoke the certificate and remove the user', 'profile': 'print the OpenVPN client profile', + 'otp-new': 'make a TOTP secret and its otpauth URI; nothing is stored', + 'otp-enrol': 'store the TOTP secret read from standard input, when the code is a code of it', + 'otp-remove': 'remove the TOTP token', } #: The help of the person options. @@ -63,16 +70,18 @@ def parse(argv): for key, _ in vpnrouter_users.PERSON: sub.add_argument(f'--{key.replace("_", "-")}', dest=key, metavar='TEXT', required=command == 'add', help=PERSON_HELP[key]) + if command == 'otp-enrol': + sub.add_argument('code', help='a code of the secret from the authenticator') return parser.parse_args(argv) def password(): - """The password from the first line of standard input.""" + """The password or the TOTP secret from the first line of standard input.""" return sys.stdin.readline().rstrip('\n') def run(connection, options, secret): - """The answer of the command the options name; secret is the password of add and passwd.""" + """The answer of the command the options name; secret is the password of add and passwd, the TOTP secret of otp-enrol.""" command = options.command if command == 'list': return vpnrouter_users.list_users(connection) @@ -83,21 +92,26 @@ def run(connection, options, secret): return vpnrouter_users.update(connection, options.name, values) if command == 'passwd': return vpnrouter_users.passwd(connection, options.name, secret) - return getattr(vpnrouter_users, command)(connection, options.name) + if command == 'otp-enrol': + return vpnrouter_users.otp_enrol(connection, options.name, secret, options.code) + return getattr(vpnrouter_users, command.replace('-', '_'))(connection, options.name) def text_of(answer): """The answer as text for people.""" if 'profile' in answer: return answer['profile'].rstrip('\n') + if 'secret' in answer: + return f'secret {answer["secret"]}\nuri {answer["uri"]}' if answer.get('removed'): return f'user {answer["name"]} removed' users = answer['users'] if 'users' in answer else [answer] if not users: return 'no users' - head = ('USER', 'STATE', 'CERTIFICATE', 'FIRST NAME', 'LAST NAME', 'E-MAIL') + head = ('USER', 'STATE', 'CERTIFICATE', 'OTP', 'FIRST NAME', 'LAST NAME', 'E-MAIL') rows = [(user['name'], 'enabled' if user['enabled'] else 'disabled', 'issued' if user['certificate'] else 'missing', - user['first_name'] or '-', user['last_name'] or '-', user['email'] or '-') for user in users] + 'enrolled' if user['otp'] else '-', user['first_name'] or '-', user['last_name'] or '-', user['email'] or '-') + for user in users] widths = [max(len(row[column]) for row in (head, *rows)) for column in range(len(head))] return '\n'.join(' '.join(cell.ljust(width) for cell, width in zip(row, widths)).rstrip() for row in (head, *rows)) @@ -110,7 +124,7 @@ def main(argv): as_json = options.json if options.command != 'list': vpnrouter_users.check_name(options.name) - secret = password() if options.command in ('add', 'passwd') else None + secret = password() if options.command in ('add', 'passwd', 'otp-enrol') else None with vpnrouter_users.session() as connection: answer = run(connection, options, secret) except Failure as failure: