Quantum-resistant LDAP secure store of public OpenSSH keys ========================================================== .. toctree:: :maxdepth: 1 :caption: Contents: .. contents:: Table of Contents :depth: 3 About ----- This document describes a practical realisation of the pattern on the Discoverer Petascale Supercomputer: it is implemented on the login nodes, where OpenSSH uses an ``AuthorizedKeysCommand`` helper and the site ldap directory to decide which public keys may authenticate each account. The schema fragment, example script, and operational notes here are written for that environment and may be reused elsewhere only after you adapt hosts, bind credentials, and directory layout to your own site. The GitLab repository containing the schema and scripts that are mentioned in the text is located here: https://gitlab.discoverer.bg/vkolev/quantum-resistant-openssh-keystore-in-ldap Purpose ------- This note describes a deployment pattern in which an ldap directory can store a sha256 fingerprint per key in ``sshPublicKeyHash`` (while retaining legacy full lines in ``sshPublicKey`` during migration), and an openssh server uses ``AuthorizedKeysCommand`` to authorise a login by recomputing that fingerprint from the key offered during authentication and comparing it to ldap, with fallback to legacy key material. The goal is to reduce what a leaked ldif export discloses: a backup or mis-sent dump no longer contains complete public key blobs that can be ingested offline into inventories, correlated trivially with captured artefacts, or retained for long-term cryptanalytic study against the same mathematical object as in the live protocol. Compared with storing a full ``authorized_keys`` line, storing only the ``SHA256:`` fingerprint token also omits the key algorithm string (for example ``ecdsa-sha2-nistp384``, ``sk-ssh-ed25519@openssh.com``) and the length field that ``ssh-keygen -l`` prints before the fingerprint, so a directory-only reader does not learn those parameters from ldap alone. This is a containment measure for directory confidentiality. It does not replace sound access control on ldap, tls to the directory, patching, or migration away from algorithms that may be weakened in the future. Background on public keys and private keys ------------------------------------------ For the asymmetric schemes used in openssh user public keys (for example ``ecdsa-sha2-nistp384``, ``ssh-ed25519``, ``rsa-sha2-*``), the private key is a separate secret value. The public key is derived from it. Under widely accepted classical assumptions, recovering the private key from the public key alone is infeasible for correctly chosen parameters and implementations. The phrase “harvest now, decrypt later” more commonly describes recording ciphertext or protocol transcripts today and attempting decryption when a future capability exists. That framing applies weakly to “guess the private key from the public key” in a classical setting: the bottleneck is mathematical, not storage of the public object. Where this design still helps is operational: ldap is a high-value aggregation point; minimising stored key material lowers impact of a directory-only compromise and shrinks incidental duplication of key blobs in backups and tickets. Fingerprints are still identifying within the fingerprint domain: anyone who later learns the same public key can compute the same sha256 fingerprint and confirm it matches ldap. The win is narrower: the ldif alone does not carry the full key. What the directory stores ------------------------- Use the same visual form produced for a line in ``authorized_keys`` when fingerprinting: .. code:: text ssh-keygen -l -E sha256 -f path/to/authorized_keys_or_single_line_file Example line of output: .. code:: text 384 SHA256:U5pLg+umPahYzW1kScqfAu/5ptxVZeyIaosPVccRGVA your_key_comment (ECDSA) The helper treats two ``sshPublicKeyHash`` values as the same fingerprint if they match after trimming and ASCII case folding, so provisioning that changes only letter case in the base64 tail still matches ``ssh-keygen`` and the ``%f`` token from ``sshd``. Store only the fingerprint token in ldap (no comment, no bit length): .. code:: text SHA256:U5pLg+umPahYzW1kScqfAu/5ptxVZeyIaosPVccRGVA That form is a fixed-width hash label plus base64; it does not encode the public-key algorithm, elliptic curve name, security-key (``sk-``) variant, or rsa modulus width, all of which are visible in a full public key line or in typical tooling output when the full key is present. Directory footprint and algorithm outlook ----------------------------------------- Holding fingerprints in ``sshPublicKeyHash`` instead of full public key lines reduces stored octets per key on each entry. That lowers total directory size for a given population, along with secondary effects such as replication volume and ldif export bulk, because each value is a short string of roughly fixed length, whereas typical ``sshPublicKey`` blobs are much longer (wide rsa moduli and long base64 blobs dominate). The same arrangement extends to future user public key types, including post-quantum or hybrid algorithms, once they are standardised for ssh, implemented in openssh, and accepted by your provisioning pipeline. At login the client still transmits the full public key; the directory only stores a fingerprint of agreed form, so growth in encoded public key length does not translate linearly into growth in ldap-held material across suppliers and backups. Operators should treat fingerprint policy (hash algorithm and exact string normalisation) as a configuration contract and revisit it if a new key type requires a different fingerprinting option in tooling. Directory schema (389 directory server) --------------------------------------- The repository ships one 389-style schema file: ``schema/55openssh-key-schema.ldif``. It defines ``sshPublicKey`` (``...1.1.13``), ``sshPublicKeyHash`` (``...1.1.14``), and auxiliary ``ldapPublicKey`` (``...2.0``) with ``MUST uid`` and ``MAY ( sshPublicKey $ sshPublicKeyHash )``. Each ``sshPublicKeyHash`` value holds only the ``SHA256:`` fingerprint token (multi-valued, one value per key), not a full ``authorized_keys`` line. Equality and syntax follow the same octet string pattern as ``sshPublicKey``. If a supplier already holds the older ``ldapPublicKey`` without ``sshPublicKeyHash`` in its ``MAY`` list, you cannot add a second definition with the same object class OID; use 389’s supported procedure to replace that definition, or build new replicas from this file and replicate. The verifier should request both ``sshPublicKeyHash`` and ``sshPublicKey``. Preferred path: compute the offered key’s ``SHA256:`` fingerprint and test membership in any ``sshPublicKeyHash`` value. Legacy path: if no hash matches (or the entry has no ``sshPublicKeyHash`` values), compute the same fingerprint for each ``sshPublicKey`` line and compare to the offered key’s fingerprint (so ldap lines with key options or comments still match without byte-for-byte equality to the offered ``"%t %k"`` string). That preserves service for accounts that have not been migrated off full blobs in ``sshPublicKey``. ``ldapsearch`` may return ``sshPublicKey:`` or ``sshPublicKeyHash:`` with a single colon and a cleartext value, or ``sshPublicKey;binary::`` / ``sshPublicKeyHash;binary::`` (or other ``;options``) where the payload after ``::`` is base64 or, with some tooling, the same cleartext string stored without base64. The helper matches those attribute names only (the same idea as ``grep '^sshPublicKey:'`` on cleartext lines, plus ``::`` decoding). If your server prints only numeric OIDs in LDIF, configure it to return names for these attributes or use a different extractor. For ``::`` it uses the output of ``base64 -d`` when that is non-empty, otherwise the trimmed literal after ``::``. Keep hashing policy aligned end to end: if operators ever change fingerprint algorithm or normalisation, both provisioning into ldap and the server script must use the same rule. What happens at ssh login (high level) -------------------------------------- After version negotiation and key exchange, the transport is protected by session keys derived in the kex. User authentication then runs inside that protected channel. For ``publickey`` authentication, the client presents the user public key (algorithm and key blob) to the server as part of the userauth exchange, then proves possession with a signature. The server must decide whether that key is authorised for the target account. With ``AuthorizedKeysCommand``, ``sshd`` invokes an external program instead of (or in addition to, depending on configuration) reading ``~/.ssh/authorized_keys``. The program must print zero or more ``authorized_keys`` lines on standard output. ``sshd`` treats the authentication as successful if the offered key matches a printed line according to the usual rules. Therefore, after your script validates the offered key against ldap, it should emit exactly one line in ``authorized_keys`` format for that offered key (typically reconstructing ``"%t %k"`` with a stable comment or none), so ``sshd`` can perform the match. Storing only the fingerprint in ldap does not remove the need to output a full key line at runtime: the client already supplied the key material; the script re-exports it after policy checks. ``sshd`` configuration ---------------------- Example directives: .. code:: text AuthorizedKeysCommand /usr/local/bin/get_ssh_keys_from_ldap.sh %u %t %k %f AuthorizedKeysCommandUser nobody Including ``%f`` is recommended: the helper uses that fingerprint first when matching ``sshPublicKeyHash`` in ldap (the same form ``sshd`` logs in ``userauth_pubkey: test pkalg``), which avoids relying on ``ssh-keygen -l`` alone on the host. If no arguments are given, openssh passes only the target username by default. A configuration line that names only the script path and ``AuthorizedKeysCommandUser`` therefore does not, by itself, pass the offered key material into argv; extend the directive with at least ``%t`` and ``%k`` (and ``%u`` if you prefer argv over relying on the default username argument) so the helper can reconstruct the offered key line and fingerprint it deterministically. Token meanings (from ``sshd_config(5)`` on your installed version; confirm locally): - ``%u`` - username for the account being authenticated. - ``%t`` - key type string (for example ``ecdsa-sha2-nistp384``). - ``%k`` - base64-encoded key blob for authentication. ``AuthorizedKeysCommand`` also accepts ``%f`` (fingerprint of the key), ``%h`` (home directory), ``%U`` (numeric uid), among others. Some sites compare ``%f`` directly to ldap after normalising representation. If you use that shortcut, verify on your openssh build that ``%f`` matches exactly what you store (prefix, base64 alphabet, url-safe variants). Otherwise, compute the fingerprint with ``ssh-keygen -l -E sha256`` on a single-line public key; modern ``ssh-keygen`` accepts ``-f -`` and reads that line from standard input, so the helper need not write a key file to disk. Permissions: the helper must be root-owned, not group- or world-writable, and executable only as intended. ``AuthorizedKeysCommandUser`` should be a least-privilege account; ``nobody`` is acceptable where local policy allows, though a dedicated service user with no login shell is often clearer for auditing. Troubleshooting: ``AuthorizedKeysCommand ... failed, status 1`` with public key auth ------------------------------------------------------------------------------------ If ``sshd`` logs a line similar to ``subprocess: AuthorizedKeysCommand command "/usr/local/bin/get_ssh_keys_from_ldap.sh vkolev"`` with only one argument after the script path, the server is not passing ``%t`` and ``%k``. Any account whose ldap entry relies on ``sshPublicKeyHash`` without a parallel full ``sshPublicKey`` line will then print nothing on stdout, so the offered key cannot match; authentication fails until ``sshd_config`` passes ``%t`` and ``%k`` (and optionally ``%f``). Fix: set ``AuthorizedKeysCommand`` to pass at least ``%u %t %k``, and preferably ``%f``, for example: .. code:: text AuthorizedKeysCommand /usr/local/bin/get_ssh_keys_from_ldap.sh %u %t %k %f AuthorizedKeysCommandUser nobody Reload ``sshd`` after editing ``sshd_config``. With ``%t`` and ``%k``, the helper can reconstruct the offered key line for stdout; with ``%f`` as well, ldap hash comparison uses the same fingerprint string ``sshd`` already computed. Accounts that still have only ``sshPublicKey`` in ldap continue to work with the same directive. When the helper exits quickly but login still fails --------------------------------------------------- The helper usually exits in a fraction of a second. That is normal. ``sshd`` then checks whether the client key matches any line the helper printed on stdout. If stdout is empty, or only contains other keys, authentication fails even though the process exited successfully (the reference script exits 0 after a completed ldap read even when it prints nothing, so you will not see ``AuthorizedKeysCommand ... failed`` for that case). Work in this order: 1. Argv: in the ``subprocess: AuthorizedKeysCommand command "..."`` debug line, confirm you see ``%u``, ``%t``, and ``%k`` (and preferably ``%f``). Hash-only ldap needs ``%t`` and ``%k`` so the script can emit the offered key line when the hash matches. 2. Reproduce as the command user: run the same path with the same arguments as in the log, under ``sudo -u nobody`` (or whatever ``AuthorizedKeysCommandUser`` is). Pipe stdout through ``wc -l`` and inspect the lines. There must be at least one ``authorized_keys``-shaped line that matches the key the client offered. 3. Ldap as the bind DN: run ``ldapsearch`` with the same ``-H``, ``-D``, password file, ``-b``, filter ``uid=…``, and requested attributes as the script. If ``sshPublicKeyHash`` or ``sshPublicKey`` never appears for the bind you use in production, the script cannot print it either (ACL or wrong filter/base). 4. Traces: create ``/etc/ssh/get_ssh_keys_from_ldap.debug`` readable by the command user, retry one login, then ``journalctl -t get_ssh_keys_from_ldap`` for that attempt. Remove the file when finished. Timeouts in the script only cap runaway network or ``ssh-keygen`` waits; they do not explain a fast exit with empty stdout. Long ldap attribute values are often folded per RFC 2849 (the next physical line begins with one space and continues the value). The helper always merges those continuation lines after ``ldapsearch`` before parsing ``sshPublicKeyHash`` or ``sshPublicKey``, so a fingerprint split across lines is still read as one string. Bash ``[[`` globs versus ``grep '^attr:'``: ``grep '^sshPublicKeyHash:'`` only fixes the start of the line. A pattern such as ``[[ "${line,,}" == sshpublickeyhash*: ]]`` (literal colon only at the end of the glob) forces the entire line to end with a colon, so cleartext values like ``sshPublicKeyHash: SHA256:…`` never match because the fingerprint token contains another colon. Use a prefix form (``sshpublickeyhash:*``) and then take the value with ``${line#*:}`` for single-colon LDIF, or split on the first ``::`` when the directory returns the base64 form. The same idea applies when matching ``sshPublicKey`` lines: ``sshpublickey*`` must not subsume ``sshPublicKeyHash``, so exclude the hash name explicitly. How openssh builds the sha256 fingerprint ----------------------------------------- In openssh portable, ``sshkey_fingerprint()`` (``sshkey.c``) calls ``sshkey_fingerprint_raw()``, which: 1. Serialises the parsed public key to its default wire blob with ``to_blob()`` with ``force_plain`` set (the same internal blob used for fingerprints, not the literal base64 text from ``authorized_keys``). 2. Computes the digest with ``ssh_digest_memory()`` for the chosen algorithm (``SHA256`` when you pass ``-E sha256`` to ``ssh-keygen -l``). 3. For the usual base64 display form, ``fingerprint_b64()`` prefixes the digest algorithm name and a colon (``SHA256:``), base64-encodes the raw digest bytes with ``b64_ntop()``, then strips ``=`` padding from the end. Reproducing that value without calling ``ssh-keygen`` or openssh libraries means implementing the same ``to_blob()`` serialisation for every key type you allow (including future types), which is why helpers normally delegate to ``ssh-keygen`` or link against openssh’s own code. Using ``%f`` from ``sshd`` avoids recomputation in the helper entirely, at the cost of proving string equality with whatever form your openssh build prints for ``%f`` versus what you store in ldap. Responsibilities of ``/usr/local/bin/get_ssh_keys_from_ldap.sh`` ---------------------------------------------------------------- Suggested logic (aligned with the usual ``AuthorizedKeysCommand`` pattern where only ``%u`` was passed): 1. Query ldap for the account (for example by ``uid``) and request ``sshPublicKey`` and ``sshPublicKeyHash``. 2. For every legacy ``sshPublicKey`` value, print the full public key line on standard output exactly as stored (after stripping the ``sshPublicKey:`` prefix), except omit a line when it is the same key as the offered one and a matching ``sshPublicKeyHash`` is already present, so stdout does not list that key twice. Openssh then compares the client-offered key to those lines. 3. When you also pass ``%t`` and ``%k`` (or otherwise know the offered single-line key), compute its ``SHA256:`` fingerprint with ``ssh-keygen -l -E sha256`` (stdin ``-f -`` is enough on current openssh). If any ``sshPublicKeyHash`` value equals that fingerprint, print the offered ``"%t %k"`` line once so authorisation succeeds for hash-only rows and for hash-plus-legacy without duplicating the same material on stdout. The reference script in ``scripts/get_ssh_keys_from_ldap.sh`` follows that model: it supports ``AuthorizedKeysCommand ... %u`` alone (hash logic skipped) or ``... %u %t %k`` (hash logic enabled). Standard output is only ``authorized_keys`` lines. Diagnostics go to stderr and to syslog with ``logger -t get_ssh_keys_from_ldap`` (facility ``auth``) when either ``SSH_KEYS_FROM_LDAP_DEBUG=1`` is set in the environment of the helper process, or the empty file ``/etc/ssh/get_ssh_keys_from_ldap.debug`` exists and is readable by ``AuthorizedKeysCommandUser`` (for example ``touch`` that file and ``chmod a+r`` it; remove the file to disable). Under systemd, follow ``journalctl -t get_ssh_keys_from_ldap -f`` because stderr from the helper may not appear in ``/var/log/secure``. Operational requirements: - ldap connectivity, timeouts, and error handling (fail closed). The reference script uses ``timeout(1)`` when present so ``ldapsearch``, per-host socket probes, and ``ssh-keygen`` cannot block ``AuthorizedKeysCommand`` indefinitely (defaults 15s / 3s / 5s; override with ``LDAPSEARCH_TIMEOUT``, ``SOCKET_PROBE_TIMEOUT``, ``SSHKEYGEN_TIMEOUT``). - Parameterised ldap bind credentials readable only by the command user (for example a root-only file, or ``sssd``/``nslcd`` integration if you already centralise reads). - tls to the directory (``ldaps://`` or ``starttls``) so fingerprints are not observable on the ldap wire in the clear. - logging without printing full public keys if policy forbids it; at least log username, result, and correlation id. ``AuthorizedKeysCommand``: exit status versus stdout ---------------------------------------------------- 1. What actually authorises the key. ``sshd`` reads standard output from the helper as zero or more ``authorized_keys`` lines (``sshd_config(5)``). For each authentication attempt it checks whether the client-offered public key matches one of those lines (same rules as for a static ``authorized_keys`` file). A match is what allows public-key authentication to proceed, together with a valid signature. Exit status alone does not replace that line-by-line match. 2. What non-zero exit means. If the helper process exits with a non-zero status, ``sshd`` treats the ``AuthorizedKeysCommand`` as failed (you will normally see a log line such as ``AuthorizedKeysCommand ... failed, status N``). Do not rely on stdout from that invocation. Exit zero means the command finished without that class of failure; ``sshd`` still only accepts a key if stdout contained a matching line. 3. Empty stdout must still exit zero after a successful lookup. If the helper exits non-zero whenever it prints no lines, ``sshd`` logs a command failure. That is appropriate for ldap outages or a broken script, but not for a normal outcome such as “this agent key is not in ldap” when the client will try another key next. The reference script therefore exits 1 only when it cannot complete the lookup (missing username, no reachable ldap host, ``ldapsearch`` failure). After ldap data was read, it exits 0 even when stdout is empty, so multiple keys in ``ssh-add`` are tried in order until one matches or the client gives up. For manual checks, inspect stdout (for example ``| wc -l``) or set ``SSH_KEYS_FROM_LDAP_DEBUG=1``, not the exit code, to see whether any lines were returned for a given ``%t``/``%k`` invocation. 4. Preferring one key on the client. To avoid extra failed attempts when many keys are loaded, use ``IdentitiesOnly yes`` with ``IdentityFile`` (or ``ssh -i``) for the key that is registered in ldap, or remove unused keys from the agent. Provisioning notes ------------------ The directory can be mixed during rollout: some accounts keep only ``sshPublicKey`` (full lines), others add ``sshPublicKeyHash`` only, others carry both until cut-over. The helper always requests both attributes from ldap; accounts without ``sshPublicKeyHash`` still work through the legacy path alone. Accounts with hash only need ``AuthorizedKeysCommand`` to pass ``%t`` and ``%k`` (and optionally ``%f``) so the offered key line can be emitted when the stored fingerprint matches. When a user rotates a key, update ldap before relying on the new key, or retain both old and new fingerprints briefly. Automate fingerprint extraction in your account lifecycle tooling so humans never paste full keys into ldap if policy forbids it. During migration you may write ``sshPublicKeyHash`` first, then remove the corresponding ``sshPublicKey`` line once clients have moved; the verifier continues to accept either source until both are aligned with policy. Security key (``sk-``) public keys ---------------------------------- Yes. Openssh security-key types (``sk-ecdsa-sha2-nistp256@openssh.com``, ``sk-ssh-ed25519@openssh.com``, and the corresponding ``-cert-v01@openssh.com`` certificate forms where you use user certificates) still publish a single-line public key in ``authorized_keys`` form. ``ssh-keygen -l -E sha256`` fingerprints that line the same way as for non-``sk`` keys, and ``sshd`` still supplies ``%t``, ``%k``, and optionally ``%f`` during ``AuthorizedKeysCommand`` invocation for the offered key. Provisioning must hash the exact ``.pub`` line (including any key options such as ``no-touch-required``, application string choices, or ``verify-required`` that change the encoded public blob) that will be presented at login. Different generation options yield different public blobs and therefore different fingerprints. Hardware or platform authenticator interaction remains required at authentication time where the key type demands it; storing a fingerprint in ldap does not remove that requirement. If you rely on plain ``sk-*`` public keys, the flow in this note applies directly. If you use ``sk-*`` user certificates instead, the same certificate-oriented caveats as for non-``sk`` keys apply (``AuthorizedPrincipals*``, ``TrustedUserCAKeys``, principal matching). Limits of the approach ---------------------- - Anyone who obtains the same public key by other means (another backup, helpdesk paste, endpoint telemetry) can compute the fingerprint and match it to a leaked ldap fingerprint. - The verifier must not introduce command injection via unsanitised ldap fields or shell interpolation around ``%k``. - Certificate-based user authentication (``ssh-ed25519-cert-v01@openssh.com``, etc.) follows different paths (``AuthorizedPrincipals*``, ``TrustedUserCAKeys``). This note assumes plain public keys in ``authorized_keys`` style. - Future algorithm policy belongs in a separate programme (disable weak algorithms, monitor openssh and vendor advisories). References ---------- - ``sshd_config(5)`` - ``AuthorizedKeysCommand``, ``AuthorizedKeysCommandUser``, token definitions. - ``sshd(8)`` - ``authorized_keys`` file format. - ``ssh-keygen(1)`` - ``-l``, ``-E sha256``. - Repository https://gitlab.discoverer.bg/vkolev/quantum-resistant-openssh-keystore-in-ldap/-/blob/main/schema/55openssh-key-schema.ldif - ``sshPublicKey``, ``sshPublicKeyHash``, and ``ldapPublicKey`` for 389 Directory Server. - Repository https://gitlab.discoverer.bg/vkolev/quantum-resistant-openssh-keystore-in-ldap/-/blob/main/scripts/get_ssh_keys_from_ldap.sh - example ``AuthorizedKeysCommand`` helper with hash and legacy behaviour. - OpenSSH portable https://github.com/openssh/openssh-portable/blob/master/sshkey.c - ``sshkey_fingerprint_raw``, ``sshkey_fingerprint``, ``fingerprint_b64`` (reference source for digest input and display form).