Artifact 562a91c5c8555fb3af555822dcc3d94ab329d31570a70c8a2d86dc6d67b9489f:
- File
internal/auth/verify.go
— part of check-in
[9d35c01fb3]
at
2026-10-13 00:12:32
on branch main
— fix(sync): log in through the x-f-l-c cookie and accept cleartext passwords (#304) (#362)
fossil's client moves its login card from the body into the x-f-l-c cookie once the server's server-version passes xfer_xflc_check (2.27, 2025-07-27 11:05 or later), and fossil's check_login, when a stored password is not 40 characters, tries the signature again against the shared secret that cleartext password derives (sections 6.4, 6.5). The client always logged in in the body, and the server checked only the stored value.
A request marks itself LoginCookieOK once the gate passes; Message.SplitLoginCookie moves its login card out of the body, and the HTTP transports (sync.HTTPTransport and the public NewHTTPTransport through the adapter) send it as the cookie, on every redirect too. The nonce is unchanged: it already covers what follows the login line, which in cookie form is the whole body. Transports that cannot set a cookie send the login in the body as before. The server side of the cookie landed with the server-version exchange.
HandleOpts gains LoginCookie, so an embedder serving HandleSyncWithOpts over its own HTTP handler can pass the cookie through; XferHandler and ServeHTTP read it themselves. (user: [email protected], size: 4137) [annotate] [blame] [check-ins using]
package auth import ( "crypto/sha1" "crypto/subtle" "database/sql" "encoding/hex" "errors" "time" "github.com/danmestas/go-libfossil/db" "github.com/danmestas/go-libfossil/internal/xfer" ) var ErrAuthFailed = errors.New("authentication failed") // VerifyLogin validates a login card against the user table, as fossil's // check_tail_hash and check_login do (src/xfer.c, draft §6.4). // // The card must have been decoded from the request it arrived in: its nonce // must be the SHA-1 of the body that followed it (xfer.LoginCard // NonceCoversTail). The signature covers only the nonce, so without that // check a card captured from one request authenticates any other. // // nobody and anonymous succeed without a password and carry no capabilities // of their own. Any other user needs a non-empty stored password: an empty one // would make the signature SHA1(nonce), which anyone can compute. // // Returns the authenticated User on success, or ErrAuthFailed on any failure. // Panics if d or card is nil (TigerStyle preconditions). func VerifyLogin(d *db.DB, projectCode string, card *xfer.LoginCard) (User, error) { if d == nil { panic("auth.VerifyLogin: d must not be nil") } if projectCode == "" { panic("auth.VerifyLogin: projectCode must not be empty") } if card == nil { panic("auth.VerifyLogin: card must not be nil") } if !card.NonceCoversTail() { return User{}, ErrAuthFailed } if card.User == "nobody" || card.User == "anonymous" { return User{Login: card.User}, nil } // 1. Look up user by card.User and retrieve stored password + cexpire. // The reserved logins are capability templates, never accounts. var storedPW string var cexpire sql.NullString var u User var info sql.NullString var mtimeRaw any // An account with no stored password cannot be logged into, as in // fossil's check_login (src/xfer.c, length(pw)>0): with an empty secret // the signature is SHA1(nonce), which anyone can compute. A new // repository's creator starts that way, holding Setup. err := d.QueryRow( "SELECT uid, login, pw, cap, cexpire, info, mtime FROM user WHERE login=?"+ " AND login NOT IN ('anonymous','nobody','developer','reader')"+ " AND length(pw)>0", card.User, ).Scan(&u.UID, &u.Login, &storedPW, &u.Cap, &cexpire, &info, &mtimeRaw) // 2. If not found → ErrAuthFailed if err != nil { return User{}, ErrAuthFailed } if info.Valid { u.Info = info.String } // 3. If cexpire is set and in the past → ErrAuthFailed if cexpire.Valid { expireTime, ok := parseCExpire(cexpire.String) if !ok || time.Now().After(expireTime) { return User{}, ErrAuthFailed } u.CExpire = expireTime } // 4. Check the signature against the stored password. if !signatureMatches(card, storedPW, projectCode) { return User{}, ErrAuthFailed } return u, nil } // signatureMatches reports whether card's signature is SHA1(nonce + secret) // for the stored password, as fossil's check_login decides it. The stored // password is normally the shared secret, SHA1(projectCode/login/password). // One that is not 40 characters is a password stored in clear, so the // signature is checked again against the shared secret it derives (§6.4). // Both comparisons run in constant time. func signatureMatches(card *xfer.LoginCard, storedPW, projectCode string) bool { sig := []byte(card.Signature) if subtle.ConstantTimeCompare([]byte(sha1hex([]byte(card.Nonce+storedPW))), sig) == 1 { return true } if len(storedPW) == 40 { return false } secret := hashPassword(projectCode, card.User, storedPW) return subtle.ConstantTimeCompare([]byte(sha1hex([]byte(card.Nonce+secret))), sig) == 1 } // parseCExpire parses a user.cexpire value. SQLite may return either ISO 8601 // or "2006-01-02 15:04:05". An expiry that does not parse reports !ok, which // the caller treats as expired for safety. func parseCExpire(s string) (time.Time, bool) { t, err := time.Parse(time.RFC3339, s) if err != nil { t, err = time.Parse("2006-01-02 15:04:05", s) } return t, err == nil } func sha1hex(data []byte) string { h := sha1.Sum(data) return hex.EncodeToString(h[:]) }