README
¶
authnd
An LDAP server for people who are somewhere else — a SQL database, a
configuration file, another directory. Pure Go, CGO_ENABLED=0, one binary.
authnd --config /etc/authnd.d
authnd check /etc/authnd.d
A site's people are already in a database. The things that want to authenticate them speak LDAP, because almost everything speaks LDAP. authnd is the front: it binds and searches over go-authn/directory's sources, so what a database holds can be read by anything that knows how to ask a directory.
The configuration
listen = "0.0.0.0:636"
base_dn = "dc=example,dc=org"
cert_file = "/etc/authnd/cert.pem"
key_file = "/etc/authnd/key.pem"
# The service account that may READ. Binding proves who you are; reading
# everybody is a separate thing to be allowed.
reader "cn=reader,dc=example,dc=org" { password_file = "/etc/authnd/reader.pw" }
# People written down here: a service account, or a small site's whole list.
user "backup" { password_file = "/etc/authnd/backup.pw" }
user "carol" {
nt_hash = "…" # MD4(UTF16LE(password)), 32 hex: a site that holds THAT, not the password
authorized_keys = ["ssh-ed25519 AAAA… carol@laptop"] # published as sshPublicKey; or authorized_keys_file
}
group "operators" { members = ["backup"] }
# And people who are somewhere else.
users "sql" {
driver = "postgres"
dsn_file = "/etc/authnd/dsn"
users = "select login, secret, nt_hash from staff"
groups = "select team, member from team_members"
}
The users block is go-authn/directory/hcldir's, so this server and a file
server (go-fileshare/fileshare)
describe the same directory the same way. The queries are yours: a site's
people are already in that site's shape.
Sources are asked in the order they are written and the first that knows a name owns it, so a service account written down here is not overridden by somebody with the same name in the database. Group membership is the union.
The entries it publishes
Every entry is a standard-schema entry, and every entry published passes
schema.Validate (go-authn/ldap's
package schema) against the schema this server publishes at cn=Subschema.
An attribute a source gave that no class of the entry allows is dropped,
and the entry is published with the rest; the startup log and authnd check
name each dropped attribute, per person, with the classes that would carry it.
An entry that still fails is not published: it is absent from every
search, the startup log names it, and authnd check lists it with each problem
Validate found. Up to v0.10.2 every person was top, person, posixAccount
with none of the attributes those classes require.
| a person with… | objectClass (every superclass listed, RFC 4512 §3.3) |
cn |
|---|---|---|
no sn |
top, account (RFC 4524 §3.1) |
only when another class allows it |
sn |
top, person, organizationalPerson, inetOrgPerson (RFC 4519, RFC 2798) |
yes |
… and uidNumber, gidNumber, homeDirectory, all three |
+ posixAccount (RFC 2307) |
yes |
| … and SSH keys | + ldapPublicKey (openssh-lpk), with sshPublicKey |
|
| … and a published NT hash with a sambaSID | + sambaSamAccount (Samba), with sambaSID and sambaNTPassword |
allowed |
| … always | + uidObject (RFC 4519 §3.14), so (objectClass=uidObject) finds everybody |
| a group with… | objectClass |
members |
|---|---|---|
a gidNumber |
top, posixGroup (RFC 2307) |
memberUid, names |
no gidNumber, somebody in it |
top, groupOfNames (RFC 4519 §3.5) |
member, the DNs this server publishes |
no gidNumber, nobody in it |
top, organizationalRole (RFC 4519 §3.10) |
none |
a gidNumber, nobody in it |
top, posixGroup |
none (no empty memberUid) |
uid is the person's name, and so is cn wherever a class allows it. An
account alone does not allow cn (its MAY is description, seeAlso, l, o,
ou and host), so a person without sn and without the POSIX trio has uid
and no cn. A person's and a group's descriptive attributes
(go-authn/directory's
Identity.Attributes() and Set.GroupAttributes) are published as given,
except one that no class of the entry allows, which is dropped:
| given | the entry | dropped, and the reason check gives |
|---|---|---|
uidNumber, gidNumber, no homeDirectory, no sn |
account, uidObject |
uidNumber, gidNumber: no class of the entry allows it; posixAccount would |
mail, no sn |
account, uidObject |
mail: no class of the entry allows it; inetOrgPerson would |
| a type the schema does not define | the rest | it: the schema served here does not define it |
mail on a group without gidNumber |
groupOfNames |
mail |
two homeDirectory values with the POSIX trio |
withheld: SINGLE-VALUE, nothing to drop | — |
a gidNumber that is not an integer |
withheld: not of its syntax | — |
Only what a source gave is dropped. An attribute authnd derives itself (uid,
cn, objectClass, sshPublicKey, sambaSID, sambaNTPassword) that the
schema refused would be a defect here, and withholds the entry. Which
attributes are not allowed is Validate's answer, and the entry is validated
again after dropping.
$ authnd check authnd.hcl
...
hal has no homeDirectory: not a posixAccount, published as account, uidObject
hal is published without uidNumber: no class of the entry allows it; posixAccount would
hal is published without gidNumber: no class of the entry allows it; posixAccount and posixGroup would
hal is published without mail: no class of the entry allows it; inetOrgPerson would
Why these choices:
- posixGroup follows RFC 2307, not draft-howard-rfc2307bis-02. The draft
makes posixGroup AUXILIARY, and it expired without being published. The schema
served here is RFC 2307's, and an AUXILIARY posixGroup would need a structural
class beside it,
groupOfNames, which needs amemberthat an empty group cannot give. - organizationalRole for an empty group without gidNumber.
groupOfNamesandgroupOfUniqueNamesboth MUST have a member, and inventing one would be false:member: ""names the root DSE, and the group's own DN makes it a member of itself.organizationalRoleis the one standard structural class whose only MUST iscnand whose occupants (roleOccupant) are optional. It takesdescription,ouandseeAlsoasgroupOfNamesdoes. - Auxiliary classes without DIT content rules. RFC 4512 §2.4.3 forbids
auxiliary classes on an entry whose structural class has no DIT content rule.
slapd allows them anyway, and so does
Validate(go-authn/ldap says so). When a site schema has a content rule, see DIT content rules, name forms and structure rules.
The schema is served: the root DSE carries subschemaSubentry: cn=Subschema,
the library answers a base search of cn=Subschema, and filters are evaluated
with the schema's matching rules, so (uidNumber>=1000) compares integers.
user "dora" {
password_file = "/etc/authnd/dora.pw"
authorized_keys = ["ssh-ed25519 AAAA… dora@laptop"]
attributes = {
sn = "Explorer" # inetOrgPerson
uidNumber = "1000" # the three: posixAccount
gidNumber = "1000"
homeDirectory = "/home/dora"
mail = "dora@example.org"
}
}
group "engineers" {
members = ["dora", "eli"]
attributes = { gidNumber = "2000" } # a posixGroup; without it, a groupOfNames
}
users "sql" {
driver = "sqlite"
dsn_file = "/etc/authnd/dsn"
users = "select login, secret, nt_hash, ssh_keys from staff"
groups = "select team, member from teams"
users_attributes = "select login, sn, uid as uidNumber, gid as gidNumber, home as homeDirectory, mail from person_attrs"
groups_attributes = "select team, gid as gidNumber, description from team_attrs"
}
What that SQL source publishes, read by OpenLDAP's ldapsearch (from the pull
request that introduced this):
dn: uid=dora,ou=people,dc=example,dc=org
objectClass: top
objectClass: person
objectClass: organizationalPerson
objectClass: inetOrgPerson
objectClass: posixAccount
objectClass: ldapPublicKey
objectClass: uidObject
uid: dora
cn: dora
sn: Explorer
uidNumber: 1000
gidNumber: 1000
homeDirectory: /home/dora
mail: dora@example.org
sshPublicKey: ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIH9Bl6dNhbHqUCLxLLjTe5MDLbLWyWZ9UBjFA+Vx4rJk dora@laptop
dn: uid=gus,ou=people,dc=example,dc=org
objectClass: top
objectClass: account
objectClass: uidObject
uid: gus
dn: cn=readers,ou=groups,dc=example,dc=org
objectClass: top
objectClass: groupOfNames
cn: readers
description: people who read
member: uid=fay,ou=people,dc=example,dc=org
member: uid=gus,ou=people,dc=example,dc=org
⛔ A consumer that filters on (objectClass=posixAccount) no longer sees a
person without the POSIX trio, and one that filters on
(objectClass=posixGroup) no longer sees a group without a gidNumber. This
includes go-authn/directory's ldapdir up to v0.13.0, and so go-fileshare.
Give people and groups those attributes, or point the consumer at
(objectClass=uidObject). go-authn/directory v0.14.0 widens ldapdir's
defaults to match (go-fileshare reads authnd correctly from the release that
requires it): the user filter to
(|(objectClass=posixAccount)(objectClass=account)(objectClass=uidObject)),
and the group filter to (|(objectClass=posixGroup)(objectClass=groupOfNames)),
with member DNs matched to the people ldapdir imported.
publish_nt_hash needs a samba block
sambaNTPassword is an attribute of Samba's sambaSamAccount, whose MUST is
uid and sambaSID, and no other class allows it. So publishing it needs the
domain a person's SID belongs to:
publish_nt_hash = true
samba {
domain_sid = "S-1-5-21-1004336348-1177238915-682003330" # `net getlocalsid` on the SMB server
algorithmic_rid_base = 1000 # optional; Samba's default
}
A person's sambaSID is their own sambaSID attribute when a source gives
one. Otherwise it is domain_sid-RID, with RID = 2 × uidNumber +
algorithmic_rid_base. That is Samba's algorithmic mapping: smb.conf(5),
algorithmic rid base, and algorithmic_pdb_uid_to_user_rid in
source3/passdb/passdb.c. A person with an NT key and neither is published
without sambaNTPassword, and check and the startup log say who. An odd
base, or one below 1000, is refused: Samba would silently use another one.
⛔ Breaking change for v0.10.x configurations: publish_nt_hash = true
without a samba block is refused at startup, with a message saying what to
add. v0.10.x served that configuration; an upgrade that does not add the block
stops, and authnd check refuses it the same way before the restart. Add:
samba {
domain_sid = "S-1-5-21-..." # the SMB server's domain SID: `net getlocalsid` prints it
}
That is deliberate. The entry v0.10.x published cannot pass the schema. The two quiet alternatives are dropping the attribute, which takes SMB away from everybody, and dropping the entry, which takes the person away. Both would happen with nothing on the console.
samba.schema carries the three Samba definitions. They are written from
Samba's published OIDs, names, syntaxes and matching rules, not copied from
Samba's GPL-3.0 samba.schema, and sambaSamAccount's MAY lists only what
authnd publishes. See the file.
Site schemas, and the auxiliary classes a source declares
A French research site serves SUPANN and eduPerson attributes. Since v0.12.0 authnd loads the schema files a site already has, and publishes the auxiliary classes a source's configuration declares:
schema_files = ["/etc/authnd/supann.schema", "/etc/authnd/eduperson.ldif"]
schema { # site-local definitions, hclschema's shape
attribute_type "siteBadge" {
oid = "1.3.6.1.4.1.99999.1.1"
syntax = "1.3.6.1.4.1.1466.115.121.1.15"
}
object_class "siteMember" {
oid = "1.3.6.1.4.1.99999.2.1"
kind = "auxiliary"
may = ["siteBadge"]
}
}
users "sql" {
driver = "postgres"
dsn_file = "/etc/authnd/dsn"
users = "select login, secret from staff"
users_attributes = "select login, sn, etab as \"supannEtablissement\", civ as \"supannCivilite\" from staff"
object_classes = ["supannPerson", "eduPerson"] # declared, never inferred
group_object_classes = ["supannGroupe"]
}
user "alice" { object_classes = ["siteMember"] ... }
group "staff" { object_classes = ["supannGroupe"] ... }
-
schema_filesare OpenLDAP.schemaor cn=config LDIF files, loaded as published, in order, on top of the standard schema (andsamba.schemawith thesambablock), by go-authn/ldap's slapd-compatible readers: any term order, slapd and RFC 2849 continuations, base64,{N}prefixes,objectidentifiermacros (one file's macros reach the next, as across slapd includes). A relative path is relative to the working directory, like every other path in the configuration. Each file must be a regular file of at most 4 MiB (go-authn/ldap'sschema.MaxFileSize, applied while reading); it is read once, at startup. -
A file that does not build refuses startup and
check, with the file, the line and column where the definition starts, and the kind of refusal: an unknown superclass, attribute, rule or syntax, a structural class derived from an auxiliary one, a usage or supertype rule broken, an OID or a name defined twice (with where it was first defined). The place and the kind come from go-authn/ldap's typed*schema.BuildError, not from reading its message:schema_files: site.schema:6:1: the object class exampleB has MAY noSuchAttribute, which is not a defined attribute type (unknown reference) -
⛔ A file may not redefine a definition that is already there -- the standard schema's, Samba's, or an earlier file's -- even word for word. That is slapd's rule: a schema file's definitions go through
parse_at/parse_oc(servers/slapd/schemaparse.c), andat_insert/oc_insert(at.c,oc.c) answer an OID or name already there with Duplicate attributeType (same OID and names) or Inconsistent duplicate attributeType; only slapd's own hard-coded schema (register_at(..., dupok)) tolerates the first. A file slapd refuses is a file authnd refuses, and the refusal says whether the text was the same:schema_files: /etc/authnd/core-copy.schema:1:1: the attribute type cn (2.5.4.3) is already defined by the standard schema (rfc4519.schema:42:1), and this is the same definition, word for word: a schema file adds definitions and never replaces one -- slapd refuses this too ("Duplicate attributeType", servers/slapd/at.c and oc.c). Remove it from this file -
A
schemablock holds site-local definitions in hclschema's shape (attribute_type,object_class,object_identifier,matching_rule,ldap_syntax), after the files. hclschema decodes from bytes; each block is handed to it as its own text at its own place in the configuration file (the bytes before it blanked, line breaks kept), so the literal-only rule -- no variables, functions,${...}or%{...}-- and hclschema's bounds hold exactly as for an.hclschema file, and every error names the configuration's own line and column. That was preferred to a newhcl.BodyAPI in go-authn/ldap: one decoder instead of two entry points, the byte bounds still measured on bytes, and no release to wait for. -
A declared class is checked at startup and by
checkagainst the schema served: it must be defined, AUXILIARY (RFC 4512 §4.1.1; an entry has one structural chain, §2.4.2, which authnd chooses), and not one authnd derives itself (account,inetOrgPersonand its superclasses,posixAccount,posixGroup,ldapPublicKey,uidObject,sambaSamAccount,groupOfNames,organizationalRole, under any of their names). Otherwise startup is refused, naming every offending block, its position and the class:c.hcl:7:20: user "a" object_classes: exampleUnit is STRUCTURAL, and an entry has exactly one structural chain (RFC 4512 §2.4.2), which authnd chooses: only an AUXILIARY class (§4.1.1) can be declared c.hcl:29:26: users "sql" group_object_classes: groupOfNames is a class authnd derives itself, from what each person or group has (see `authnd check`), and a declaration cannot overrule that -
Published with its superclasses (RFC 4512 §3.3: "all superclasses of the named classes SHALL be implicitly added"), after the classes authnd derives. The attributes those classes allow are kept instead of dropped. A class is never inferred from an attribute: a person whose source declares nothing keeps losing the attributes no class of theirs allows.
-
⛔ A person (or group) missing a MUST of a declared class is published without that class, its attributes are dropped like any other attribute no class allows, and both are reported. That is the rule authnd already follows -- the person stays published, problems are said, nothing is changed silently. Withholding the person would take them out of every search for one missing attribute; publishing the class without its MUST would serve an entry that fails the schema served. Which MUST is missing is
schema.Validate's answer. -
Two sources declaring different classes for one group: go-authn/directory publishes the first source's list and ignores the later one, silently.
checkand the startup log say so:group staff: the configuration file declares supannGroupe and a postgres database declares eduMember; the first is published and the second is ignored (the first source that declares classes for a group decides).
What check says, from the test that loads the committed example schema
(testdata/example.schema, testdata/example.ldif, OIDs under the private arc
1.3.6.1.4.1.99999) and a schema block (temporary paths shortened):
$ authnd check c.hcl
ldap://127.0.0.1:0, base dc=example,dc=org
schema: testdata/example.schema adds 3 object identifier macros, 3 attribute types and 5 object classes
schema: testdata/example.ldif adds 1 attribute type and 1 object class
schema: the schema block at c.hcl:8:1 adds 1 attribute type and 1 object class: exampleLocker and exampleLockerHolder
c.hcl:24:20: user "ann" declares exampleStaff, exampleLockerHolder and exampleOccupant for this person
c.hcl:35:20: user "ben" declares exampleStaff for this person
c.hcl:44:20: group "staff" declares exampleGroup for this group
USER FROM CAN PROVE PUBLISHED AS WITH
ann the configuration file a password person, organizationalPerson, inetOrgPerson, uidObject, examplePerson, exampleStaff, exampleLockerHolder, exampleOccupant uid, cn, exampleBadge, exampleEmployeeId, exampleLocker, exampleOffice and sn
ben the configuration file a password account, uidObject uid
cat the configuration file a password account, uidObject uid
GROUP MEMBERS PUBLISHED AS
staff ann and ben posixGroup, exampleGroup
ann has no uidNumber, gidNumber or homeDirectory: not a posixAccount, published as person, organizationalPerson, inetOrgPerson, uidObject, examplePerson, exampleStaff, exampleLockerHolder, exampleOccupant
ben has no uidNumber, gidNumber or homeDirectory: not a posixAccount, published as account, uidObject
ben is published without exampleStaff, which its source declares: ben has no exampleEmployeeId, which exampleStaff requires
ben is published without exampleBadge: no class of the entry allows it; examplePerson and exampleStaff would
cat has no uidNumber, gidNumber or homeDirectory: not a posixAccount, published as account, uidObject
cat is published without exampleBadge: no class of the entry allows it; examplePerson and exampleStaff would
...
this configuration can be served
ann's exampleStaff brought examplePerson, its superclass. ben declares
exampleStaff without its MUST exampleEmployeeId, so he is published
without it, and his exampleBadge with it. cat declares nothing, and nothing
is inferred from her exampleBadge. The startup log carries the same lines,
with DNs.
Schema files are not secrets, but they decide what every entry may hold: keep
them with the rest of the configuration, root:authnd, mode 0640, in a
directory only root writes (docs/install.md).
DIT content rules, name forms and structure rules
schema_files and schema blocks may hold the three kinds of definition
RFC 4512 §4.1.6 and §4.1.7 define (ditcontentrule, nameform,
ditstructurerule, or dit_content_rule, name_form, dit_structure_rule
in a block; slapd has a directive for the first only). They are published at
cn=Subschema, held to the redefinition rule above, and enforced:
-
A DIT content rule shapes each entry of its structural class. An auxiliary class not in its AUX is left off that entry -- a declared one, or one authnd derives: posixAccount, ldapPublicKey (and its sshPublicKey), sambaSamAccount (and sambaSID, sambaNTPassword), uidObject -- like a declared class missing a MUST. An attribute in its NOT is dropped. A type in its MUST that the entry lacks leaves the entry out, since no class can be left off to fix it. All of it is said at startup and by
check:uid=alice,ou=people,dc=example,dc=org is published without ldapPublicKey: the DIT content rule exampleAccountRule does not allow it (not in its AUX) uid=carol,ou=people,dc=example,dc=org is NOT published: it fails the schema: telephoneNumber is required by the DIT content rule examplePersonRule and is missing (RFC 4512 4.1.6) -
⛔ Structure rules hold the tree, which is configuration: with one active DIT structure rule, the suffix (given the class its RDN names: dc a domain, o an organization, ou an organizationalUnit, c a country, l a locality),
ou=peopleandou=groups(organizationalUnits), and the RDN attributes authnd names people (uid) and groups (cn) by are checked from the root down, with go-authn/ldap'sGoverningStructureRule. A tree the rules do not allow refuses startup andcheck:DIT structure rules: authnd names its people uid=<name>,ou=people,dc=example,dc=org, and an account named so is refused: the RDN (uid) is allowed by the name form of no DIT structure rule that places account under an entry governed by the rule 2: accountByCN needs cnA person or group whose structural class -- which comes from its data: an
snmakes an inetOrgPerson, agidNumbera posixGroup -- no rule places under its container is left out and said. An RDN the data makes different (a name holding+) never reaches the rules: Validate already refuses an entry whose RDN is not its own value. With no active structure rule, nothing changes.testdata/rules.schemais a complete example for this tree.
What it refuses, and why
An LDAP server is asked to prove people, so the refusals are the design.
The two empty-password binds
RFC 4513 has two, and they are one field apart:
| §5.1.1 anonymous | empty name, empty password | legitimate — answered success, and the association is anonymous |
| §5.1.2 unauthenticated | a name, empty password | refused with unwillingToPerform |
The unauthenticated one establishes an anonymous state while naming
somebody. The name "is not to be authenticated or otherwise validated", and
the specification says servers "SHOULD by default fail Unauthenticated Bind
requests with a resultCode of unwillingToPerform".
Getting this wrong goes wrong in both directions, and this server has managed both:
- Passing it through as proof lets anybody in as anybody, and the client cannot tell it from a real success.
- Refusing every empty password — which this did until the root DSE arrived — locks out every anonymous client, including from the discovery that tells it how to authenticate at all.
- Refusing it as
invalidCredentialssays that password was wrong, so a person retries and starts doubting a password that was never the problem.unwillingToPerformsays this server does not do that, which is true and stops the retry.
Both are checked, with OpenLDAP's own client sending exactly them.
Two people whose names differ only by case
A uid is compared ignoring case (RFC 4519
§2.39), and so is every DN. user "backup" in the file and a row Backup in a
database are therefore one name to LDAP and two people to anything that
compares bytes — which is a password change by one landing on the other. So a
configuration whose sources between them hold two such names is refused at
startup, naming both and where each came from. A password change is also
authorized on the exact identity the connection bound as, not on a DN
comparison, so the two controls do not depend on each other.
Sources are read every refresh, and a source that cannot be read stops the start
The people and the groups of every source are read at startup and then
again every refresh (top-level, "60s" by default, at least one second;
refresh = "0" reads once, at startup). A removed person or a removed group
member is gone from the directory within one period. The new data is read,
checked against the schema and the rules, and published into a new snapshot
off the serving path, then swapped in whole: a search asks no database
anything and answers from one snapshot to the end. A search over 500 people and
200 groups of 50 took 4.13 s in v0.13.3, when every search ran the groups
queries, and takes 0.5 ms.
A refresh that fails (a source down, a query that does not run, a group block
naming somebody who is no longer served) keeps the last good snapshot and
says so once per failure streak, naming the source. To stop serving data that
may have been revoked, set max_age (for example "10m", at least refresh):
once the served data is older, searches, people's binds and password changes
answer unavailable (52); readers, the root DSE and cn=Subschema keep
answering. Without max_age stale data is served for as long as refreshes fail.
A refresh never overlaps the previous one and does not wait for a slow source
longer than one period. It reads each source once, in bulk: 10,000 people and
2,000 groups of 50 on SQLite take about 1.6 s of one core (v0.14.1; v0.14.0
needed 390 s), 2.7 % of the default period. What is left out (an entry that fails the schema) is
logged again only when that set changes.
A source that cannot be read at startup — a groups query that does not run,
one of several databases down — refuses startup and check, naming the
source and the group, rather than starting as a smaller directory. An entry too
large for one LDAP message (about 3 MiB, one 10 MB attribute value is enough) is
not published, as an entry that fails the schema is not, and is said at
startup and by check. v0.13.4 had no refresh and needed a restart for any
change to a group; v0.14.0 does not.
Limits, an upstream outage, and a renewed certificate (v0.15.0)
Four top-level keys bound what a client, or a slow source, can cost. authnd check prints the values in force (limits: 4096 connections, no limit per address; an operation may take 30s; startup may take 1m0s).
| key | default | meaning |
|---|---|---|
max_connections |
4096 |
connections held at once; over it a connection is sent a notice of disconnection (RFC 4511 4.4.1, result code busy) and closed. 0 = no limit. Lower it under a small ulimit -n. |
max_connections_per_ip |
0 (none) |
per remote address; must not exceed max_connections. Behind a NAT or a proxy every client is one address: leave it unset. |
operation_timeout |
"30s" |
one LDAP operation. At the deadline the library answers timeLimitExceeded (3) for a handler that is still running; a bind waiting on an upstream directory is answered unavailable (52) a tenth earlier. "0" = no limit. |
startup_timeout |
"60s" |
opening and first reading every source. On expiry authnd exits with an error naming the stage and the source (startup did not finish within 60s (startup_timeout): reading the people of ldap ldap://... did not answer). "0" = no limit. |
An outage is not a wrong password. When the directory that holds a
password (an ldap source) cannot be asked, because the connection is refused,
reset or cut mid-bind, the upstream answers busy, unavailable or other, or it
does not answer within operation_timeout, the bind is answered
unavailable (52), not invalidCredentials (49), and the cause goes to the
log only. A wrong password is still 49. The same holds for the old password of
a password change and for the password factor of a second factor. Built on
go-authn/directory v0.17.0 (ErrUnavailable) and go-authn/ldap v0.12.0.
The TLS certificate is read again when cert_file or key_file change (their
content, checked on the first handshake after 5 s, and on
SIGHUP), so a renewed certificate (ACME) needs no restart. A pair that does
not load (garbage, half a renewal, a key that does not match) keeps the one
being served and is logged once. The configuration and the keytab are still
read at startup only; SIGHUP does not reload them.
Changing a password proves the old one
A person may change their own password, and nobody else's, through the RFC 3062
password modify operation (what ldappasswd sends) or an RFC 4511 modify. In
both, the current password must be sent with the new one, whatever the
connection bound with. A session bound with an OAUTHBEARER token, which is
short-lived and may be single-factor, could otherwise turn it into a permanent
password that also works for kinit.
$ ldappasswd -H ldap://localhost:3893 -D uid=alice,ou=people,dc=example,dc=org \
-W -A -S
-A asks for the old password and -S for the new one. With ldapmodify, the
change is a delete of the old value followed by an add of the new one, in one
request:
dn: uid=alice,ou=people,dc=example,dc=org
changetype: modify
delete: userPassword
userPassword: the old one
-
add: userPassword
userPassword: the new one
-
| Request | Answer |
|---|---|
no old password, or a bare replace: userPassword |
insufficientAccessRights |
| an old password that is not the current one | unwillingToPerform |
| anybody with no password at all (signs in with a token only) | unwillingToPerform: a bearer token is not a password, so it cannot set the first one either. An administrator sets it in the configuration. |
somebody served from a users block (a database, another directory) |
unwillingToPerform: this server reads those sources and does not write them. Only the people written down in the configuration files can change a password here, and the change is written back into the file that declares them. |
This is OpenLDAP's ppolicy behaviour with pwdSafeModify set
(slapo-ppolicy(5)), with the result codes its ppolicy.c returns. RFC 3062 §3
allows it: "If oldPasswd is not present, the server MAY use other policy to
determine whether or not to change the password." Before v0.7.0 a
bound session could change its password without the old one.
Anonymous search
A directory that answers everybody has published its people to everybody. A
reader binds first, and a person who binds successfully still may not read
the list.
A credential over a plaintext socket
sambaNTPassword is not a password hash in the sense a login form means:
whoever holds MD4(UTF16LE(password)) authenticates as that person over
NTLMv2, without ever learning the password. So publishing it needs TLS — or a
listener that is not on the network at all:
$ authnd check /etc/authnd.d
authnd: publish_nt_hash needs cert_file and key_file: an NT hash IS the credential,
and 0.0.0.0:3893 is not loopback, so it would cross a network in the clear
A second factor, over a protocol with one field
LDAP's simple bind carries a name and a password and nothing else — no place for a code, no round trip to ask for one, and a client that speaks LDAP will not learn one. So the code goes on the end of the password, which is what every appliance that has ever done this does, and what people already know how to type:
password: hunter2314159
^^^^^^^ ^^^^^^
password the six digits on their phone
mfa {
factors = 2
distinct_kinds = true # two things they KNOW are not two factors
# digits = 6, period = 30 (seconds) and window = 1 are the defaults. Each
# step of window is a step of somebody else's guessing time: set it on purpose.
}
user "tess" {
password = "…"
totp_secret = "JBSWY3DPEHPK3PXPJBSWY3DPEHPK3PXP" # ⛔ this IS the second factor
}
The secret can equally come from a database column or an LDAP attribute — see go-authn/directory, where it is a credential like any other. The checking is go-authn/totp's.
What follows from the one field, since none of it is obvious:
- The split is by length. The last N characters are the code. A password ending in digits is fine; a field no longer than a code is refused rather than guessed at, because a short password cannot be told apart from a code.
- Both halves are always checked. Returning early on a wrong password would say, in the time taken, which half was wrong.
- A code is used once. It is valid for a whole step, so a server that accepts one twice accepts a replay — and every step up to the last accepted one is refused, not merely the same one.
- Guessing is limited per name, with go-authn/totp's defaults: after five
wrong codes in a row a person is refused for fifteen minutes, the right code
included, and after a hundred they are locked out until authnd restarts. The
count is kept in memory, per process. Only a code behind the right password
is counted: otherwise anybody who knew a name could freeze its owner with
five binds. Behind a wrong password the code is still computed, so the time
taken says nothing either way. A reader is counted under its DN as the
configuration spells it, so
CN=Readergets no fresh budget of its own. - A secret too short to be one is refused at startup. A
totp_secretin auserorreaderblock under 128 bits (RFC 4226 R6) stops the server, naming the person, rather than failing every one of their logins later. - Nobody enrolled is not a refusal. A person with no secret has answered nothing rather than answered wrongly, and the server's own output says nobody enrolled an authenticator for this person while the client is told only that the bind failed.
- Readers do not carry a code unless
mfa { readers = true }: a service account has no phone. One that does gets atotp_secretof its own. - No Kerberos realm beside it. A
kerberosblock and anmfablock in one configuration are refused at startup. A kinit proves the password and nothing else — RFC 4120's encrypted-timestamp pre-authentication is keyed by the password alone, and a second factor needs OTP pre-authentication (RFC 6560) inside FAST, which this KDC does not implement. A realm here would hand out tickets on one factor to the people this block says need two, so the choice is made closed: one or the other.
check lists who cannot bind under the policy, which is the question to ask
before turning it on.
A token at the bind, in a field that is not the password
go-authn/oidc verifies a token into an
identity. The obvious way to carry one to an LDAP server is the password
field, and several appliances do exactly that. It works once and then stops:
the password field is where the mfa block above takes the code off the end,
and a token cannot be both.
So the token goes where the protocol has a place for it. A SASL bind's
credentials are an octet string of the mechanism's own shape, and
RFC 7628's OAUTHBEARER shape is a
GS2 header followed by key/value pairs, one of which is auth=Bearer <token>.
The password field is left alone — which is the whole point:
oidc {
issuer = "https://login.example.org/realms/staff"
audience = "ldap"
# Which claim names the person in THIS directory. The default order is
# preferred_username, then email when email_verified is true, then sub.
username_claim = "preferred_username"
# jwks_url skips discovery, for a provider that does not publish it.
}
On one listener, at the same time, tess still types her password with this second's code on the end of it, and alice sends a token. Neither arrangement costs the other anything.
How many factors is a token?
Whatever the issuer says it checked, and nothing more. A token carries
RFC 8176's amr: the methods the
provider used. That is the only evidence there is, so it is what the mfa
policy counts — ["pwd","otp"] is a password and a one-time code, of two
distinct kinds, and satisfies distinct_kinds = true. A token with no amr
is one factor and such a policy refuses it.
The alternative would be this server inventing a second factor nobody performed. The trust involved is the same trust the bind already rests on: a server that believes the issuer about who this is has no separate ground for disbelieving it about how it checked.
What it refuses
- A token in the clear. A bearer token needs no other half — whoever reads
it off the wire is that person until it expires.
allow_plaintext = trueexists for a deployment that terminates TLS in front of this process, which is a statement only that deployment can make. - A request to be somebody else. The GS2 header may name an
authzid. A token for alice carrying a request to act as bob is refused, not quietly treated as either. - A token for somebody no source here publishes, and the client is told
only
invalid_token. Which name exists is not something to hand out one guess at a time; the reason is in this server's own output, where an administrator is. - A mechanism this server does not speak, with
authMethodNotSupportedrather thaninvalidCredentials— those send a client to different places.
A refusal is RFC 7628 3.2.3's:
a JSON error carrying openid-configuration, returned as saslBindInProgress
so the client can send the ^A that ends the exchange.
The library this needed
glauth/ldap refused every SASL bind outright, so there was no field to put a
token in — which is why this page said, for months, that OIDC at the bind
needed "the shape deciding rather than guessing". It did not. RFC 7628 has had
a field for a token since 2015; the library would not carry one.
That is now go-authn/ldap, written here
rather than borrowed, after six defects turned up in the parts of the
borrowed one this server actually used — five of them in the fifty lines of
the bind path. Among them:
- a failed bind left the connection with the previous bind's authorisation (RFC 4511 §4.2.1), so on a pooled connection — how every LDAP client library is used — the person whose password was refused was served as the last one who succeeded;
- a substring filter was evaluated as a broader one, so
(uid=svc-*-prod)returnedsvc-web-stage. In a directory that is disclosure, and it is silent, because every entry returned is a real entry.
Those fixes are offered back as glauth/ldap #11, #12 and #13.
ldaps:// or StartTLS, and not both at once
starttls = true # a plaintext listener, upgraded on request
cert_file = "/etc/authnd/cert.pem"
key_file = "/etc/authnd/key.pem"
Without starttls, the listener is ldaps://: the handshake happens before a
byte of LDAP is spoken. With it, the listener is plaintext and a client asks
to upgrade (RFC 4511 4.14) — which is what a client pointed at 389 with "use
TLS" ticked does.
It is one or the other and has to be: a listener cannot both require a handshake and wait to see whether one is asked for. This used to be documented as both at once, which no deployment ever got.
⛔ StartTLS is asked for by the client, so merely offering it promises
nothing. A listener configured for it therefore refuses every bind and every
search until it has been upgraded, and says confidentialityRequired. That is
what keeps cert_file a guarantee rather than an offer — which is what
publish_nt_hash is allowed to rely on.
A Kerberos realm for the same people
kerberos {
realm = "EXAMPLE.ORG"
keytab = "/etc/authnd/krb5.keytab" # krbtgt/EXAMPLE.ORG, and a key per service
listen = "127.0.0.1:88" # the default; UDP and TCP both
lifetime = "10h" # the default, MIT's
}
A kerberos block makes this a KDC as well as a directory, through
go-authn/kdc: the same people are issued
tickets instead of only being looked up. Startup refuses a realm with no name,
one not in capitals, no keytab, or a keytab without krbtgt/REALM, and, as
said above, a kerberos block beside an mfa block.
A KDC needs the person's password, not only a way to check one, so a
person whose source only verifies (an LDAP directory behind this one) can never
be issued a ticket, and neither can one known only by an NT hash, which is the
RC4 key and not the AES key this realm issues. check lists who can and who
cannot, and why.
A password changed over LDAP reaches the realm at once: the KDC reads the same
people the directory serves, so the old password stops working for kinit the
moment it stops working for a bind.
check, before you restart something people log in through
$ authnd check authnd.hcl
ldap://127.0.0.1:3893, base dc=example,dc=org
USER FROM CAN PROVE PUBLISHED AS WITH
dora a sqlite database a password and 1 key person, organizationalPerson, inetOrgPerson, posixAccount, ldapPublicKey, uidObject uid, cn, sn, uidNumber, gidNumber, homeDirectory, mail and sshPublicKey
eli a sqlite database a password account, posixAccount, uidObject uid, cn, uidNumber, gidNumber and homeDirectory
fay a sqlite database a password person, organizationalPerson, inetOrgPerson, uidObject uid, cn, sn and mail
gus a sqlite database a password account, uidObject uid
hal a sqlite database a password account, uidObject uid
ida a sqlite database a password account, posixAccount, uidObject uid, cn, uidNumber, gidNumber and homeDirectory
GROUP MEMBERS PUBLISHED AS
alumni nobody organizationalRole
engineers dora and eli posixGroup
nobody-yet nobody posixGroup
readers fay and gus groupOfNames
fay has no uidNumber, gidNumber or homeDirectory: not a posixAccount, published as person, organizationalPerson, inetOrgPerson, uidObject
gus has no uidNumber, gidNumber or homeDirectory: not a posixAccount, published as account, uidObject
hal has no homeDirectory: not a posixAccount, published as account, uidObject
hal is published without uidNumber: no class of the entry allows it; posixAccount would
hal is published without gidNumber: no class of the entry allows it; posixAccount and posixGroup would
hal is published without mail: no class of the entry allows it; inetOrgPerson would
group alumni has no gidNumber and nobody in it: not a posixGroup, published as an organizationalRole (a groupOfNames needs a member)
group readers has no gidNumber: not a posixGroup, published as a groupOfNames (member DNs, no memberUid)
a bind carries a password
cn=reader,dc=example,dc=org may search; every other bound client may not
sambaNTPassword is not published, so nothing here can authenticate an SMB session (NTLMv2 needs the password or its MD4, and a bind cannot answer it)
this configuration can be served
It opens every source, the certificate and the keytab -- everything the server opens before it listens -- and prints what would be published, without listening, and never a secret, only where each one comes from. A port somebody else holds is the one failure it cannot see.
PUBLISHED AS is each entry's object classes and WITH its attributes, read
off the entries a search would return, built and validated the same way. After
the tables come the people who lack posixAccount's attributes and which ones,
the groups without a gidNumber and what they are published as instead, the
people whose NT hash has no sambaSID to go with it, every attribute dropped
because no class of its entry allows it (and which classes would), and every entry that is
not published, with each problem schema.Validate named.
The awkward half, said once
NTLMv2 needs the password or its MD4, and nothing else will do. A client never sends a password to an SMB server; it sends a proof computed from one. So a file server asking authnd about somebody can serve them over SMB only if the source held enough for that — a password, or the hash. A directory that only checks passwords (an LDAP directory behind this one) answers WebDAV and can never answer SMB, whatever authnd does in between.
check prints that per person, and publish_nt_hash is the switch that
decides it. Nothing about this is a limitation of this program.
Verified against real things
- OpenLDAP's own
ldapsearchreads this server: the entries, a filter that really filters, the groups, a bind as a person,Invalid credentials (49)for a wrong password andServer is unwilling to perform (53)for the unauthenticated bind. A Go client from the same ecosystem could share a misreading of the protocol with the server and agree with it; OpenLDAP cannot. - TLS, judged twice:
ldapsearchoverldaps://proves the LDAP conversation crosses TLS, andopenssl s_client -verify_return_errorproves the chain. (macOS'sldapsearchtrusts the system store and ignoresLDAPTLS_CACERT, so it cannot be told to trust a test's certificate — measured, not assumed.) - The whole stack: a SQLite database published by this server and read back
by
go-authn/directory/ldapdir, which is the library a file server uses. The identities that come out carry the NT hash SMB needs — and, withpublish_nt_hashoff, provably do not. - RENATER's SUPANN schema, as published, loaded by
schema_filesand served from a SQLite source declaringsupannPerson, read back byldapsearch(supannEtablissement,supannCivilite,supannListeRouge,supannEmpId, and a filter evaluated with the file's matching rules). The file is downloaded only whenLDAP_SCHEMA_NETWORK=1, checked against its recorded SHA-256, and never committed: RENATER publishes it without a licence. - The NT hash the tests expect was computed by an OpenSSL that is not this
program (
printf 'hunter2' | iconv -t UTF-16LE | openssl md4, with 1.1, since 3.x dropped MD4), and that OpenSSL was itself checked against the value every article about NTLM quotes forpassword.
Building only what you want
| build | size | what it leaves out |
|---|---|---|
| everything | 26.6 MiB | |
-tags noldap |
26.3 MiB | the LDAP client, and the users "ldap" block |
-tags nosql |
15.1 MiB | the three database drivers, and the users "sql" block |
-tags nosql,noldap |
14.8 MiB | both |
As the CI's build-tags lanes print them (linux/amd64, Go 1.27.1,
CGO_ENABLED=0).
The server itself is always there; the tags are about where the people come
from. nosql is by a distance the biggest lever — the three drivers weigh
11.5 MiB, close to half the binary — and it is also
attack surface: code that was never compiled cannot be reached.
Each tag lane runs the whole test suite, not a subset: the people arrive as
user blocks instead, and every question about binds, searches and refusals
is asked again. A tag lane that skipped its tests would prove only that the
binary links.
Not yet
-
Writes, beyond a person changing their own password (above).
add,deleteandmodifyDNare answeredunwillingToPerform, because this server implements no handler for them, and so is amodifyof one's own entry that touches anything butuserPassword. That is "I do not do that" — a different statement frominsufficientAccessRights, "you may not do that", and it sends an administrator somewhere else.What changed is that a correct write is now expressible. The library this used to stand on held a modify as three buckets — add, delete, replace — so
delete member=alice; add member=alice (alice stays) add member=alice; delete member=alice (alice goes)arrived as the same request, and no server built on it could tell which was asked.
go-authn/ldapcarries them as an ordered list, which is what RFC 4511 §4.6 requires. A directory this server fronts is still edited where it lives; the obstacle to changing that is now policy, not shape.
Release binaries
Each release carries authnd for linux, darwin and windows on amd64 and arm64
(pure Go, CGO_ENABLED=0), a SHA256SUMS manifest, and a build provenance
attestation per binary, made by this repository's release workflow at the
tag. Check a download before running it:
sha256sum -c SHA256SUMS --ignore-missing
gh attestation verify authnd-linux-amd64 --repo go-authn/authnd
authnd --version prints the tag it was built from.
To run it as a systemd service -- the account, a hardened unit, a configuration for each source, TLS, the Kerberos realm, upgrades and roll back -- see docs/install.md.
Licence
BSD-3-Clause.
Documentation
¶
Overview ¶
Command authnd is an LDAP server for people who are somewhere else.
authnd --config /etc/authnd.d authnd check /etc/authnd.d
A site's people are in a database, or in a file, or in a directory that already exists. The things that want to authenticate them speak LDAP, because almost everything speaks LDAP. authnd is the front: it binds and searches over go-authn/directory's sources, so that what a database holds can be read by anything that knows how to ask a directory.
A realm, optionally ¶
A kerberos block makes this a KDC as well as a directory: the same people, issued tickets instead of only being looked up. It answers on UDP and TCP, requires encrypted-timestamp pre-authentication, and signs its own tickets with the krbtgt key in a keytab — the same file the services read.
⛔ Not every directory can back one. A KDC must DECRYPT the client's pre-authentication with that person's long-term key, so it needs the PASSWORD; a source that only VERIFIES one cannot produce a key. `authnd check` names the people this affects, because the alternative is somebody discovering it when their correct password is reported wrong.
What it refuses, and why ¶
An LDAP server is asked to prove people, so the refusals are the design:
The UNAUTHENTICATED BIND. A bind carrying a NAME and an empty password (RFC 4513 5.1.2) establishes an anonymous state while naming somebody: the name "is not to be authenticated or otherwise validated", and a server that passes it through as proof lets anybody in as anybody. Refused with unwillingToPerform, which is what 5.1.2 asks for -- invalidCredentials would say the password was wrong, and a person would retry one that was never the problem.
Its neighbour is NOT refused: 5.1.1, the ANONYMOUS bind, is an empty name AND an empty password, it is legitimate, and refusing it locks every client out of the root DSE that says how to authenticate.
ANONYMOUS SEARCH. A directory that answers everybody publishes its people to everybody. A reader binds first.
A CREDENTIAL OVER A PLAINTEXT SOCKET. sambaNTPassword is not a password hash in the sense a login form means: whoever holds it authenticates as that person over NTLMv2. So publishing it needs TLS -- or a listener that is not on the network at all.
A second factor, over a protocol with one field ¶
An LDAP simple bind carries a name and a password and nothing else. So a code goes on the END of the password -- "hunter2314159" -- which is what every appliance doing this does. The split is by length, both halves are always checked so the time taken says nothing about which was wrong, and a code is accepted once: it is valid for a whole step, and a server that takes it twice takes a replay.
The awkward half, said once ¶
NTLMv2 needs the password or its MD4, so a file server asking authnd about somebody can serve them over SMB only if the SOURCE holds enough for that. A directory that only checks passwords -- an LDAP directory behind this one -- answers WebDAV and cannot answer SMB, whatever authnd does in between. `authnd check` prints that per person, from the same model the file server uses: github.com/go-authn/directory.