Quantum-resistant LDAP secure store of public OpenSSH keys

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:

ssh-keygen -l -E sha256 -f path/to/authorized_keys_or_single_line_file

Example line of output:

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):

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:

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:

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