Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion cmd/declarativeauth/cmd_admin.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
18 changes: 18 additions & 0 deletions examples/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
16 changes: 15 additions & 1 deletion internal/auth/authenticate.go
Original file line number Diff line number Diff line change
Expand Up @@ -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())
Expand All @@ -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
Expand Down
33 changes: 33 additions & 0 deletions internal/auth/ntlm.go
Original file line number Diff line number Diff line change
@@ -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)))
}
45 changes: 45 additions & 0 deletions internal/auth/ntlm_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
43 changes: 40 additions & 3 deletions internal/config/env.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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.
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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)
}
Expand Down
83 changes: 83 additions & 0 deletions internal/config/env_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
8 changes: 8 additions & 0 deletions internal/config/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
12 changes: 12 additions & 0 deletions internal/ldapserver/dn.go
Original file line number Diff line number Diff line change
Expand Up @@ -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=<name>,<baseDN>.
func SambaDomainDN(baseDN, domainName string) string {
return fmt.Sprintf("sambaDomainName=%s,%s", domainName, baseDN)
}

// UserDN returns the canonical DN for a username: uid=<username>,ou=users,<baseDN>.
Expand Down
Loading
Loading