directory

package module
v0.14.0 Latest Latest
Warning

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

Go to latest
Published: Oct 18, 2026 License: BSD-3-Clause Imports: 11 Imported by: 4

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.

So an Identity answers each protocol with its own method:

  • Verify(password) for anything that is handed the password. It prefers the verifier a source gave (which may be an LDAP bind), and falls back to a held password compared in constant time, on SHA-256 digests so that not even the length leaks.
  • NTKey() for NTLMv2: from the hash a source published, or derived from the password; ErrNoCredential when it has neither.
  • KerberosKey(derive) for a KDC, which only an identity carrying the password can answer: a KDC has to DECRYPT with the key, and a yes from a verifier is not one.

A database whose passwords are bcrypt, argon2 or anything else is read with sqldir.WithPasswordCheck(func(db, name, password) error). Without it, a password column is compared as it stands.

Attributes: what describes somebody, and proves nothing

A server publishing posixAccount (RFC 2307), posixGroup and inetOrgPerson (RFC 2798) entries needs more than credentials: a uidNumber, a homeDirectory, a mail. These are attributes, kept apart from credentials on purpose — a credential decides who may log in, an attribute is published to everybody allowed to search.

a, err := directory.NewAttributes(
    directory.Attribute{Name: "uidNumber", Values: []string{"1000"}},
    directory.Attribute{Name: "homeDirectory", Values: []string{"/home/alice"}},
    directory.Attribute{Name: "mail", Values: []string{"alice@example.org", "a.liddell@example.org"}},
)
id := directory.NewIdentity("alice", directory.WithPassword("s3cret"), directory.WithAttributes(a))

id.Attributes().Get("UIDNUMBER")   // ["1000"]: case-insensitive, as LDAP is
id.Attributes().Names()            // [uidNumber homeDirectory mail]: in order
  • Names are RFC 4512 attribute descriptions in descriptor form, with optional options: mail, description;lang-fr. The type and options are case-insensitive and the order of the options does not matter (§2.5). The numeric form (2.5.4.35) is refused: refusals are by name, and 2.5.4.35 is userPassword.
  • Values are strings and are not interpreted: "0100" stays "0100". An empty value, or none, is refused; an exact repeat is kept once.
  • An Attributes is immutable, and can only be built by NewAttributes, so one that exists has been checked. CheckAttributeName applies the same rule on its own, for a configuration to refuse before anything is read.
What can never be an attribute
refused why
userPassword, authPassword, userPKCS12, sambaNTPassword, sambaLMPassword, unicodePwd, krbPrincipalKey, ipaNTHash, ipatokenOTPkey, oathSecret, … (56 secrets; ReservedAttributes() returns the whole refusal list, 75 names, with reasons) a secret, read off RFC 4519, RFC 3112, RFC 2798 and the Samba, Active Directory, MIT and Heimdal Kerberos, FreeIPA, 389-ds and OpenLDAP schemas
any name containing password, passwd, pwd, secret, credential, hash, privatekey a list only knows the schemas somebody read; the one standard name this would catch, secretary (RFC 4524), is allowed
sshPublicKey it has its own path, WithPublicKeys, so Can(PublicKeys) stays true
uid, userid, cn, commonName, objectClass the server derives them from the name it publishes the entry under
member, uniqueMember, memberUid, memberOf membership is what Members answers; a second answer would disagree
createTimestamp, modifyTimestamp, creatorsName, modifiersName, structuralObjectClass, governingStructureRule, subschemaSubentry, entryDN, entryUUID the server maintains them; a configuration could only forge them
Group attributes

A source may also describe its groups, through the optional GroupAttributer (GroupAttributes(group) (Attributes, error)). A source that does not implement it still compiles and behaves as before. Set.GroupAttributes asks every source: the first source to give an attribute owns it, so a gidNumber in a local file overrides the directory's while a description only the directory has comes through. Values are never merged across sources. A source without GroupAttributer is asked Members, to learn whether it has the group at all. Unlike Members, a broken source is an error even when another answered: the missing source may be the one whose gidNumber should have won.

A person's attributes come with the person, from the source that owns the name — a Set does not assemble somebody out of two sources.

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 naming a database or an LDAP server, and user/group blocks holding the people themselves golang.org/x/crypto/ssh, to parse an authorized_keys line; hashicorp/hcl/v2 and zclconf/go-cty, to rewrite a file in place (SetPassword)
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, null, nt_hash, ssh_keys from staff"   # by position: name, password, nt_hash, ssh_keys, totp_secret
  groups   = "select team, member from team_members"

  # optional, read by column ALIAS: the first column is the name
  users_attributes  = "select login, uid_number as \"uidNumber\", home as \"homeDirectory\", m.address as mail from staff left join mails m using (login)"
  groups_attributes = "select team, gid as \"gidNumber\" from teams"
}

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"
  attributes         = ["mail", "uidNumber", "homeDirectory"]   # copied from each person's entry
  group_attributes   = ["gidNumber"]                            # and from each group's
}

user "alice" {
  password_file = "/etc/authnd/alice.pw"
  attributes = {
    uidNumber     = "1000"                  # a string: LDAP values are strings
    homeDirectory = "/home/alice"
    mail          = ["alice@example.org", "a.liddell@example.org"]
    "description;lang-fr" = "une personne"
  }
}

group "staff" {
  members    = ["alice", "bob"]
  attributes = { gidNumber = "100", description = "the people who work here" }   # a posixGroup needs a gidNumber
}

The SQL attributes queries (sqldir.Queries.PeopleAttributes and GroupAttributes) are optional, and the positional users and groups queries are unchanged — a sixth positional column is still refused, because attributes come by name, in their own query. Every column after the first is an attribute named by its alias; a column whose alias is not an attribute description (an unaliased expression) or names a credential is refused, with its column number, before a row is read. NULL and '' are absent. A name may come back on several rows, and the values of all of them are kept — a join to a table of addresses gives a person several. A group named only in groups_attributes exists, with nobody in it: that is how a person's own primary group is written, and Members then answers [].

⛔ The case of an alias is the database's. PostgreSQL folds an unquoted alias to lower case: as uidNumber comes back as uidnumber. Attribute names are case-insensitive, so it is the same attribute and Get("uidNumber") finds it — but the spelling a server prints is the database's. Quote the alias (as "uidNumber") where the spelling matters. SQLite keeps an alias as written.

The attributes map in user and group blocks takes a string or a list of strings per name; null leaves an attribute out. A number is refused with the fix in the message (write it as a string, "1000"): this package does not choose how a number is spelled. The decoder hands it over as a cty.Value.

ldapdir copies the names listed in attributes / group_attributes (ldapdir.Config.Attributes, GroupAttributes), matching the directory's spelling by RFC 4512 equivalence and keeping the configured one. It refuses, at New and in Block.Check, anything CheckAttributeName refuses, and also the totp_attribute (a secret under a name only this configuration knows), the group member attribute and the group naming attribute. Subtypes the directory returns besides (description;lang-fr for description) are not copied: ask for them by name.

src, err := hcldir.Open(block)      // or hcldir.OpenAll(blocks)

hcldir does not decode: the struct tags are inert strings, so the caller decodes — with gohcl, or by hand — and hands the block over. It does import HCL, since v0.10.0, but only to write: SetPassword edits the file that declares a person with hclwrite, which keeps its comments, ordering and spacing. 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.

Who an LDAP directory's people are: the defaults
setting (users "ldap" / ldapdir.Config) default
user_filter / UserFilter (|(objectClass=posixAccount)(objectClass=account)(objectClass=uidObject)) — ldapdir.DefaultUserFilter
user_attribute / UserAttribute uid
group_base_dn / GroupBaseDN base_dn
group_filter / GroupFilter (|(objectClass=posixGroup)(objectClass=groupOfNames)) — ldapdir.DefaultGroupFilter
group_attribute / GroupAttribute cn
group_member_attribute / MemberAttribute none: memberUid (names) and member (DNs); set, it is the one attribute read

The three classes are the standard ones that carry a uid: posixAccount (RFC 2307, a person with a uidNumber, gidNumber and home directory), account (RFC 4524 §3.1) and uidObject (RFC 4519 §3.14). go-authn/authnd publishes a person without the POSIX attributes as an account or an inetOrgPerson, and puts uidObject on every person; under the old default such a person was not read at all.

⛔ The default user filter is WIDER than it was up to v0.13.0, which was (objectClass=posixAccount). A directory holding account or uidObject entries that are not posixAccounts — service accounts, hosts named by uid, people imported from elsewhere — now gives them to every program that kept the default, and those people can then be checked against the directory by a bind. A plain inetOrgPerson with none of the three classes is still not read (TestTheDefaultUserFilter). To keep exactly the old set, write the old filter:

users "ldap" {
  # ...
  user_filter = "(objectClass=posixAccount)"
}

⛔ The default group filter is WIDER than it was up to v0.13.0, which was (objectClass=posixGroup) with members in memberUid. A groupOfNames is now a group too, its members read from member. go-authn/authnd publishes a group without a gidNumber that way, since RFC 2307 makes gidNumber MUST in posixGroup, and under the old default such a group was "no such group" and an @group rule naming it granted nothing.

This adds no person. A member value is a DN, and it maps to somebody only when it is, by distinguishedNameMatch (RFC 4517 §4.2.15), the DN of an entry the people search returned. A DN outside those entries, or naming nobody, maps to nobody, and so does uid=alice,ou=elsewhere,… when alice was imported from ou=people. The value is never read out of the DN. What does change is that an institution's groupOfNames groups become groups here, and an @group rule naming one now grants what it says (TestTheGroupFilterAddsNoIdentity, TestAGroupOfNamesByDefault). To keep exactly the old groups:

users "ldap" {
  # ...
  group_filter           = "(objectClass=posixGroup)"
  group_member_attribute = "memberUid"
}

A configured group_member_attribute is the one attribute read, as before. A value of it that is a DN (always, for member and uniqueMember) goes through the same mapping. Up to v0.13.0 a DN was reduced to the value of its first RDN, whoever's it was.

An LDAP directory is reached over TLS

Every password checked against an LDAP directory is a simple bind, and a simple bind carries the password itself (RFC 4513 §5.1.3, §6.3.3). So a users "ldap" block — and ldapdir.New — refuses ldap:// to another machine unless start_tls = true. ldaps:// is accepted, and so are ldap:// to a loopback address (127.0.0.0/8, ::1, localhost) and ldapi://, where nobody is on the way. There is no switch to turn this off.

⛔ This refuses configuration that v0.9.0 accepted. A block that reached a remote directory over plain ldap:// now fails at Check, before anything connects; add start_tls = true or move to ldaps://.

A password change keeps the file's mode

hcldir.SetPassword rewrites the file that declares the person, through a temporary file and a rename. The new file keeps the mode the old one had (a password_file is capped at 0600), and on Unix its owner and group as far as the process may; when the group cannot be kept, the group bits are dropped. Up to v0.9.0 the configuration came back 0644 whatever it had been.

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. Person.Extra and Directory.GroupExtra add any other attribute to a person's or a group's entry, and a search gets back only the attributes it asked for (RFC 4511 §4.5.1.8: none or * is all, 1.1 is none) — so a client that forgets to ask for an attribute fails its test, as it would fail against a real directory.

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 by Expand rather than expanded to nothing, with ErrEmptyGroup: a configuration that grants nothing to nobody reads exactly like one that works. Members itself answers such a group with an empty list, since the group exists (before v0.12.0 it said ErrNoSuchGroup). A group no source has is ErrNoSuchGroup, not an empty list — 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. The attributes queries were also run once by hand against PostgreSQL 18.6 through pgx (not in CI): an unquoted alias came back lower-cased and was found anyway, a quoted one kept its case, and an alias naming userPassword was refused.
  • 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 ErrAttributeName = errors.New("directory: not an attribute this package carries")

ErrAttributeName is a name that cannot be an attribute here: not an RFC 4512 attribute description, or one this package refuses to carry. The message says which, and why.

View Source
var ErrAttributeValue = errors.New("directory: not a value an attribute can hold")

ErrAttributeValue is an attribute with no value, or an empty one.

View Source
var ErrEmptyGroup = errors.New("directory: the group has no members")

ErrEmptyGroup is what Expand returns for a group a source HAS, with nobody in it: not ErrNoSuchGroup, because the remedy differs -- a typo or a deleted group on one side, a group to fill on the other. A caller that tolerates a group that grants nothing tests for both.

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 CheckAttributeName added in v0.13.0

func CheckAttributeName(name string) error

CheckAttributeName says whether name can be an attribute: nil, or an error wrapping ErrAttributeName that says why not.

It is exported so that a configuration can be refused before anything is read -- ldapdir checks the attributes it is told to copy when it is built, rather than at the first search.

A name is an attribute description (RFC 4512 §2.5), restricted to the descriptor form:

attributedescription = descr *( ";" option )
descr  = ALPHA *( ALPHA / DIGIT / "-" )      ; §1.4 keystring
option = 1*( ALPHA / DIGIT / "-" )

The numeric form RFC 4512 also allows (2.5.4.35) is refused, because the refusals below are by NAME: 2.5.4.35 is userPassword, and a list of names cannot recognise it. Every attribute a server would publish has a descriptor.

Then, whatever the case and whatever the options, it refuses:

  • anything holding a secret: a password, a hash of one, a key, a one-time code secret, a trust secret -- the list is in ReservedAttributes, and any name containing password, passwd, pwd, secret, credential, hash or privatekey is refused with it, because a list of names only knows the schemas somebody read;
  • sshPublicKey, which has its own path through WithPublicKeys, so that Identity.Can keeps telling the truth about who can use SSH;
  • uid, cn and objectClass (and their aliases userid and commonName), which a server derives from the name it publishes an entry under;
  • member, uniqueMember, memberUid and memberOf, which are membership -- Source.Members answers that, and a second answer would disagree;
  • the operational attributes a server maintains (RFC 4512 §3.4, entryDN, entryUUID), which a configuration could only forge.

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.

func ReservedAttributes added in v0.13.0

func ReservedAttributes() map[string]string

ReservedAttributes are the names CheckAttributeName refuses outright, whatever their case, each with the reason it gives. Names containing a secret-looking fragment are refused besides, without being listed.

It returns a copy: the list is what this package enforces, and changing a copy changes nothing.

Types

type Attribute added in v0.13.0

type Attribute struct {
	Name   string
	Values []string
}

An Attribute is one name and its values, as given to NewAttributes.

type Attributes added in v0.13.0

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

Attributes are what a source says about somebody, or about a group, that is not proof of anything: a uidNumber, a home directory, a mail address, a display name.

They are kept apart from credentials on purpose. A credential decides who may log in and is guarded accordingly -- Identity.NTKey and Identity.TOTPSecret say so in their own documentation. An attribute is published: a server turns it into an LDAP entry that anybody allowed to search may read. So the one thing this type will not hold is a secret, and CheckAttributeName is where that is refused.

The names are LDAP attribute descriptions (RFC 4512 §2.5): a descriptor and optional options, such as "mail" or "description;lang-fr". They are case-insensitive, as LDAP's are, and the order of the options does not matter. Values are strings and are not interpreted -- "1000" is a uidNumber because a server's schema says so, not because this package parsed it.

An Attributes is immutable once built, so one can be shared and copied freely. The zero value holds nothing.

func NewAttributes added in v0.13.0

func NewAttributes(attrs ...Attribute) (Attributes, error)

NewAttributes checks these attributes and puts them together.

The order is kept. A name given twice -- or twice in different case, or with its options in a different order -- is one attribute, spelled the first way it was written, holding the values of both. A value repeated exactly is kept once: an LDAP attribute may not hold two equal values (RFC 4512 §2.2), and an exact repeat is equal under every matching rule there is. Values that are equal only under a schema's rule ("A" and "a" under caseIgnoreMatch) are kept, because this package does not know the schema.

Refused, with an error naming the attribute:

  • a name that is not an RFC 4512 attribute description, or that is an OID rather than a descriptor (see CheckAttributeName);
  • a name that holds a credential, or that a server derives itself;
  • an attribute with no values, or with an empty one: an LDAP attribute has at least one value, and nearly every syntax refuses an empty string.

func (Attributes) All added in v0.13.0

func (a Attributes) All() iter.Seq2[string, []string]

All is every attribute, in order, with a copy of its values.

func (Attributes) Get added in v0.13.0

func (a Attributes) Get(name string) []string

Get is the values of one attribute, found whatever the case of name and the order of its options, or nil when there is no such attribute.

A name that could never be an attribute here -- a refused one, or one that is not an attribute description -- is simply not found: asking is not configuring, and the question has an answer.

func (Attributes) Len added in v0.13.0

func (a Attributes) Len() int

Len is how many attributes there are.

func (Attributes) Names added in v0.13.0

func (a Attributes) Names() []string

Names are the attributes, in order, each spelled the first way it was given.

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 GroupAttributer added in v0.13.0

type GroupAttributer interface {
	GroupAttributes(group string) (Attributes, error)
}

A GroupAttributer is a source that can say what a group IS beyond who is in it: a gidNumber, a description.

Optional for the reason GroupLister is: a source that implements nothing new still compiles and still answers Members, and a Set asks it only whether it has the group.

It answers like Members: ErrNoSuchGroup for a group the source does not have, and an empty Attributes, with no error, for one it has and says nothing about.

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) Attributes added in v0.13.0

func (i *Identity) Attributes() Attributes

Attributes is what the source said about this person beyond their name and credentials. They describe and prove nothing, and none of them is a secret: see CheckAttributeName.

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) Derive added in v0.6.0

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

Derive runs a caller's key derivation over this identity's password and returns only the result.

This is what KerberosKey always was: its body never mentioned Kerberos, and the shape -- hand the secret to a function the caller owns, keep the secret here -- is the one every protocol needs that must POSSESS the password rather than merely check it.

⛔ THE NAME MATTERED. go-fileshare needs exactly this to verify an AWS SigV4 signature, whose signing key is

HMAC(HMAC(HMAC(HMAC("AWS4"+secret, date), region), service), "aws4_request")

-- a derivation, and nothing to do with Kerberos. Calling KerberosKey to build an S3 key would have read as a mistake to everybody who met it later, and the protocol after that would have added a third name for one function. KerberosKey stays and delegates, because it is published and its own name is right at its own call sites.

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 these protocols at all: they need the secret itself, and "is this the right password" does not produce one. That is a property of the protocols, and a server should say so at configuration time rather than at the first request.

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. Of the SHA-256 digests, not the strings: subtle.ConstantTimeCompare returns at once when the lengths differ, which tells a caller how long the password is.

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 WithAttributes added in v0.13.0

func WithAttributes(a Attributes) Option

WithAttributes says what is known about this person that proves nothing: a uidNumber, a home directory, a mail address. Given twice, the second set is added to the first, by the rules of NewAttributes.

It takes an Attributes rather than names and values because an Option cannot fail, and a name that holds a secret must: NewAttributes is where it is refused, so an Attributes that exists has already been checked.

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) GroupAttributes added in v0.13.0

func (s *Set) GroupAttributes(group string) (Attributes, error)

GroupAttributes asks every source about one group and puts the answers together, one attribute at a time: the first source to give an attribute owns it, so a gidNumber written in a local file overrides the directory's, and a description only the directory has still comes through. Values are never merged across sources -- two sources giving a group two gidNumbers is a disagreement, and taking the union would publish both.

A source that cannot describe groups (no GroupAttributer) is asked Members instead, to learn whether it HAS the group: a group that exists only there is answered with no attributes, not with ErrNoSuchGroup.

⛔ Unlike Members, a broken source is an error even when another answered. Members can return the half it has, because more people is the only thing the missing half could add. Here the missing source may be the one whose gidNumber should have WON, and answering without it would publish a different group id -- which decides who may read which files -- with nothing saying so.

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

	// GroupAttrs describe the groups in Groups. A group is one of this
	// source's because it is in Groups: an entry here for a group that is not
	// there describes nothing, and is answered as no such group.
	GroupAttrs map[string]Attributes
}

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) GroupAttributes added in v0.13.0

func (s *Static) GroupAttributes(group string) (Attributes, error)

GroupAttributes is what this list says about a group.

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