directory

package module
v0.5.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Sep 12, 2026 License: BSD-3-Clause Imports: 9 Imported by: 0

README

directory

Go Reference License CI

Who somebody is and what proves them — one model over a database, an LDAP server, or a list you hold yourself. Pure Go, CGO_ENABLED=0, no driver dependencies in the core.

people := directory.NewSet(
    &directory.Static{Name: "the configuration file", People: local},
    sqlSource,   // github.com/go-authn/directory/sqldir
    ldapSource,  // github.com/go-authn/directory/ldapdir
)

for _, id := range must(people.Identities()) {
    fmt.Println(id.Name(), id.Can(directory.NTHash), id.Can(directory.PublicKeys))
}

What proves somebody is not one thing

This is why the package exists, and it is a fact about protocols rather than a design choice.

needs so it can come from
NTLMv2 (SMB, Windows file sharing) the password, or MD4(UTF16LE(password)) — the "NT hash" a password you hold, or sambaNTPassword
HTTP Basic, and anything that hands the server the password any verifier a comparison, a hash, or an LDAP bind
SSH a public key or a certificate sshPublicKey, or a column
a second factor (RFC 6238) the shared secret behind the six digits a column, or an attribute you name

NTLMv2 is a challenge-response: the client never sends the password, so the server computes MD4(UTF16LE(password)) itself. An LDAP bind cannot authenticate an SMB session, and neither can a bcrypt. That is why Samba's own schema has sambaNTPassword, and why Identity.Can exists — so a server can tell somebody "you can use WebDAV and SFTP, not SMB, because this directory holds a bcrypt" before they meet a refusal at a mount.

And plainly in the other direction: the NT hash is the credential. Anybody holding it authenticates as that person exactly as if they held the password.

The sources

package reads dependencies
directory a Static list you build none
directory/sqldir any *sql.DB, with your queries none — the driver is yours to pick
directory/ldapdir an LDAP server go-ldap/ldap/v3
directory/hcldir a users block from a configuration file none — the struct tags are inert
directory/ldaptest (an LDAP directory to test against) glauth/ldap

A one-time-code secret is a credential like the others — Can(TOTPSecret), Identity.TOTPSecret() — and like the NT hash it is the credential: whoever holds it produces every future code. sqldir reads it from a fifth column; ldapdir reads it from an attribute you name, with no default, because there is no standard one (FreeIPA has ipatokenOTPkey, other schemas have oathSecret) and a guess would read nothing while looking like it had looked. go-authn/totp is what checks a code against it.

sqldir takes queries rather than a schema, because a site whose people are already in a database has them in its shape; a schema this package invented would mean copying them into a second one that goes stale. It takes an *sql.DB rather than a DSN, so a program that wants SQLite does not carry PostgreSQL.

The users block

Two programs had written the same block — a file server deciding who may mount a share, and an authentication server answering for them both — so it lives here once:

users "sql" {
  driver   = "postgres"               # or sqlite, or mysql
  dsn_file = "/etc/authnd/dsn"        # a DSN holds a password: it lives in a file
  users    = "select login, nt_hash, ssh_keys from staff"
  groups   = "select team, member from team_members"
}

users "ldap" {
  url                = "ldaps://ldap.example.org"
  base_dn            = "ou=people,dc=example,dc=org"
  bind_dn            = "cn=reader,dc=example,dc=org"
  bind_password_file = "/etc/authnd/bind.pw"
}
src, err := hcldir.Open(block)      // or hcldir.OpenAll(blocks)

hcldir imports no HCL library: the struct tags are inert strings, so the caller decodes — with gohcl, or by hand — and hands the block over. Nor does it import a database driver: a program blank-imports the ones it wants, and a binary that imported none is told exactly that rather than "unknown driver", because the fix is one line in the program and not in the configuration.

Two build tags leave a kind out entirely: -tags nosql and -tags noldap. For the program that uses this, nosql is usually the biggest single lever it has — the three database drivers weigh about 12 MB.

Testing against a directory

directory/ldaptest is an LDAP server to test against, since three packages had written the same fixture and one of them is a file server in another organisation:

d, _ := ldaptest.NewServer(&ldaptest.Directory{
    People: map[string]ldaptest.Person{
        "dora": {Password: "hunter2", NTHash: hex.EncodeToString(directory.NTHashOf("hunter2"))},
        "eli":  {Password: "swordfish"},   // a bind, and nothing else
    },
    Groups: map[string][]string{"engineers": {"dora", "eli"}},
})
defer d.Close()

It is glauth/ldap underneath — an independent implementation — and it is itself read by OpenLDAP's own ldapsearch in CI, because a fixture built from one reading of a protocol can only ever confirm that reading. d.Binds() counts binds, so "this password was checked against the directory" is something a test can show rather than assume.

Groups

A group is written @staff wherever a person could be — Samba's spelling, which anybody administering a file server types without being told:

allowed, err := directory.Expand([]string{"@staff", "alice"}, people)

Listing the groups is a different question from asking about one, and a source may answer the second and not the first — an LDAP directory can permit a lookup and refuse an enumeration. So Set.GroupNames() reads the sources that can list (the optional GroupLister) and skips the rest: a server publishing a list of groups can honestly publish fewer than Members would answer for, and a server checking membership keeps asking Members.

A group with nobody in it is refused rather than expanded to nothing: a configuration that grants nothing to nobody reads exactly like one that works. A group no source has is an error, not an empty list, for the same reason — and a source that is broken is distinguished from one that simply lacks the group, because that is the difference between a wrong configuration and a database that is down.

Several sources, in order

The first source that knows a name owns it. A site with its people in LDAP and one service account written down locally should not have to put the service account in LDAP — and adding somebody to LDAP must not break a server that has them written down. Group membership, by contrast, is the union: a group can have people from both, and returning only the first half would silently exclude the rest.

Reading is done once

Sources are read when a server starts; a change is picked up by a restart. That is a deliberate limit, not an oversight — asking the directory on every connection is a different design with different failure modes (a server that stops authenticating when the database blinks). The exception is a password check, which may reach the source every time: a bind is the only way an LDAP directory answers "is this the right password", and it cannot be cached without holding the secret this package went out of its way not to hold.

Verified against real things

  • MD4 against RFC 1320's own test vectors, and the NT hash of password against the value every article about NTLM quotes.
  • sqldir against a real SQLite database through a real driver: actual SQL, actual NULLs deciding what each person can be proved with.
  • ldapdir against an independent LDAP server (glauth/ldap) rather than a fake — a fake built from my own reading of the protocol could only confirm that reading. The fixture deliberately allows the unauthenticated bind (an empty password, which a real directory answers with success), so the test fails if this package ever stops refusing it first.
  • ldaptest — the fixture itself — against OpenLDAP's ldapsearch, which knows nothing about this module: it reads the entries, the sambaNTPassword of the one person who has it, and the group; it binds as a person and is refused with Invalid credentials (49) for a wrong password.

Licence

BSD-3-Clause.

Documentation

Overview

Package directory answers three questions about the people a server serves: who is here, what proves them, and who is in which group.

The answers come from wherever a site already keeps them — a database, an LDAP server, a file, or the program's own configuration — and every source answers the same three questions, so the code that USES an identity never learns where it came from.

What proves somebody is not one thing

This is the whole reason the package exists, and it is a fact about protocols rather than a design choice:

NTLMv2 (SMB, Windows file sharing) is a CHALLENGE-RESPONSE. The client
never sends the password, so the server must compute MD4(UTF16LE(password))
itself. An LDAP bind cannot answer it. Neither can a bcrypt. What answers
it is the password, or that MD4 — the "NT hash", which is exactly what
Samba's sambaNTPassword attribute holds.

HTTP Basic, and anything else that hands the server the password, can be
answered by ANY verifier: a comparison, a hash, or a bind against a
directory that holds the secret and will not give it up.

SSH authenticates with a public key, or a certificate from an authority.
No password is involved at all, and a directory publishes the keys —
OpenSSH's convention is the sshPublicKey attribute.

So an Identity carries what its source could give, each protocol uses what it can, and Identity.Can says which is which. A server can then tell a person "you can use WebDAV and SFTP, and not SMB, because this directory holds a bcrypt" — before they meet a refusal at a mount.

Reading is done once

A source is read when the server starts, and a change to it is picked up by a restart. That is a deliberate limit rather than an oversight: the alternative is asking the directory on every connection, which is a different design with different failure modes (a server that stops authenticating when the database blinks), and this package says which one it is rather than implying.

The exception is a PASSWORD CHECK: Identity.Verify may reach the source every time, because a bind is the only way an LDAP directory will answer "is this the right password" and it cannot be cached without holding the secret this package went out of its way not to hold.

Index

Constants

View Source
const GroupPrefix = "@"

GroupPrefix is how a group is written where a person could be: @staff. It is Samba's spelling (`valid users = @staff`) and what anybody administering a file server types without being told.

Variables

View Source
var ErrNoCredential = errors.New("directory: this identity has no such credential")

ErrNoCredential says this identity cannot answer that question at all, which is a different thing from answering it wrongly: one is a configuration to fix, the other is somebody typing the wrong password.

View Source
var ErrNoSuchGroup = errors.New("directory: no such group")

ErrNoSuchGroup is what a source returns for a name it does not have.

View Source
var ErrWrongPassword = errors.New("directory: wrong password")

ErrWrongPassword is what a failed check returns.

Functions

func Expand

func Expand(names []string, src Source) ([]string, error)

Expand turns a list of names — people and groups — into the people in it, keeping the order and dropping repeats.

A group with nobody in it is refused rather than expanded to nothing: a configuration that grants nothing to nobody reads exactly like one that works.

func IsGroup

func IsGroup(name string) bool

IsGroup reports whether a name is a group's.

func NTHashOf

func NTHashOf(password string) []byte

NTHashOf is MD4(UTF16LE(password)), the value Samba stores as sambaNTPassword and Windows calls the NT hash.

MD4 is broken as a hash function and is used here anyway, because NTLMv2 is DEFINED over it: this is not a choice about security, it is the arithmetic the protocol specifies, and a server that used anything else would refuse every correct password.

func ParseNTHash

func ParseNTHash(s string) ([]byte, error)

ParseNTHash reads the hex a directory publishes.

A hash that is not 16 bytes is refused rather than padded or truncated: a mangled one would fail every login with "wrong password", which is the least useful thing a server could say.

func ParseTOTPSecret added in v0.4.0

func ParseTOTPSecret(s string) ([]byte, error)

ParseTOTPSecret reads the base32 a directory stores a one-time-code secret as: upper or lower case, spaces and padding optional, which is how people copy them out of an authenticator.

github.com/go-authn/totp reads the same shape and neither package imports the other: this one is about who somebody IS, that one is about one way of checking, and a dependency either way would make every consumer of one carry the other. Eight lines of encoding/base32 is the cheaper of the two prices, and it is written down here so that the next reader knows it was a choice.

Types

type Credential

type Credential int

A Credential is one way of proving somebody. They are named so that a server can SAY which it has and which a protocol needs, rather than discovering the mismatch when somebody fails to log in.

const (
	// Password is the password itself, which this process then holds. It
	// answers everything, and is the only thing that does.
	Password Credential = iota
	// NTHash is MD4(UTF16LE(password)) — Samba's sambaNTPassword. It answers
	// NTLMv2 and nothing else: it cannot be compared against a password a
	// client sent, because it IS the thing derived from it.
	NTHash
	// Verifier answers "is this the right password" without this process
	// knowing it: a hash comparison, or a bind against the directory that
	// holds it.
	Verifier
	// PublicKeys are SSH public keys, in authorized_keys spelling.
	PublicKeys
	// TOTPSecret is the shared secret behind the six digits on a phone (RFC
	// 6238). It is a SECOND factor and never a first: it proves the person
	// holds the thing it was enrolled into, and says nothing about who they
	// are. A server asking for two factors needs to know who has one — which
	// is the same question Can() answers for every other credential.
	TOTPSecret
)

func (Credential) String

func (c Credential) String() string

type GroupLister added in v0.3.0

type GroupLister interface {
	GroupNames() ([]string, error)
}

A GroupLister is a source that can say WHICH groups it has, rather than only answering about one by name.

It is a separate, optional interface because not every source can. Members asks a question with the name in hand -- an LDAP filter, a WHERE clause -- and a source that answers those need not be able to enumerate: a query that lists people says nothing about groups at all, and a directory may permit one and refuse the other.

A server that PUBLISHES groups needs this; one that only checks membership does not, which is why adding it did not change the Source interface.

type Identity

type Identity struct {
	// contains filtered or unexported fields
}

An Identity is one person, and what a source could give to prove them.

The zero value is nobody. Build one with NewIdentity or take one from a Source.

func NewIdentity

func NewIdentity(name string, opts ...Option) *Identity

NewIdentity names somebody. Credentials are added with the With… options, and a person with none can be named but proved by nothing — which is a legitimate state (a directory listing everybody, with the secrets somewhere else) and one Identity.Can reports honestly.

func (*Identity) Can

func (i *Identity) Can(c Credential) bool

Can reports whether this identity carries a given credential — which is what lets a server say "you can use WebDAV and not SMB" before somebody finds out the hard way.

func (*Identity) Groups

func (i *Identity) Groups() []string

Groups are the groups this source said they are in.

func (*Identity) KerberosKey added in v0.5.0

func (i *Identity) KerberosKey(derive func(password string) ([]byte, error)) ([]byte, error)

KerberosKey derives this identity's Kerberos long-term key.

The derivation is the CALLER's, and that is the whole design. A Kerberos key is string2key(password, salt, enctype) — arithmetic this package has no business carrying, since it would drag a Kerberos library into everything that merely wants to list users. Passing the function in means the password never leaves here and the enctype table stays where enctypes are understood.

It answers only for an identity that carries the PASSWORD. A Verifier — a bind against somebody else's directory, or a hash comparison — cannot serve Kerberos at all: a KDC has to DECRYPT the client's pre-authentication with this key, and "is this the right password" does not produce one. That is a property of Kerberos, not a limitation here, and a server should say so at configuration time rather than at the first kinit.

func (*Identity) Keys

func (i *Identity) Keys() []string

Keys are their SSH public keys, in authorized_keys spelling.

func (*Identity) NTKey

func (i *Identity) NTKey() ([]byte, error)

NTKey is the key NTLMv2 is computed with: MD4(UTF16LE(password)).

It comes from the hash when the source published one, and is derived from the password when the source gave that instead. A source with neither cannot serve SMB, and this says so rather than returning something that will fail every login with "wrong password".

func (*Identity) Name

func (i *Identity) Name() string

Name is who they are.

func (*Identity) TOTPSecret added in v0.4.0

func (i *Identity) TOTPSecret() []byte

TOTPSecret is the shared secret behind this person's one-time codes, or nil.

⛔ Like Identity.NTKey, this hands out a credential: whoever holds it produces every future code. It is here because a server that must CHECK a code has to have it, and a copy is returned so that the caller cannot change what a directory said.

func (*Identity) Verify

func (i *Identity) Verify(password string) error

Verify answers "is this the right password".

It prefers the verifier a source gave — that is the one that may reach an LDAP server, and the one that exists precisely so this process need not hold the secret. Falling back to a held password is a constant-time comparison: the difference between "wrong at byte 1" and "wrong at byte 12" is measurable over a network.

func (*Identity) Where

func (i *Identity) Where() string

Where is the source that knew them.

type Option

type Option func(*Identity)

An Option adds something to an identity.

func From

func From(where string) Option

From records where this identity came from, for a server to print.

func WithGroups

func WithGroups(groups ...string) Option

WithGroups says which groups this person is in, when the source knows without being asked.

func WithNTHash

func WithNTHash(hash []byte) Option

WithNTHash gives MD4(UTF16LE(password)) — 16 bytes, as a directory publishes it. Anybody holding this can authenticate as that person exactly as if they held the password: it is not a password hash in the sense a login form means, and storing it does not make a leak less bad.

func WithPassword

func WithPassword(password string) Option

WithPassword gives the password itself, which answers every protocol.

func WithPublicKeys

func WithPublicKeys(keys ...string) Option

WithPublicKeys gives SSH public keys, each in authorized_keys spelling.

func WithTOTPSecret added in v0.4.0

func WithTOTPSecret(secret []byte) Option

WithTOTPSecret gives the shared secret behind a one-time code (RFC 6238), as the raw bytes — github.com/go-authn/totp's ParseSecret reads the base32 spelling a directory usually stores.

⛔ It is the credential, not a hash of one: anybody holding it produces every future code, and it does not expire. A source that publishes it has published the second factor, exactly as with an NT hash.

func WithVerifier

func WithVerifier(verify func(password string) error) Option

WithVerifier gives a way to check a password without holding it: a hash comparison, or a bind. The error it returns is reported to the SERVER, never to the client — a client is told only that authentication failed, which is the right amount to tell somebody who has not proved who they are.

type Set

type Set struct {
	// contains filtered or unexported fields
}

A Set is several sources read as one.

A site with its people in LDAP and one service account written down locally should not have to put the service account in LDAP. So the sources are asked in the order they were given and the FIRST one that knows a name owns it — which is the rule a person can hold in their head, and the one that lets a local file override a directory rather than the other way round.

func NewSet

func NewSet(sources ...Source) *Set

NewSet reads these sources, in this order.

func (*Set) Add

func (s *Set) Add(src Source)

Add appends a source, which will be asked after the ones already there.

func (*Set) Close

func (s *Set) Close() error

Close closes whichever sources hold something open.

func (*Set) Describe

func (s *Set) Describe() string

func (*Set) GroupNames added in v0.3.0

func (s *Set) GroupNames() ([]string, error)

GroupNames is every group the sources can name, sorted and without repeats.

Sources that cannot list groups are skipped rather than refused, so this can legitimately return FEWER groups than Set.Members would answer for: an LDAP directory that will not enumerate still answers about a group somebody names. A caller publishing a list should say where it came from, and a caller checking membership should keep asking Members.

func (*Set) Identities

func (s *Set) Identities() ([]*Identity, error)

func (*Set) Members

func (s *Set) Members(group string) ([]string, error)

Members asks every source and puts the answers together: a group can have people from a database and from a file, and a server that returned only the first source's half would silently exclude the rest.

func (*Set) Sources

func (s *Set) Sources() []Source

Sources are the sources, in order.

type Source

type Source interface {
	// Identities is everybody this source knows, with whatever credentials it
	// has for them.
	Identities() ([]*Identity, error)

	// Members expands one group. A group the source has never heard of is an
	// ERROR, not an empty list: an empty list quietly grants nothing to
	// nobody, which reads exactly like a working configuration.
	Members(group string) ([]string, error)

	// Describe says what this is, for a server to print: "a postgres
	// database", "ldaps://ldap.example.org", "the configuration file".
	Describe() string
}

A Source is somewhere people are kept.

The three questions are the whole interface, and they are the three a file server actually asks: who is here, who is in this group, and where did that come from.

type Static

type Static struct {
	Name   string
	People []*Identity
	Groups map[string][]string
}

Static is a source held in memory: what a program's own configuration file becomes once it is read, and what a test uses.

func (*Static) Describe

func (s *Static) Describe() string

func (*Static) GroupNames added in v0.3.0

func (s *Static) GroupNames() ([]string, error)

GroupNames is the groups this list holds, which it knows exactly.

func (*Static) Identities

func (s *Static) Identities() ([]*Identity, error)

func (*Static) Members

func (s *Static) Members(group string) ([]string, error)

Directories

Path Synopsis
Package hcldir is the `users` block: a directory named in a configuration file, opened.
Package hcldir is the `users` block: a directory named in a configuration file, opened.
Package ldapdir reads people and groups from an LDAP directory.
Package ldapdir reads people and groups from an LDAP directory.
Package ldaptest is an LDAP directory to test against.
Package ldaptest is an LDAP directory to test against.
Package sqldir reads people and groups from a database.
Package sqldir reads people and groups from a database.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL