From 26662db315dd5775f7beaad6ab04e9ca4c97f372 Mon Sep 17 00:00:00 2001 From: Bryce Kwon Date: Fri, 16 Oct 2026 22:11:55 -1000 Subject: Spend a missing account's login on a real hash The fixed SHA-512 stand-in matched only the documented setup, so a site hashing with yescrypt, other rounds or bcrypt answered for missing and real names in different times, and on OpenBSD it failed at once. --- custom/extensions/auth-file.lua | 29 +++++++++++++++++------------ custom/extensions/auth-inline.lua | 23 ++++++++++++++--------- 2 files changed, 31 insertions(+), 21 deletions(-) (limited to 'custom/extensions') diff --git a/custom/extensions/auth-file.lua b/custom/extensions/auth-file.lua index f43eb82..59e05cc 100644 --- a/custom/extensions/auth-file.lua +++ b/custom/extensions/auth-file.lua @@ -90,11 +90,6 @@ local cookie_insecure = false -- the password's length, and one longer than this is turned down unchecked. local max_password_bytes = 256 --- A throwaway hash of the documented shape, used only to spend the same work --- on a missing account as on a present one, so a failed login does not reveal --- by timing whether the user name exists. -local dummy_hash = "$6$rounds=300000$0000000000000000$" - -- One request reaches this script as an open, a write and a close, so what the -- open decodes is kept here for the calls that follow. local action, http, cgit, post @@ -129,6 +124,10 @@ end -- The hash is trimmed as well as the name because reading by line strips -- the newline but not a carriage return, so a CRLF users file would hand -- crypt a hash with a trailing \r and fail every login. +-- +-- A name with no account comes back as nil and another account's hash, so +-- a failed login can spend the same work on a hash of the same kind and +-- cost, and its timing does not tell whether the name exists. function account_hash(user) if user == nil then return nil @@ -138,15 +137,19 @@ function account_hash(user) if users_file == nil then return nil end + local stand_in = nil for line in users_file:lines() do local name, hash = string.match(line, "(.-):(.+)") - if name ~= nil and trim(name):lower() == wanted then - users_file:close() - return trim(hash) + if name ~= nil then + if trim(name):lower() == wanted then + users_file:close() + return trim(hash) + end + stand_in = stand_in or trim(hash) end end users_file:close() - return nil + return nil, stand_in end -- The users allowed into a repository come back keyed by lowercased name, and @@ -454,10 +457,12 @@ function authenticate_post() local password = post["password"] local ok = false if username ~= nil and password ~= nil and #password <= max_password_bytes then - local hash = account_hash(username) + local hash, stand_in = account_hash(username) if hash == nil then - -- Spend the work anyway, see dummy_hash. - unistd.crypt(password, dummy_hash) + -- Spend the work anyway, see account_hash. + if stand_in ~= nil then + unistd.crypt(password, stand_in) + end elseif constant_equals(hash, unistd.crypt(password, hash)) then ok = true end diff --git a/custom/extensions/auth-inline.lua b/custom/extensions/auth-inline.lua index 53b1efc..3355b5c 100644 --- a/custom/extensions/auth-inline.lua +++ b/custom/extensions/auth-inline.lua @@ -90,11 +90,6 @@ local cookie_insecure = false -- the password's length, and one longer than this is turned down unchecked. local max_password_bytes = 256 --- A throwaway hash of the documented shape, used only to spend the same work --- on a missing account as on a present one, so a failed login does not reveal --- by timing whether the user name exists. -local dummy_hash = "$6$rounds=300000$0000000000000000$" - -- One request reaches this script as an open, a write and a close, so what the -- open decodes is kept here for the calls that follow. local action, http, cgit, post @@ -120,11 +115,19 @@ do end end +-- A name with no account comes back as nil and another account's hash, so +-- a failed login can spend the same work on a hash of the same kind and +-- cost, and its timing does not tell whether the name exists. function account_hash(user) if user == nil then return nil end - return users[user:lower()] + local hash = users[user:lower()] + if hash ~= nil then + return hash + end + local _, stand_in = next(users) + return nil, stand_in end -- The users allowed into a repository come back keyed by lowercased name, and @@ -416,10 +419,12 @@ function authenticate_post() local password = post["password"] local ok = false if username ~= nil and password ~= nil and #password <= max_password_bytes then - local hash = account_hash(username) + local hash, stand_in = account_hash(username) if hash == nil then - -- Spend the work anyway, see dummy_hash. - unistd.crypt(password, dummy_hash) + -- Spend the work anyway, see account_hash. + if stand_in ~= nil then + unistd.crypt(password, stand_in) + end elseif constant_equals(hash, unistd.crypt(password, hash)) then ok = true end -- cgit