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 exampleecdsa-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.
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:
Argv: in the
subprocess: AuthorizedKeysCommand command "..."debug line, confirm you see%u,%t, and%k(and preferably%f). Hash-only ldap needs%tand%kso the script can emit the offered key line when the hash matches.Reproduce as the command user: run the same path with the same arguments as in the log, under
sudo -u nobody(or whateverAuthorizedKeysCommandUseris). Pipe stdout throughwc -land inspect the lines. There must be at least oneauthorized_keys-shaped line that matches the key the client offered.Ldap as the bind DN: run
ldapsearchwith the same-H,-D, password file,-b, filteruid=…, and requested attributes as the script. IfsshPublicKeyHashorsshPublicKeynever appears for the bind you use in production, the script cannot print it either (ACL or wrong filter/base).Traces: create
/etc/ssh/get_ssh_keys_from_ldap.debugreadable by the command user, retry one login, thenjournalctl -t get_ssh_keys_from_ldapfor 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:
Serialises the parsed public key to its default wire blob with
to_blob()withforce_plainset (the same internal blob used for fingerprints, not the literal base64 text fromauthorized_keys).Computes the digest with
ssh_digest_memory()for the chosen algorithm (SHA256when you pass-E sha256tossh-keygen -l).For the usual base64 display form,
fingerprint_b64()prefixes the digest algorithm name and a colon (SHA256:), base64-encodes the raw digest bytes withb64_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):
Query ldap for the account (for example by
uid) and requestsshPublicKeyandsshPublicKeyHash.For every legacy
sshPublicKeyvalue, print the full public key line on standard output exactly as stored (after stripping thesshPublicKey:prefix), except omit a line when it is the same key as the offered one and a matchingsshPublicKeyHashis already present, so stdout does not list that key twice. Openssh then compares the client-offered key to those lines.When you also pass
%tand%k(or otherwise know the offered single-line key), compute itsSHA256:fingerprint withssh-keygen -l -E sha256(stdin-f -is enough on current openssh). If anysshPublicKeyHashvalue 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 soldapsearch, per-host socket probes, andssh-keygencannot blockAuthorizedKeysCommandindefinitely (defaults 15s / 3s / 5s; override withLDAPSEARCH_TIMEOUT,SOCKET_PROBE_TIMEOUT,SSHKEYGEN_TIMEOUT).Parameterised ldap bind credentials readable only by the command user (for example a root-only file, or
sssd/nslcdintegration if you already centralise reads).tls to the directory (
ldaps://orstarttls) 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.
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 inauthorized_keysstyle.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_keysfile 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, andldapPublicKeyfor 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
AuthorizedKeysCommandhelper 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).