diff --git a/README.md b/README.md index db1f108..25d53ac 100644 --- a/README.md +++ b/README.md @@ -302,6 +302,32 @@ Two independent layers, since neither can catch everything alone: substitute for actually running TLS; a network attacker who controls the connection can also tamper with the JavaScript itself. +## Samba integration + +A Samba file server can use DeclarativeAuth as its account backend +(`passdb backend = ldapsam:ldap://...`) via LDAP, so file share ACLs come +from the same declarative users/groups everything else does. This is +opt-in and off by default -- set `DECLARATIVEAUTH_LDAP_SAMBA_READERS_GROUP` +to a group name, plus `DECLARATIVEAUTH_LDAP_SAMBA_DOMAIN_SID` (generate once +with `net getlocalsid` on the Samba host, then never change it) and +`DECLARATIVEAUTH_LDAP_SAMBA_DOMAIN_NAME` (Samba's `workgroup`). See +`examples/.env.example`. + +Two things worth understanding before turning this on: + +- **A second, weaker credential is involved.** NTLM (what SMB actually + speaks on the wire) requires the server to hold an NT hash -- MD4 of the + password, unsalted -- not a check against Argon2id. DeclarativeAuth + computes and stores this alongside the Argon2id hash, but it's real + password-equivalent material if leaked, unlike Argon2id. +- **Read access to it is gated, not just present.** Only an LDAP bind + authenticated as a member of `DECLARATIVEAUTH_LDAP_SAMBA_READERS_GROUP` + can access `sambaNTPassword`, `sambaSID`, or `sambaAcctFlags`. + +Known limitation: A declaratively-hashed account +(`passwordHash`/`passwordHashFile`) can never get an NT hash, so such accounts +(typically service accounts) won't be able to authenticate to Samba shares. + ## Development See [CONTRIBUTING.md](CONTRIBUTING.md) for local dev setup (a WSL/Docker dev diff --git a/cmd/declarativeauth/cmd_admin.go b/cmd/declarativeauth/cmd_admin.go index e5b699c..1bdd2fa 100644 --- a/cmd/declarativeauth/cmd_admin.go +++ b/cmd/declarativeauth/cmd_admin.go @@ -68,7 +68,7 @@ func runAdminSetPassword(args []string) error { } creds := &store.CredentialStore{Pool: pool} - if err := creds.Upsert(ctx, *username, encoded); err != nil { + if err := creds.Upsert(ctx, *username, encoded, auth.NTHash(*password)); err != nil { return err } fmt.Printf("password set for %s\n", *username) diff --git a/examples/.env.example b/examples/.env.example index cd560a5..9d367b6 100644 --- a/examples/.env.example +++ b/examples/.env.example @@ -46,6 +46,24 @@ DECLARATIVEAUTH_LDAP_SECURE_LISTEN_ADDR=0.0.0.0:636 # DECLARATIVEAUTH_LDAP_TLS_CERT_FILE= # DECLARATIVEAUTH_LDAP_TLS_KEY_FILE= +# ---- LDAP: Samba integration ------------------------------------------- +# Default: empty, Samba integration: When set, this reference a group. +# Only its mambers can access the samba-related sensitive informations. +# Allows setting the Samba auth source as `passdb backend = ldapsam`. +# See README's Samba integration section. Requires the two variables below +# to be set as well. +# DECLARATIVEAUTH_LDAP_SAMBA_READERS_GROUP=samba-readers + +# Required if the above is set: the Samba domain SID, generated once by the +# operator (e.g. `net getlocalsid` on the Samba host). Never generate or +# change this after Samba clients depend on it, that invalidates every +# SID-based permission they hold. +# DECLARATIVEAUTH_LDAP_SAMBA_DOMAIN_SID=S-1-5-21-1234567890-1234567890-1234567890 + +# Required if DECLARATIVEAUTH_LDAP_SAMBA_READERS_GROUP is set: the NetBIOS +# workgroup/domain name declared in Samba's smb.conf ("workgroup = ..."). +# DECLARATIVEAUTH_LDAP_SAMBA_DOMAIN_NAME=WORKGROUP + # ---- OIDC / web ---------------------------------------------------------- # (required if OIDC enabled) Must exactly match the externally-visible base URL # clients use; it's embedded in issued tokens and the discovery document. diff --git a/internal/auth/authenticate.go b/internal/auth/authenticate.go index 7fa8dd0..ed74fc3 100644 --- a/internal/auth/authenticate.go +++ b/internal/auth/authenticate.go @@ -96,8 +96,10 @@ func (a *Authenticator) Authenticate(ctx context.Context, identifier, password, // never have had any effect anyway. storedHash := user.PasswordHash mustReset := false + var cred *store.Credential if storedHash == "" { - cred, err := a.Credentials.Get(ctx, username) + var err error + cred, err = a.Credentials.Get(ctx, username) if err != nil { if errors.Is(err, store.ErrNotFound) { _, _ = a.Hasher.Verify(password, a.Hasher.Dummy()) @@ -119,6 +121,18 @@ func (a *Authenticator) Authenticate(ctx context.Context, identifier, password, return nil, &AuthError{Reason: ReasonBadPassword} } + // Backfill the Samba NT hash for a Postgres-backed credential that + // predates it (or was never touched by a set-password/reset since): + // this is the only remaining point a plaintext password is available + // for an existing account without forcing a reset. A no-op query once + // nt_hash is set. Declaratively-hashed accounts (cred == nil here) + // never get one -- there is no plaintext to derive it from. + if cred != nil && cred.NTHash == "" { + if err := a.Credentials.SetNTHashIfMissing(ctx, username, NTHash(password)); err != nil && a.Logger != nil { + a.Logger.Error("failed to backfill samba NT hash", "component", "auth", "username", username, "error", err) + } + } + if a.RateLimiter != nil { if err := a.RateLimiter.RecordSuccess(ctx, username, sourceIP); err != nil { return nil, err diff --git a/internal/auth/ntlm.go b/internal/auth/ntlm.go new file mode 100644 index 0000000..5b91536 --- /dev/null +++ b/internal/auth/ntlm.go @@ -0,0 +1,33 @@ +package auth + +import ( + "encoding/binary" + "encoding/hex" + "strings" + "unicode/utf16" + + //lint:ignore SA1019 required for Samba/NTLM interop -- see NTHash's doc comment below. + "golang.org/x/crypto/md4" +) + +// NTHash returns the Samba/NTLM "NT hash" of password: MD4 over its +// UTF-16LE encoding, hex-encoded uppercase -- the exact value Samba's +// ldapsam passdb backend expects in the sambaNTPassword attribute to +// compute NTLM challenge-responses itself. +// +// This is deliberately NOT a secure password hash: unsalted MD4 is fast to +// brute-force offline if leaked, nowhere near Argon2id. It exists only +// because the NTLM protocol requires the verifier to hold this exact +// derived value -- there is no way to make Samba/SMB authentication work +// without it. See internal/ldapserver's samba-readers-group gating for how +// read access to it is restricted to a trusted Samba service bind. +func NTHash(password string) string { + u16 := utf16.Encode([]rune(password)) + b := make([]byte, len(u16)*2) + for i, r := range u16 { + binary.LittleEndian.PutUint16(b[i*2:], r) + } + h := md4.New() + h.Write(b) + return strings.ToUpper(hex.EncodeToString(h.Sum(nil))) +} diff --git a/internal/auth/ntlm_test.go b/internal/auth/ntlm_test.go new file mode 100644 index 0000000..f72eced --- /dev/null +++ b/internal/auth/ntlm_test.go @@ -0,0 +1,45 @@ +package auth + +import "testing" + +// Known-answer tests: these NT hash values are widely published (e.g. in +// hashcat/John the Ripper example hash sets), so a wrong implementation is +// caught here rather than the first time a real Samba deployment rejects a +// login. +func TestNTHash_KnownVectors(t *testing.T) { + cases := []struct { + password string + want string + }{ + {"password", "8846F7EAEE8FB117AD06BDD830B7586C"}, + {"", "31D6CFE0D16AE931B73C59D7E0C089C0"}, + } + for _, tc := range cases { + if got := NTHash(tc.password); got != tc.want { + t.Errorf("NTHash(%q) = %q, want %q", tc.password, got, tc.want) + } + } +} + +func TestNTHash_DeterministicAndCaseSensitiveToInput(t *testing.T) { + a := NTHash("Secret123!") + b := NTHash("Secret123!") + if a != b { + t.Fatalf("expected NTHash to be deterministic (no salt), got %q then %q", a, b) + } + if NTHash("Secret123!") == NTHash("secret123!") { + t.Fatal("expected differently-cased passwords to hash differently") + } +} + +func TestNTHash_AlwaysUppercaseHex(t *testing.T) { + got := NTHash("whatever") + for _, r := range got { + if r >= 'a' && r <= 'z' { + t.Fatalf("expected uppercase hex output, got %q", got) + } + } + if len(got) != 32 { + t.Fatalf("expected a 32-character hex MD4 digest, got %d chars: %q", len(got), got) + } +} diff --git a/internal/config/env.go b/internal/config/env.go index 126b9e9..9beea79 100644 --- a/internal/config/env.go +++ b/internal/config/env.go @@ -4,11 +4,17 @@ import ( "fmt" "net/url" "os" + "regexp" "strconv" "strings" "time" ) +// sambaDomainSIDPattern matches a Windows/Samba domain SID: "S-1-5-21-" +// followed by exactly three sub-authority numbers identifying the domain +// (a *domain* SID has no trailing RID -- that's appended per-user). +var sambaDomainSIDPattern = regexp.MustCompile(`^S-1-5-21-\d+-\d+-\d+$`) + // Environment variable names for every ServerConfig field. There is no // server config FILE -- see .env.example at the repo root for the same // list with defaults, descriptions, and required/optional status. @@ -35,6 +41,26 @@ const ( // Search are rejected over a non-TLS connection, without disabling the // plaintext listener itself -- StartTLS still upgrades it in place. EnvLDAPRequireTLS = "DECLARATIVEAUTH_LDAP_REQUIRE_TLS" + // EnvLDAPSambaReadersGroup, unset by default, opts into exposing the + // sambaSamAccount attributes (sambaSID, sambaNTPassword, sambaAcctFlags) + // needed for a Samba server's `passdb backend = ldapsam` -- and only to + // a bind authenticated as a member of the named declarative group, never + // to anonymous/unprivileged binds or via a wildcard attribute request. + // Requires EnvLDAPSambaDomainSID and EnvLDAPSambaDomainName to also be + // set. See README's Samba integration section. + EnvLDAPSambaReadersGroup = "DECLARATIVEAUTH_LDAP_SAMBA_READERS_GROUP" + // EnvLDAPSambaDomainSID is the Samba domain SID (e.g. + // "S-1-5-21-1234567890-1234567890-1234567890"), generated once by the + // operator (e.g. `net getlocalsid` on the Samba host) and set here -- + // never generated or changed by DeclarativeAuth itself, since changing + // it after Samba clients rely on it invalidates every SID-based + // permission they hold. + EnvLDAPSambaDomainSID = "DECLARATIVEAUTH_LDAP_SAMBA_DOMAIN_SID" + // EnvLDAPSambaDomainName is the NetBIOS workgroup/domain name Samba's + // smb.conf declares (`workgroup = ...`), advertised in the synthetic + // sambaDomain LDAP entry so smbd can find it without ever needing to + // write one itself (identity stays read-only via LDAP). + EnvLDAPSambaDomainName = "DECLARATIVEAUTH_LDAP_SAMBA_DOMAIN_NAME" // EnvOIDCListenAddr and EnvOIDCSecureListenAddr are the same independent // plaintext/secure split as the LDAP pair above. @@ -129,9 +155,12 @@ func LoadServerConfigFromEnv() (*ServerConfig, error) { DSN: os.Getenv(EnvDatabaseDSN), }, LDAP: LDAPConfig{ - ListenAddr: os.Getenv(EnvLDAPListenAddr), - SecureListenAddr: os.Getenv(EnvLDAPSecureListenAddr), - BaseDN: os.Getenv(EnvLDAPBaseDN), + ListenAddr: os.Getenv(EnvLDAPListenAddr), + SecureListenAddr: os.Getenv(EnvLDAPSecureListenAddr), + BaseDN: os.Getenv(EnvLDAPBaseDN), + SambaReadersGroup: os.Getenv(EnvLDAPSambaReadersGroup), + SambaDomainSID: os.Getenv(EnvLDAPSambaDomainSID), + SambaDomainName: os.Getenv(EnvLDAPSambaDomainName), TLS: TLSListenerConfig{ CertFile: os.Getenv(EnvLDAPTLSCertFile), KeyFile: os.Getenv(EnvLDAPTLSKeyFile), @@ -188,6 +217,14 @@ func LoadServerConfigFromEnv() (*ServerConfig, error) { if cfg.LDAP.RequireTLS, err = getenvBool(EnvLDAPRequireTLS, true); err != nil { return nil, err } + if cfg.LDAP.SambaReadersGroup != "" { + if cfg.LDAP.SambaDomainSID == "" || cfg.LDAP.SambaDomainName == "" { + return nil, fmt.Errorf("%s requires both %s and %s to be set", EnvLDAPSambaReadersGroup, EnvLDAPSambaDomainSID, EnvLDAPSambaDomainName) + } + if !sambaDomainSIDPattern.MatchString(cfg.LDAP.SambaDomainSID) { + return nil, fmt.Errorf("%s: %q is not a domain SID of the form S-1-5-21-x-x-x", EnvLDAPSambaDomainSID, cfg.LDAP.SambaDomainSID) + } + } if cfg.OIDC.SigningAlg != "ES256" && cfg.OIDC.SigningAlg != "RS256" { return nil, fmt.Errorf("%s: unsupported signing algorithm %q (must be ES256 or RS256)", EnvOIDCSigningAlg, cfg.OIDC.SigningAlg) } diff --git a/internal/config/env_test.go b/internal/config/env_test.go index c4d5af8..9966be0 100644 --- a/internal/config/env_test.go +++ b/internal/config/env_test.go @@ -195,3 +195,86 @@ func TestLoadServerConfigFromEnv_InvalidValues(t *testing.T) { }) } } + +func TestLoadServerConfigFromEnv_SambaReadersGroup_Disabled(t *testing.T) { + cfg, err := LoadServerConfigFromEnv() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if cfg.LDAP.SambaReadersGroup != "" || cfg.LDAP.SambaDomainSID != "" || cfg.LDAP.SambaDomainName != "" { + t.Errorf("expected Samba integration unset by default: %+v", cfg.LDAP) + } +} + +func TestLoadServerConfigFromEnv_SambaReadersGroup_RequiresDomainSIDAndName(t *testing.T) { + cases := []struct { + name string + env map[string]string + }{ + {"missing both", map[string]string{EnvLDAPSambaReadersGroup: "samba-readers"}}, + {"missing domain name", map[string]string{ + EnvLDAPSambaReadersGroup: "samba-readers", + EnvLDAPSambaDomainSID: "S-1-5-21-1-2-3", + }}, + {"missing domain SID", map[string]string{ + EnvLDAPSambaReadersGroup: "samba-readers", + EnvLDAPSambaDomainName: "WORKGROUP", + }}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + for k, v := range tc.env { + t.Setenv(k, v) + } + if _, err := LoadServerConfigFromEnv(); err == nil { + t.Error("expected an error when the samba-readers group is set without both companion variables") + } + }) + } +} + +func TestLoadServerConfigFromEnv_SambaDomainSID_MustLookLikeADomainSID(t *testing.T) { + cases := []struct { + name string + sid string + ok bool + }{ + {"valid domain SID", "S-1-5-21-1004336348-1177238915-682003330", true}, + {"missing a sub-authority", "S-1-5-21-1004336348-1177238915", false}, + {"has a trailing RID (not a domain SID)", "S-1-5-21-1004336348-1177238915-682003330-1000", false}, + {"not a SID at all", "not-a-sid", false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Setenv(EnvLDAPSambaReadersGroup, "samba-readers") + t.Setenv(EnvLDAPSambaDomainSID, tc.sid) + t.Setenv(EnvLDAPSambaDomainName, "WORKGROUP") + _, err := LoadServerConfigFromEnv() + if tc.ok && err != nil { + t.Errorf("expected %q to be accepted, got error: %v", tc.sid, err) + } + if !tc.ok && err == nil { + t.Errorf("expected %q to be rejected as an invalid domain SID", tc.sid) + } + }) + } +} + +func TestLoadServerConfigFromEnv_SambaReadersGroup_ValidConfigLoads(t *testing.T) { + t.Setenv(EnvLDAPSambaReadersGroup, "samba-readers") + t.Setenv(EnvLDAPSambaDomainSID, "S-1-5-21-1004336348-1177238915-682003330") + t.Setenv(EnvLDAPSambaDomainName, "WORKGROUP") + cfg, err := LoadServerConfigFromEnv() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if cfg.LDAP.SambaReadersGroup != "samba-readers" { + t.Errorf("unexpected samba readers group: %q", cfg.LDAP.SambaReadersGroup) + } + if cfg.LDAP.SambaDomainSID != "S-1-5-21-1004336348-1177238915-682003330" { + t.Errorf("unexpected samba domain SID: %q", cfg.LDAP.SambaDomainSID) + } + if cfg.LDAP.SambaDomainName != "WORKGROUP" { + t.Errorf("unexpected samba domain name: %q", cfg.LDAP.SambaDomainName) + } +} diff --git a/internal/config/types.go b/internal/config/types.go index d27fab8..7cc30d7 100644 --- a/internal/config/types.go +++ b/internal/config/types.go @@ -148,6 +148,14 @@ type LDAPConfig struct { // excepted) without touching whether the plaintext listener itself // runs -- see EnvLDAPRequireTLS. RequireTLS bool + // SambaReadersGroup, SambaDomainSID, SambaDomainName -- see + // EnvLDAPSambaReadersGroup/EnvLDAPSambaDomainSID/EnvLDAPSambaDomainName. + // SambaReadersGroup empty (the default) disables Samba integration + // entirely: no sambaSamAccount/sambaDomain data is ever built or + // exposed, regardless of what binds. + SambaReadersGroup string + SambaDomainSID string + SambaDomainName string } // OIDCConfig configures the OIDC/web listener(s), with the same independent diff --git a/internal/ldapserver/dn.go b/internal/ldapserver/dn.go index 4c9b329..dcd9c3c 100644 --- a/internal/ldapserver/dn.go +++ b/internal/ldapserver/dn.go @@ -19,6 +19,18 @@ type Config struct { // listener or the plaintext listener after a successful StartTLS. It // does not disable the plaintext listener itself. RequireTLS bool + // SambaReadersGroup, SambaDomainSID, SambaDomainName: empty + // SambaReadersGroup (the default) disables Samba integration entirely. + // See config.EnvLDAPSambaReadersGroup and friends. + SambaReadersGroup string + SambaDomainSID string + SambaDomainName string +} + +// SambaDomainDN returns the DN of the synthetic sambaDomain entry: +// sambaDomainName=,. +func SambaDomainDN(baseDN, domainName string) string { + return fmt.Sprintf("sambaDomainName=%s,%s", domainName, baseDN) } // UserDN returns the canonical DN for a username: uid=,ou=users,. diff --git a/internal/ldapserver/schema.go b/internal/ldapserver/schema.go index 07eb004..21532da 100644 --- a/internal/ldapserver/schema.go +++ b/internal/ldapserver/schema.go @@ -1,11 +1,21 @@ package ldapserver -import "declarativeauth/internal/identity" +import ( + "strings" + + "declarativeauth/internal/identity" +) // Attribute is a single LDAP attribute with its (possibly multi-valued) values. type Attribute struct { Name string Values []string + // Sensitive attributes (the Samba NT hash) are never returned to a "*" + // or empty (default) attribute selection, only when named explicitly -- + // see buildSearchResultEntry in search.go. This is on top of, not + // instead of, only building these attributes into the entry at all for + // a bind privileged via the samba-readers group in the first place. + Sensitive bool } // oidStartTLS is the LDAPOID for the StartTLS extended operation (RFC 4511 @@ -21,16 +31,33 @@ const oidPagedResults = "1.2.840.113556.1.4.319" // RootDSE's subschemaSubentry attribute. const SubschemaDN = "cn=subschema" +// SambaUserAttrs is a user's Samba-specific attributes (sambaSID derived +// from the configured domain SID + that user's assigned RID, sambaNTPassword +// from store.SambaCredential.NTHash), added to UserEntry's output only for +// a search privileged via the samba-readers group -- see handleSearch. +type SambaUserAttrs struct { + SID string + NTHash string + Enabled bool +} + // UserEntry renders a user's LDAP attributes, including the fully flattened // memberOf list read directly off the snapshot — O(1), no per-request graph -// traversal. -func UserEntry(baseDN string, u identity.User, flattenedGroups []string) []Attribute { +// traversal. samba, when non-nil, adds a sambaSamAccount auxiliary +// objectClass with sambaSID/sambaNTPassword/sambaAcctFlags -- the caller is +// responsible for only ever passing a non-nil value to an already-privileged +// requester (see internal/ldapserver.Config.SambaReadersGroup). +func UserEntry(baseDN string, u identity.User, flattenedGroups []string, samba *SambaUserAttrs) []Attribute { memberOf := make([]string, len(flattenedGroups)) for i, g := range flattenedGroups { memberOf[i] = GroupDN(baseDN, g) } - return []Attribute{ - {Name: "objectClass", Values: []string{"declarativeAuthUser", "inetOrgPerson"}}, + objectClasses := []string{"declarativeAuthUser", "inetOrgPerson"} + if samba != nil { + objectClasses = append(objectClasses, "sambaSamAccount") + } + attrs := []Attribute{ + {Name: "objectClass", Values: objectClasses}, {Name: "uid", Values: []string{u.Username}}, {Name: "cn", Values: []string{u.DisplayNameOrDefault()}}, {Name: "givenName", Values: nonEmpty(u.FirstName)}, @@ -38,6 +65,42 @@ func UserEntry(baseDN string, u identity.User, flattenedGroups []string) []Attri {Name: "mail", Values: nonEmpty(u.Email)}, {Name: "memberOf", Values: memberOf}, } + if samba != nil { + attrs = append(attrs, + Attribute{Name: "sambaSID", Values: []string{samba.SID}, Sensitive: true}, + Attribute{Name: "sambaAcctFlags", Values: []string{sambaAcctFlags(samba.Enabled)}, Sensitive: true}, + ) + if samba.NTHash != "" { + attrs = append(attrs, Attribute{Name: "sambaNTPassword", Values: []string{samba.NTHash}, Sensitive: true}) + } + } + return attrs +} + +// sambaAcctFlags renders Samba's fixed-width (11 characters between the +// brackets) account-control-block string, e.g. "[U ]" for an +// enabled normal user or "[UD ]" once disabled. +func sambaAcctFlags(enabled bool) string { + flags := "U" + if !enabled { + flags += "D" + } + return "[" + flags + strings.Repeat(" ", 11-len(flags)) + "]" +} + +// SambaDomainEntry renders the synthetic, read-only sambaDomain entry +// smbd looks up (by sambaDomainName, searching under the LDAP suffix) to +// learn the domain SID before it can compute or verify any user's sambaSID. +// Samba's ldapsam backend normally creates this entry itself on first run +// if missing -- identity is read-only via LDAP here, so it must already +// exist, sourced entirely from EnvLDAPSambaDomainSID/EnvLDAPSambaDomainName +// rather than ever being written by this server. +func SambaDomainEntry(domainName, domainSID string) []Attribute { + return []Attribute{ + {Name: "objectClass", Values: []string{"sambaDomain"}}, + {Name: "sambaDomainName", Values: []string{domainName}}, + {Name: "sambaSID", Values: []string{domainSID}}, + } } // GroupEntry renders a group's LDAP attributes, including "member" -- every @@ -121,6 +184,8 @@ func SubschemaEntry() []Attribute { `( 2.5.6.5 NAME 'organizationalUnit' SUP top STRUCTURAL MUST ou )`, `( 0.9.2342.19200300.100.4.13 NAME 'domain' SUP top STRUCTURAL MUST dc MAY o )`, `( 1.3.6.1.4.1.61313.1.1 NAME 'declarativeAuthUser' SUP top AUXILIARY MAY ( memberOf ) )`, + `( 1.3.6.1.4.1.7165.2.2.6 NAME 'sambaSamAccount' SUP top AUXILIARY MUST ( uid $ sambaSID ) MAY ( sambaNTPassword $ sambaAcctFlags $ sambaDomainName ) )`, + `( 1.3.6.1.4.1.7165.2.2.5 NAME 'sambaDomain' SUP top STRUCTURAL MUST ( sambaDomainName $ sambaSID ) )`, }}, {Name: "attributeTypes", Values: []string{ `( 2.5.4.0 NAME 'objectClass' EQUALITY objectIdentifierMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.38 )`, @@ -135,6 +200,10 @@ func SubschemaEntry() []Attribute { `( 2.5.4.10 NAME 'o' EQUALITY caseIgnoreMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.15 )`, `( 0.9.2342.19200300.100.1.25 NAME 'dc' EQUALITY caseIgnoreMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.15 )`, `( 2.5.4.11 NAME 'ou' EQUALITY caseIgnoreMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.15 )`, + `( 1.3.6.1.4.1.7165.2.1.20 NAME 'sambaSID' EQUALITY caseIgnoreIA5Match SYNTAX 1.3.6.1.4.1.1466.115.121.1.26 )`, + `( 1.3.6.1.4.1.7165.2.1.25 NAME 'sambaNTPassword' EQUALITY caseIgnoreIA5Match SYNTAX 1.3.6.1.4.1.1466.115.121.1.26 )`, + `( 1.3.6.1.4.1.7165.2.1.26 NAME 'sambaAcctFlags' EQUALITY caseIgnoreIA5Match SYNTAX 1.3.6.1.4.1.1466.115.121.1.26 )`, + `( 1.3.6.1.4.1.7165.2.1.38 NAME 'sambaDomainName' EQUALITY caseIgnoreMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.15 )`, }}, } } diff --git a/internal/ldapserver/schema_test.go b/internal/ldapserver/schema_test.go index 256ebf0..5c2a967 100644 --- a/internal/ldapserver/schema_test.go +++ b/internal/ldapserver/schema_test.go @@ -68,3 +68,81 @@ func TestOUEntry(t *testing.T) { t.Fatalf("expected ou [users], got %v", got) } } + +func TestUserEntry_NoSambaAttrsWhenNil(t *testing.T) { + attrs := UserEntry("dc=example,dc=com", identity.User{Username: "jsmith"}, nil, nil) + if got := attrValues(attrs, "sambaSID"); len(got) != 0 { + t.Fatalf("expected no sambaSID when samba is nil, got %v", got) + } + objectClass := attrValues(attrs, "objectClass") + for _, oc := range objectClass { + if oc == "sambaSamAccount" { + t.Fatalf("expected no sambaSamAccount objectClass when samba is nil, got %v", objectClass) + } + } +} + +func TestUserEntry_SambaAttrsPresentAndMarkedSensitive(t *testing.T) { + samba := &SambaUserAttrs{SID: "S-1-5-21-1-2-3-1000", NTHash: "8846F7EAEE8FB117AD06BDD830B7586C", Enabled: true} + attrs := UserEntry("dc=example,dc=com", identity.User{Username: "jsmith"}, nil, samba) + + objectClass := attrValues(attrs, "objectClass") + found := false + for _, oc := range objectClass { + if oc == "sambaSamAccount" { + found = true + } + } + if !found { + t.Fatalf("expected sambaSamAccount objectClass, got %v", objectClass) + } + if got := attrValues(attrs, "sambaSID"); len(got) != 1 || got[0] != samba.SID { + t.Fatalf("expected sambaSID [%s], got %v", samba.SID, got) + } + if got := attrValues(attrs, "sambaNTPassword"); len(got) != 1 || got[0] != samba.NTHash { + t.Fatalf("expected sambaNTPassword [%s], got %v", samba.NTHash, got) + } + for _, a := range attrs { + if a.Name == "sambaSID" || a.Name == "sambaNTPassword" || a.Name == "sambaAcctFlags" { + if !a.Sensitive { + t.Fatalf("expected %s to be marked Sensitive", a.Name) + } + } + } +} + +func TestUserEntry_NoNTPasswordAttrWhenHashNotYetComputed(t *testing.T) { + samba := &SambaUserAttrs{SID: "S-1-5-21-1-2-3-1000", NTHash: "", Enabled: true} + attrs := UserEntry("dc=example,dc=com", identity.User{Username: "jsmith"}, nil, samba) + if got := attrValues(attrs, "sambaNTPassword"); len(got) != 0 { + t.Fatalf("expected no sambaNTPassword attribute when NTHash hasn't been computed yet, got %v", got) + } +} + +func TestSambaAcctFlags(t *testing.T) { + if got := sambaAcctFlags(true); got != "[U ]" { + t.Fatalf("expected enabled flags %q, got %q", "[U ]", got) + } + if got := sambaAcctFlags(false); got != "[UD ]" { + t.Fatalf("expected disabled flags %q, got %q", "[UD ]", got) + } + for _, enabled := range []bool{true, false} { + got := sambaAcctFlags(enabled) + if len(got) != 13 { // "[" + 11 + "]" + t.Fatalf("expected fixed-width 13-char flags string, got %d chars: %q", len(got), got) + } + } +} + +func TestSambaDomainEntry(t *testing.T) { + attrs := SambaDomainEntry("WORKGROUP", "S-1-5-21-1-2-3") + if got := attrValues(attrs, "sambaDomainName"); len(got) != 1 || got[0] != "WORKGROUP" { + t.Fatalf("expected sambaDomainName [WORKGROUP], got %v", got) + } + if got := attrValues(attrs, "sambaSID"); len(got) != 1 || got[0] != "S-1-5-21-1-2-3" { + t.Fatalf("expected sambaSID [S-1-5-21-1-2-3], got %v", got) + } + if got := attrValues(attrs, "objectClass"); len(got) != 1 || got[0] != "sambaDomain" { + t.Fatalf("expected objectClass [sambaDomain], got %v", got) + } +} diff --git a/internal/ldapserver/search.go b/internal/ldapserver/search.go index 88a71a5..b757ab8 100644 --- a/internal/ldapserver/search.go +++ b/internal/ldapserver/search.go @@ -1,11 +1,15 @@ package ldapserver import ( + "context" + "fmt" "io" "sort" "strings" + "time" "declarativeauth/internal/identity" + "declarativeauth/internal/store" ber "github.com/go-asn1-ber/asn1-ber" "github.com/go-ldap/ldap/v3" @@ -46,6 +50,18 @@ func (sel attrSelection) includes(name string) bool { return sel.names[strings.ToLower(name)] } +// includesExplicit reports whether name was asked for by its exact name -- +// unlike includes, a "*"/empty (all) selection does NOT count. Used to gate +// Attribute.Sensitive values: a wildcard dump of "every attribute" must +// never surface the Samba NT hash, only a request that names it directly +// (which is how Samba's own ldapsam backend actually asks for it). +func (sel attrSelection) includesExplicit(name string) bool { + if sel.none { + return false + } + return sel.names[strings.ToLower(name)] +} + func parseAttrSelection(p *ber.Packet) attrSelection { if p == nil || len(p.Children) == 0 { return attrSelection{all: true} @@ -105,7 +121,27 @@ func (h *Handler) handleSearch(w io.Writer, isTLS bool, sourceIP, boundUser stri } snap := h.Snapshot() - entries := h.entriesFor(snap, baseObject, int(scope), startTLSAvailable) + + // Only a bind authenticated as a member of the configured + // samba-readers group -- never anonymous, never any other user -- ever + // sees sambaSamAccount/sambaDomain data. Fetched once per search + // (not once per matched entry) and passed down. + var sambaCreds map[string]store.SambaCredential + sambaPrivileged := h.Config.SambaReadersGroup != "" && boundUser != "" && snap.IsMemberOf(boundUser, h.Config.SambaReadersGroup) + if sambaPrivileged && h.Credentials != nil { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + sambaCreds, err = h.Credentials.AllSambaCredentials(ctx) + cancel() + if err != nil { + if h.Logger != nil { + h.Logger.Error("fetching samba credentials failed", "component", "ldapserver", "error", err) + } + writeResult(w, messageID, ldap.ApplicationSearchResultDone, ldap.LDAPResultOther, "", "internal error") + return + } + } + + entries := h.entriesFor(snap, baseObject, int(scope), startTLSAvailable, sambaPrivileged, sambaCreds) sort.Slice(entries, func(i, j int) bool { return entries[i].dn < entries[j].dn }) var matched []entry @@ -136,7 +172,7 @@ func (h *Handler) handleSearch(w io.Writer, isTLS bool, sourceIP, boundUser stri writeMessageWithControls(w, messageID, newLDAPResultPacket(ldap.ApplicationSearchResultDone, ldap.LDAPResultSuccess, "", ""), respControls) } -func (h *Handler) entriesFor(snap *identity.Snapshot, baseObject string, scope int, startTLSAvailable bool) []entry { +func (h *Handler) entriesFor(snap *identity.Snapshot, baseObject string, scope int, startTLSAvailable bool, sambaPrivileged bool, sambaCreds map[string]store.SambaCredential) []entry { baseDN := h.Config.BaseDN if baseObject == "" { @@ -145,13 +181,20 @@ func (h *Handler) entriesFor(snap *identity.Snapshot, baseObject string, scope i if dnEqualFold(baseObject, SubschemaDN) { return []entry{{dn: SubschemaDN, attrs: SubschemaEntry(), forceAllAttrs: true}} } + if sambaPrivileged && dnEqualFold(baseObject, SambaDomainDN(baseDN, h.Config.SambaDomainName)) { + if scope == scopeSingleLevel { + return nil // leaf: no children + } + return []entry{{dn: SambaDomainDN(baseDN, h.Config.SambaDomainName), attrs: SambaDomainEntry(h.Config.SambaDomainName, h.Config.SambaDomainSID)}} + } if username, ok := UsernameFromLeafDN(baseDN, baseObject); ok { if scope == scopeSingleLevel { return nil // a user entry is a leaf: no children } if u, exists := snap.Users[username]; exists { - return []entry{{dn: UserDN(baseDN, username), attrs: UserEntry(baseDN, u, snap.FlattenedMemberOf[username])}} + samba := h.sambaAttrsFor(username, u, sambaPrivileged, sambaCreds) + return []entry{{dn: UserDN(baseDN, username), attrs: UserEntry(baseDN, u, snap.FlattenedMemberOf[username], samba)}} } return nil } @@ -165,7 +208,7 @@ func (h *Handler) entriesFor(snap *identity.Snapshot, baseObject string, scope i return nil } if dnEqualFold(baseObject, UsersOUDN(baseDN)) { - return h.usersSubtree(snap, scope) + return h.usersSubtree(snap, scope, sambaPrivileged, sambaCreds) } if dnEqualFold(baseObject, GroupsOUDN(baseDN)) { return h.groupsSubtree(snap, scope) @@ -175,21 +218,28 @@ func (h *Handler) entriesFor(snap *identity.Snapshot, baseObject string, scope i case scopeBaseObject: return []entry{{dn: baseDN, attrs: BaseEntry(baseDN)}} case scopeSingleLevel: - return []entry{ + entries := []entry{ {dn: UsersOUDN(baseDN), attrs: OUEntry("users")}, {dn: GroupsOUDN(baseDN), attrs: OUEntry("groups")}, } + if sambaPrivileged { + entries = append(entries, entry{dn: SambaDomainDN(baseDN, h.Config.SambaDomainName), attrs: SambaDomainEntry(h.Config.SambaDomainName, h.Config.SambaDomainSID)}) + } + return entries case scopeWholeSubtree: entries := []entry{{dn: baseDN, attrs: BaseEntry(baseDN)}} - entries = append(entries, h.usersSubtree(snap, scopeWholeSubtree)...) + entries = append(entries, h.usersSubtree(snap, scopeWholeSubtree, sambaPrivileged, sambaCreds)...) entries = append(entries, h.groupsSubtree(snap, scopeWholeSubtree)...) + if sambaPrivileged { + entries = append(entries, entry{dn: SambaDomainDN(baseDN, h.Config.SambaDomainName), attrs: SambaDomainEntry(h.Config.SambaDomainName, h.Config.SambaDomainSID)}) + } return entries } } return nil } -func (h *Handler) usersSubtree(snap *identity.Snapshot, scope int) []entry { +func (h *Handler) usersSubtree(snap *identity.Snapshot, scope int, sambaPrivileged bool, sambaCreds map[string]store.SambaCredential) []entry { baseDN := h.Config.BaseDN var entries []entry if scope == scopeBaseObject || scope == scopeWholeSubtree { @@ -199,11 +249,32 @@ func (h *Handler) usersSubtree(snap *identity.Snapshot, scope int) []entry { return entries } for username, u := range snap.Users { - entries = append(entries, entry{dn: UserDN(baseDN, username), attrs: UserEntry(baseDN, u, snap.FlattenedMemberOf[username])}) + samba := h.sambaAttrsFor(username, u, sambaPrivileged, sambaCreds) + entries = append(entries, entry{dn: UserDN(baseDN, username), attrs: UserEntry(baseDN, u, snap.FlattenedMemberOf[username], samba)}) } return entries } +// sambaAttrsFor builds a user's Samba attributes for a privileged search, +// or nil if the search isn't privileged or no NT hash has been computed for +// this user yet (never logged in / never had a password set since the +// samba-readers-group feature was configured -- see +// auth.Authenticator.Authenticate's lazy backfill and store.CredentialStore.Upsert). +func (h *Handler) sambaAttrsFor(username string, u identity.User, sambaPrivileged bool, sambaCreds map[string]store.SambaCredential) *SambaUserAttrs { + if !sambaPrivileged { + return nil + } + cred, ok := sambaCreds[username] + if !ok { + return nil + } + return &SambaUserAttrs{ + SID: fmt.Sprintf("%s-%d", h.Config.SambaDomainSID, cred.RID), + NTHash: cred.NTHash, + Enabled: u.Enabled, + } +} + func (h *Handler) groupsSubtree(snap *identity.Snapshot, scope int) []entry { baseDN := h.Config.BaseDN var entries []entry @@ -228,7 +299,11 @@ func buildSearchResultEntry(e entry, sel attrSelection, typesOnly bool) *ber.Pac if len(a.Values) == 0 { continue } - if !e.forceAllAttrs && !sel.includes(a.Name) { + if a.Sensitive { + if !sel.includesExplicit(a.Name) { + continue + } + } else if !e.forceAllAttrs && !sel.includes(a.Name) { continue } pa := ber.Encode(ber.ClassUniversal, ber.TypeConstructed, ber.TagSequence, nil, "PartialAttribute") diff --git a/internal/ldapserver/search_test.go b/internal/ldapserver/search_test.go index c751b0c..1666ee6 100644 --- a/internal/ldapserver/search_test.go +++ b/internal/ldapserver/search_test.go @@ -1,9 +1,13 @@ package ldapserver import ( + "strings" "testing" "declarativeauth/internal/identity" + "declarativeauth/internal/store" + + ber "github.com/go-asn1-ber/asn1-ber" ) func testSnapshot() *identity.Snapshot { @@ -28,7 +32,7 @@ func testHandler() *Handler { func TestEntriesFor_RootDSE(t *testing.T) { h := testHandler() - entries := h.entriesFor(testSnapshot(), "", scopeBaseObject, true) + entries := h.entriesFor(testSnapshot(), "", scopeBaseObject, true, false, nil) if len(entries) != 1 || entries[0].dn != "" { t.Fatalf("expected a single unnamed RootDSE entry, got %v", entries) } @@ -39,7 +43,7 @@ func TestEntriesFor_RootDSE(t *testing.T) { func TestEntriesFor_Subschema(t *testing.T) { h := testHandler() - entries := h.entriesFor(testSnapshot(), SubschemaDN, scopeBaseObject, true) + entries := h.entriesFor(testSnapshot(), SubschemaDN, scopeBaseObject, true, false, nil) if len(entries) != 1 || entries[0].dn != SubschemaDN { t.Fatalf("expected the subschema entry, got %v", entries) } @@ -47,7 +51,7 @@ func TestEntriesFor_Subschema(t *testing.T) { func TestEntriesFor_BaseScope_ReturnsBaseEntryOnly(t *testing.T) { h := testHandler() - entries := h.entriesFor(testSnapshot(), "dc=example,dc=com", scopeBaseObject, true) + entries := h.entriesFor(testSnapshot(), "dc=example,dc=com", scopeBaseObject, true, false, nil) if len(entries) != 1 || entries[0].dn != "dc=example,dc=com" { t.Fatalf("expected only the base entry, got %v", entries) } @@ -55,7 +59,7 @@ func TestEntriesFor_BaseScope_ReturnsBaseEntryOnly(t *testing.T) { func TestEntriesFor_SingleLevel_ReturnsOUsOnly(t *testing.T) { h := testHandler() - entries := h.entriesFor(testSnapshot(), "dc=example,dc=com", scopeSingleLevel, true) + entries := h.entriesFor(testSnapshot(), "dc=example,dc=com", scopeSingleLevel, true, false, nil) if len(entries) != 2 { t.Fatalf("expected exactly the two OU containers, got %v", entries) } @@ -68,7 +72,7 @@ func TestEntriesFor_SingleLevel_ReturnsOUsOnly(t *testing.T) { func TestEntriesFor_WholeSubtree_ReturnsEverything(t *testing.T) { h := testHandler() - entries := h.entriesFor(testSnapshot(), "dc=example,dc=com", scopeWholeSubtree, true) + entries := h.entriesFor(testSnapshot(), "dc=example,dc=com", scopeWholeSubtree, true, false, nil) // base + ou=users + jsmith + ou=groups + engineering if len(entries) != 5 { t.Fatalf("expected 5 entries in the whole subtree, got %d: %v", len(entries), entries) @@ -77,7 +81,7 @@ func TestEntriesFor_WholeSubtree_ReturnsEverything(t *testing.T) { func TestEntriesFor_LeafUser_SingleLevelHasNoChildren(t *testing.T) { h := testHandler() - entries := h.entriesFor(testSnapshot(), "uid=jsmith,ou=users,dc=example,dc=com", scopeSingleLevel, true) + entries := h.entriesFor(testSnapshot(), "uid=jsmith,ou=users,dc=example,dc=com", scopeSingleLevel, true, false, nil) if len(entries) != 0 { t.Fatalf("a leaf user entry has no children, got %v", entries) } @@ -85,7 +89,7 @@ func TestEntriesFor_LeafUser_SingleLevelHasNoChildren(t *testing.T) { func TestEntriesFor_LeafUser_BaseScope(t *testing.T) { h := testHandler() - entries := h.entriesFor(testSnapshot(), "uid=jsmith,ou=users,dc=example,dc=com", scopeBaseObject, true) + entries := h.entriesFor(testSnapshot(), "uid=jsmith,ou=users,dc=example,dc=com", scopeBaseObject, true, false, nil) if len(entries) != 1 || entries[0].dn != "uid=jsmith,ou=users,dc=example,dc=com" { t.Fatalf("expected jsmith's entry, got %v", entries) } @@ -93,7 +97,7 @@ func TestEntriesFor_LeafUser_BaseScope(t *testing.T) { func TestEntriesFor_UnknownBase_ReturnsNothing(t *testing.T) { h := testHandler() - entries := h.entriesFor(testSnapshot(), "dc=nowhere,dc=com", scopeWholeSubtree, true) + entries := h.entriesFor(testSnapshot(), "dc=nowhere,dc=com", scopeWholeSubtree, true, false, nil) if len(entries) != 0 { t.Fatalf("expected no entries for an unrelated base, got %v", entries) } @@ -122,3 +126,167 @@ func TestAttrSelection_NamedIsCaseInsensitive(t *testing.T) { t.Fatal("expected an attribute not in the selection to be excluded") } } + +func TestAttrSelection_IncludesExplicit_WildcardDoesNotCount(t *testing.T) { + sel := attrSelection{all: true} + if sel.includesExplicit("sambaNTPassword") { + t.Fatal("expected a wildcard/default selection to NOT count as an explicit request for a sensitive attribute") + } +} + +func TestAttrSelection_IncludesExplicit_NamedAttributeCounts(t *testing.T) { + sel := attrSelection{names: map[string]bool{strings.ToLower("sambaNTPassword"): true}} + if !sel.includesExplicit("sambaNTPassword") { + t.Fatal("expected an explicitly-named attribute to count, case-insensitively") + } + if sel.includesExplicit("uid") { + t.Fatal("expected an attribute not in the selection to be excluded") + } +} + +func TestAttrSelection_IncludesExplicit_NoneExcludesEverything(t *testing.T) { + sel := attrSelection{none: true} + if sel.includesExplicit("sambaNTPassword") { + t.Fatal("expected the 1.1 (no attributes) selection to exclude even an explicitly-sensitive attribute") + } +} + +// resultEntryAttrNames extracts the attribute names actually encoded into a +// buildSearchResultEntry packet, walking the same PartialAttributeList +// structure search.go builds -- so these tests exercise the real wire +// encoding, not just the pre-encoding Attribute slice. +func resultEntryAttrNames(p *ber.Packet) []string { + if len(p.Children) < 2 { + return nil + } + var names []string + for _, pa := range p.Children[1].Children { + if len(pa.Children) == 0 { + continue + } + if name, ok := pa.Children[0].Value.(string); ok { + names = append(names, name) + } + } + return names +} + +func containsFold(names []string, want string) bool { + for _, n := range names { + if strings.EqualFold(n, want) { + return true + } + } + return false +} + +func TestBuildSearchResultEntry_SensitiveAttrHiddenFromWildcardSelection(t *testing.T) { + e := entry{dn: "uid=jsmith,ou=users,dc=example,dc=com", attrs: []Attribute{ + {Name: "uid", Values: []string{"jsmith"}}, + {Name: "sambaNTPassword", Values: []string{"8846F7EAEE8FB117AD06BDD830B7586C"}, Sensitive: true}, + }} + names := resultEntryAttrNames(buildSearchResultEntry(e, attrSelection{all: true}, false)) + if containsFold(names, "sambaNTPassword") { + t.Fatalf("expected sambaNTPassword to be excluded from a wildcard selection, got %v", names) + } + if !containsFold(names, "uid") { + t.Fatalf("expected uid to still be present, got %v", names) + } +} + +func TestBuildSearchResultEntry_SensitiveAttrShownWhenExplicitlyRequested(t *testing.T) { + e := entry{dn: "uid=jsmith,ou=users,dc=example,dc=com", attrs: []Attribute{ + {Name: "uid", Values: []string{"jsmith"}}, + {Name: "sambaNTPassword", Values: []string{"8846F7EAEE8FB117AD06BDD830B7586C"}, Sensitive: true}, + }} + sel := attrSelection{names: map[string]bool{"sambantpassword": true}} + names := resultEntryAttrNames(buildSearchResultEntry(e, sel, false)) + if !containsFold(names, "sambaNTPassword") { + t.Fatalf("expected sambaNTPassword to be present when explicitly requested, got %v", names) + } + if containsFold(names, "uid") { + t.Fatalf("expected uid to be excluded since it wasn't requested, got %v", names) + } +} + +func testSnapshotWithSambaReader() *identity.Snapshot { + users := map[string]identity.User{ + "jsmith": {Username: "jsmith", Email: "jsmith@example.com", Enabled: true}, + } + groups := map[string]identity.Group{ + "samba-readers": {Name: "samba-readers"}, + } + flattenedMemberOf := map[string][]string{"jsmith": {"samba-readers"}} + return &identity.Snapshot{ + Users: users, + Groups: groups, + FlattenedMemberOf: flattenedMemberOf, + FlattenedMembers: identity.ResolveFlattenedMembers(flattenedMemberOf), + } +} + +func testSambaHandler() *Handler { + return &Handler{Config: Config{ + BaseDN: "dc=example,dc=com", + SambaReadersGroup: "samba-readers", + SambaDomainSID: "S-1-5-21-1-2-3", + SambaDomainName: "WORKGROUP", + }} +} + +func TestEntriesFor_SambaPrivileged_AddsUserSambaAttrs(t *testing.T) { + h := testSambaHandler() + creds := map[string]store.SambaCredential{"jsmith": {NTHash: "8846F7EAEE8FB117AD06BDD830B7586C", RID: 1000}} + entries := h.entriesFor(testSnapshotWithSambaReader(), "uid=jsmith,ou=users,dc=example,dc=com", scopeBaseObject, true, true, creds) + if len(entries) != 1 { + t.Fatalf("expected jsmith's entry, got %v", entries) + } + if got := attrValues(entries[0].attrs, "sambaSID"); len(got) != 1 || got[0] != "S-1-5-21-1-2-3-1000" { + t.Fatalf("expected sambaSID [S-1-5-21-1-2-3-1000], got %v", got) + } + if got := attrValues(entries[0].attrs, "sambaNTPassword"); len(got) != 1 || got[0] != "8846F7EAEE8FB117AD06BDD830B7586C" { + t.Fatalf("expected sambaNTPassword, got %v", got) + } +} + +func TestEntriesFor_NotPrivileged_NoSambaAttrsEvenIfCredsProvided(t *testing.T) { + h := testSambaHandler() + creds := map[string]store.SambaCredential{"jsmith": {NTHash: "8846F7EAEE8FB117AD06BDD830B7586C", RID: 1000}} + entries := h.entriesFor(testSnapshotWithSambaReader(), "uid=jsmith,ou=users,dc=example,dc=com", scopeBaseObject, true, false, creds) + if got := attrValues(entries[0].attrs, "sambaSID"); len(got) != 0 { + t.Fatalf("expected no sambaSID when the search isn't samba-privileged, got %v", got) + } +} + +func TestEntriesFor_SambaPrivileged_NoCredYetMeansNoSambaAttrs(t *testing.T) { + h := testSambaHandler() + entries := h.entriesFor(testSnapshotWithSambaReader(), "uid=jsmith,ou=users,dc=example,dc=com", scopeBaseObject, true, true, map[string]store.SambaCredential{}) + if got := attrValues(entries[0].attrs, "sambaSID"); len(got) != 0 { + t.Fatalf("expected no sambaSID before an NT hash has been computed for this user, got %v", got) + } +} + +func TestEntriesFor_SambaPrivileged_DomainEntryAppearsUnderBaseSingleLevel(t *testing.T) { + h := testSambaHandler() + entries := h.entriesFor(testSnapshotWithSambaReader(), "dc=example,dc=com", scopeSingleLevel, true, true, nil) + wantDN := "sambaDomainName=WORKGROUP,dc=example,dc=com" + found := false + for _, e := range entries { + if e.dn == wantDN { + found = true + } + } + if !found { + t.Fatalf("expected the sambaDomain entry (%s) among single-level results, got %v", wantDN, entries) + } +} + +func TestEntriesFor_NotPrivileged_NoDomainEntry(t *testing.T) { + h := testSambaHandler() + entries := h.entriesFor(testSnapshotWithSambaReader(), "dc=example,dc=com", scopeSingleLevel, true, false, nil) + for _, e := range entries { + if e.dn == "sambaDomainName=WORKGROUP,dc=example,dc=com" { + t.Fatalf("expected no sambaDomain entry when the search isn't samba-privileged, got %v", entries) + } + } +} diff --git a/internal/ldapserver/server.go b/internal/ldapserver/server.go index e6f6f77..47bb4fc 100644 --- a/internal/ldapserver/server.go +++ b/internal/ldapserver/server.go @@ -10,6 +10,7 @@ import ( "declarativeauth/internal/auth" "declarativeauth/internal/identity" + "declarativeauth/internal/store" ber "github.com/go-asn1-ber/asn1-ber" "github.com/go-ldap/ldap/v3" @@ -23,6 +24,11 @@ type Handler struct { TrustedProxy *auth.TrustedProxies Logger *slog.Logger + // Credentials, when Config.SambaReadersGroup is set, is consulted by a + // privileged search to populate sambaNTPassword/sambaSID. Unused (may + // be nil) otherwise. + Credentials *store.CredentialStore + OnBind func(username string, success bool, sourceIP string, reason string) OnSearch func(sourceIP string) } diff --git a/internal/server/run.go b/internal/server/run.go index 3504faf..ff20cd7 100644 --- a/internal/server/run.go +++ b/internal/server/run.go @@ -113,10 +113,14 @@ func Run(ctx context.Context, cfg *config.ServerConfig, holder *config.SnapshotH BaseDN: cfg.LDAP.BaseDN, AllowAnonymousBind: cfg.LDAP.AllowAnonymousBind, RequireTLS: cfg.LDAP.RequireTLS, + SambaReadersGroup: cfg.LDAP.SambaReadersGroup, + SambaDomainSID: cfg.LDAP.SambaDomainSID, + SambaDomainName: cfg.LDAP.SambaDomainName, }, Snapshot: holder.Get, Authenticator: authenticator, TrustedProxy: trustedProxies, + Credentials: &store.CredentialStore{Pool: pool}, Logger: logger, OnBind: func(username string, success bool, sourceIP, reason string) { eventType := "ldap_bind_failure" diff --git a/internal/store/credentials.go b/internal/store/credentials.go index 43d9827..1934cb7 100644 --- a/internal/store/credentials.go +++ b/internal/store/credentials.go @@ -15,7 +15,23 @@ var ErrNotFound = errors.New("not found") type Credential struct { Username string PasswordHash string - MustReset bool + // NTHash is the Samba/NTLM "NT hash" (see auth.NTHash), empty until the + // first successful password verification or set/reset after this field + // was introduced -- Authenticate backfills it lazily since it's the + // only place a plaintext password is available for an existing account + // without asking the user to change it. Never derived from PasswordHash + // itself, which is one-way Argon2id. + NTHash string + SambaRID int64 + MustReset bool +} + +// SambaCredential is the subset of a Credential an LDAP search privileged +// via the samba-readers group is allowed to see: enough to populate a +// sambaSamAccount entry, nothing else. +type SambaCredential struct { + NTHash string + RID int64 } // CredentialStore provides CRUD access to the credentials table. @@ -27,10 +43,11 @@ type CredentialStore struct { // has never had a password set (the bootstrap state). func (s *CredentialStore) Get(ctx context.Context, username string) (*Credential, error) { row := s.Pool.QueryRow(ctx, - `SELECT username, password_hash, must_reset FROM credentials WHERE username = $1`, + `SELECT username, password_hash, COALESCE(nt_hash, ''), COALESCE(samba_rid, 0), must_reset + FROM credentials WHERE username = $1`, username) var c Credential - if err := row.Scan(&c.Username, &c.PasswordHash, &c.MustReset); err != nil { + if err := row.Scan(&c.Username, &c.PasswordHash, &c.NTHash, &c.SambaRID, &c.MustReset); err != nil { if errors.Is(err, pgx.ErrNoRows) { return nil, ErrNotFound } @@ -39,17 +56,64 @@ func (s *CredentialStore) Get(ctx context.Context, username string) (*Credential return &c, nil } -// Upsert sets (or replaces) the password hash for username, clearing -// must_reset. -func (s *CredentialStore) Upsert(ctx context.Context, username, passwordHash string) error { +// Upsert sets (or replaces) the password hash and NT hash for username, +// clearing must_reset. A RID is assigned once -- either now, for a +// brand-new row, or on a later call, for a row that predates the NT hash +// column and never got one -- and kept stable afterwards regardless of how +// many more times the password changes. nextval() is evaluated by Postgres +// even on the ON CONFLICT update path whether or not its value ends up +// used, so a password change on a user that already has a RID still burns +// a sequence value; that just leaves a gap in the RID space, which is +// harmless (Windows/Samba SIDs are not required to be contiguous). +func (s *CredentialStore) Upsert(ctx context.Context, username, passwordHash, ntHash string) error { _, err := s.Pool.Exec(ctx, ` - INSERT INTO credentials (username, password_hash, password_set_at, must_reset, updated_at) - VALUES ($1, $2, now(), false, now()) + INSERT INTO credentials (username, password_hash, nt_hash, samba_rid, password_set_at, must_reset, updated_at) + VALUES ($1, $2, $3, nextval('samba_rid_seq'), now(), false, now()) ON CONFLICT (username) DO UPDATE SET password_hash = EXCLUDED.password_hash, + nt_hash = EXCLUDED.nt_hash, + samba_rid = COALESCE(credentials.samba_rid, EXCLUDED.samba_rid), password_set_at = now(), must_reset = false, updated_at = now()`, - username, passwordHash) + username, passwordHash, ntHash) return err } + +// SetNTHashIfMissing backfills ntHash (and assigns a RID) for an existing +// credential row that predates the NT hash column, or that was created +// before a samba-readers-group deployment existed to make use of it. A +// no-op once nt_hash is already set, so a successful login only ever pays +// this write once per user. +func (s *CredentialStore) SetNTHashIfMissing(ctx context.Context, username, ntHash string) error { + _, err := s.Pool.Exec(ctx, ` + UPDATE credentials + SET nt_hash = $2, + samba_rid = COALESCE(samba_rid, nextval('samba_rid_seq')), + updated_at = now() + WHERE username = $1 AND nt_hash IS NULL`, + username, ntHash) + return err +} + +// AllSambaCredentials returns every user's NT hash + RID that has one, +// keyed by username -- fetched in one round-trip per privileged LDAP +// search rather than per matched entry. +func (s *CredentialStore) AllSambaCredentials(ctx context.Context) (map[string]SambaCredential, error) { + rows, err := s.Pool.Query(ctx, `SELECT username, nt_hash, samba_rid FROM credentials WHERE nt_hash IS NOT NULL AND samba_rid IS NOT NULL`) + if err != nil { + return nil, err + } + defer rows.Close() + + out := make(map[string]SambaCredential) + for rows.Next() { + var username, ntHash string + var rid int64 + if err := rows.Scan(&username, &ntHash, &rid); err != nil { + return nil, err + } + out[username] = SambaCredential{NTHash: ntHash, RID: rid} + } + return out, rows.Err() +} diff --git a/internal/store/migrations/00013_samba_nt_hash.sql b/internal/store/migrations/00013_samba_nt_hash.sql new file mode 100644 index 0000000..1fd8575 --- /dev/null +++ b/internal/store/migrations/00013_samba_nt_hash.sql @@ -0,0 +1,12 @@ +-- +goose Up +-- RIDs start at 1000 (below that is conventionally reserved for +-- well-known/built-in SIDs on a Windows domain). +CREATE SEQUENCE samba_rid_seq START WITH 1000; + +ALTER TABLE credentials ADD COLUMN nt_hash TEXT; +ALTER TABLE credentials ADD COLUMN samba_rid BIGINT UNIQUE; + +-- +goose Down +ALTER TABLE credentials DROP COLUMN samba_rid; +ALTER TABLE credentials DROP COLUMN nt_hash; +DROP SEQUENCE samba_rid_seq; diff --git a/internal/web/reset.go b/internal/web/reset.go index 5afba04..3912a2f 100644 --- a/internal/web/reset.go +++ b/internal/web/reset.go @@ -312,7 +312,7 @@ func (h *ResetHandlers) handleResetConfirmSubmit(w http.ResponseWriter, r *http. http.Error(w, "internal error", http.StatusInternalServerError) return } - if err := h.Creds.Upsert(ctx, result.Username, encoded); err != nil { + if err := h.Creds.Upsert(ctx, result.Username, encoded, auth.NTHash(password)); err != nil { http.Error(w, "internal error", http.StatusInternalServerError) return } diff --git a/test/integration/email_login_test.go b/test/integration/email_login_test.go index 50ae320..75758c3 100644 --- a/test/integration/email_login_test.go +++ b/test/integration/email_login_test.go @@ -6,6 +6,7 @@ import ( "context" "testing" + "declarativeauth/internal/auth" "declarativeauth/internal/config" ) @@ -20,7 +21,7 @@ func TestAuthenticate_EmailLogin(t *testing.T) { authenticator := buildAuthenticator(pool, holder, defaultLockoutParams()) encoded, _ := authenticator.Hasher.Hash("Secret123!") - if err := authenticator.Credentials.Upsert(context.Background(), "jsmith", encoded); err != nil { + if err := authenticator.Credentials.Upsert(context.Background(), "jsmith", encoded, auth.NTHash("Secret123!")); err != nil { t.Fatalf("seed credential: %v", err) } @@ -54,7 +55,7 @@ func TestAuthenticate_UsernameAndEmailShareLockoutBudget(t *testing.T) { params := defaultLockoutParams() // threshold=3 authenticator := buildAuthenticator(pool, holder, params) encoded, _ := authenticator.Hasher.Hash("Secret123!") - if err := authenticator.Credentials.Upsert(context.Background(), "jsmith", encoded); err != nil { + if err := authenticator.Credentials.Upsert(context.Background(), "jsmith", encoded, auth.NTHash("Secret123!")); err != nil { t.Fatalf("seed credential: %v", err) } diff --git a/test/integration/helpers_test.go b/test/integration/helpers_test.go index a74d4c3..5378efc 100644 --- a/test/integration/helpers_test.go +++ b/test/integration/helpers_test.go @@ -84,7 +84,7 @@ func seedPassword(t *testing.T, pool *pgxpool.Pool, username, password string) { t.Fatalf("hash password: %v", err) } creds := &store.CredentialStore{Pool: pool} - if err := creds.Upsert(context.Background(), username, encoded); err != nil { + if err := creds.Upsert(context.Background(), username, encoded, auth.NTHash(password)); err != nil { t.Fatalf("seed credential: %v", err) } } diff --git a/test/integration/ldap_bind_test.go b/test/integration/ldap_bind_test.go index 077c957..b15d8b6 100644 --- a/test/integration/ldap_bind_test.go +++ b/test/integration/ldap_bind_test.go @@ -10,6 +10,7 @@ import ( "testing" "time" + "declarativeauth/internal/auth" "declarativeauth/internal/config" "declarativeauth/internal/ldapserver" dtls "declarativeauth/internal/tls" @@ -36,7 +37,7 @@ func startLDAPServer(t *testing.T, identityFixture string, opts ...func(*ldapser if err != nil { t.Fatalf("hash: %v", err) } - if err := authenticator.Credentials.Upsert(context.Background(), "jsmith", encoded); err != nil { + if err := authenticator.Credentials.Upsert(context.Background(), "jsmith", encoded, auth.NTHash("Secret123!")); err != nil { t.Fatalf("seed credential: %v", err) } diff --git a/test/integration/lockout_test.go b/test/integration/lockout_test.go index d7bfd74..cc3759f 100644 --- a/test/integration/lockout_test.go +++ b/test/integration/lockout_test.go @@ -26,7 +26,7 @@ func TestLockout_LocksAfterThresholdAndClearsOnSuccess(t *testing.T) { authenticator := buildAuthenticator(pool, holder, params) encoded, _ := authenticator.Hasher.Hash("Secret123!") - if err := authenticator.Credentials.Upsert(context.Background(), "jsmith", encoded); err != nil { + if err := authenticator.Credentials.Upsert(context.Background(), "jsmith", encoded, auth.NTHash("Secret123!")); err != nil { t.Fatalf("seed credential: %v", err) } @@ -91,7 +91,7 @@ func TestLockout_DimensionsAreIndependentlyConfigurable(t *testing.T) { }, } encoded, _ := a.Hasher.Hash("Secret123!") - if err := a.Credentials.Upsert(context.Background(), "jsmith", encoded); err != nil { + if err := a.Credentials.Upsert(context.Background(), "jsmith", encoded, auth.NTHash("Secret123!")); err != nil { t.Fatalf("seed credential: %v", err) } return a diff --git a/test/integration/samba_nt_hash_test.go b/test/integration/samba_nt_hash_test.go new file mode 100644 index 0000000..b579033 --- /dev/null +++ b/test/integration/samba_nt_hash_test.go @@ -0,0 +1,134 @@ +//go:build integration + +package integration + +import ( + "context" + "testing" + + "declarativeauth/internal/auth" + "declarativeauth/internal/config" + "declarativeauth/internal/store" +) + +// TestAuthenticate_BackfillsMissingNTHashOnSuccessfulLogin covers the lazy +// backfill in auth.Authenticator.Authenticate: a credential row that +// predates the NT hash column (or was created before Samba integration was +// configured) gets nt_hash/samba_rid filled in on its next successful +// login, without requiring a password reset. +func TestAuthenticate_BackfillsMissingNTHashOnSuccessfulLogin(t *testing.T) { + pool := setupPool(t) + holder := &config.SnapshotHolder{} + snap, err := config.LoadIdentity(fixturePath("valid")) + if err != nil { + t.Fatalf("load identity: %v", err) + } + holder.Set(snap) + + authenticator := buildAuthenticator(pool, holder, defaultLockoutParams()) + encoded, err := authenticator.Hasher.Hash("Secret123!") + if err != nil { + t.Fatalf("hash: %v", err) + } + + // Simulate a pre-existing row from before nt_hash existed: written + // directly, bypassing CredentialStore.Upsert (which always computes it). + ctx := context.Background() + if _, err := pool.Exec(ctx, ` + INSERT INTO credentials (username, password_hash, password_set_at, must_reset, updated_at) + VALUES ($1, $2, now(), false, now())`, + "jsmith", encoded); err != nil { + t.Fatalf("seed legacy credential: %v", err) + } + + creds := &store.CredentialStore{Pool: pool} + before, err := creds.Get(ctx, "jsmith") + if err != nil { + t.Fatalf("get before login: %v", err) + } + if before.NTHash != "" || before.SambaRID != 0 { + t.Fatalf("expected no NT hash/RID before any login, got %+v", before) + } + + if _, err := authenticator.Authenticate(ctx, "jsmith", "Secret123!", "203.0.113.20"); err != nil { + t.Fatalf("expected login to succeed, got %v", err) + } + + after, err := creds.Get(ctx, "jsmith") + if err != nil { + t.Fatalf("get after login: %v", err) + } + if after.NTHash != auth.NTHash("Secret123!") { + t.Fatalf("expected NT hash to be backfilled after a successful login, got %q", after.NTHash) + } + if after.SambaRID == 0 { + t.Fatal("expected a RID to be assigned alongside the backfilled NT hash") + } + + // A wrong password must never trigger a write (nothing plaintext to + // derive from, and the row is invisible to Samba until logged in with + // the right password anyway). + rid := after.SambaRID + if _, err := authenticator.Authenticate(ctx, "jsmith", "Secret123!", "203.0.113.20"); err != nil { + t.Fatalf("expected second login to succeed, got %v", err) + } + again, err := creds.Get(ctx, "jsmith") + if err != nil { + t.Fatalf("get after second login: %v", err) + } + if again.SambaRID != rid { + t.Fatalf("expected the RID to stay stable across logins, got %d then %d", rid, again.SambaRID) + } +} + +// TestCredentialStore_Upsert_KeepsRIDStableAcrossPasswordChanges guards +// against a regression where a password change on an existing user could +// silently strand samba_rid at NULL forever (only the very first Upsert +// call for a username assigned one; a subsequent password change on the ON +// CONFLICT path needs to preserve it, not just leave it untouched from a +// state where it was never set to begin with). +func TestCredentialStore_Upsert_KeepsRIDStableAcrossPasswordChanges(t *testing.T) { + pool := setupPool(t) + creds := &store.CredentialStore{Pool: pool} + ctx := context.Background() + + if err := creds.Upsert(ctx, "jsmith", "hash-v1", auth.NTHash("v1")); err != nil { + t.Fatalf("first upsert: %v", err) + } + first, err := creds.Get(ctx, "jsmith") + if err != nil { + t.Fatalf("get: %v", err) + } + if first.SambaRID == 0 { + t.Fatal("expected a RID to be assigned on first upsert") + } + if first.NTHash != auth.NTHash("v1") { + t.Fatalf("unexpected NT hash: %q", first.NTHash) + } + + if err := creds.Upsert(ctx, "jsmith", "hash-v2", auth.NTHash("v2")); err != nil { + t.Fatalf("second upsert: %v", err) + } + second, err := creds.Get(ctx, "jsmith") + if err != nil { + t.Fatalf("get: %v", err) + } + if second.SambaRID != first.SambaRID { + t.Fatalf("expected RID to stay stable across a password change, got %d then %d", first.SambaRID, second.SambaRID) + } + if second.NTHash != auth.NTHash("v2") { + t.Fatalf("expected NT hash to be updated to the new password's, got %q", second.NTHash) + } + + all, err := creds.AllSambaCredentials(ctx) + if err != nil { + t.Fatalf("AllSambaCredentials: %v", err) + } + got, ok := all["jsmith"] + if !ok { + t.Fatal("expected jsmith in AllSambaCredentials") + } + if got.RID != second.SambaRID || got.NTHash != second.NTHash { + t.Fatalf("AllSambaCredentials returned %+v, want RID=%d NTHash=%q", got, second.SambaRID, second.NTHash) + } +}