Give each password zmupdate.pl migrates its own random bcrypt salt, rehash
legacy (mysql and zmupdate.pl -ZM-) password hashes on every password login,
not only the web login form, and log the users still on a -ZM- hash.

Backported to 1.38.4; upstream pull request pending.
refs https://github.com/ZoneMinder/zoneminder/issues/4333

diff --git a/scripts/zmupdate.pl.in b/scripts/zmupdate.pl.in
index b90705707..afd140637 100644
--- a/scripts/zmupdate.pl.in
+++ b/scripts/zmupdate.pl.in
@@ -1041,27 +1041,37 @@ sub patchDB {
 
 sub migratePasswords {
   use Crypt::Eksblowfish::Bcrypt;
-  my $random;
-  eval {
-    require Bytes::Random::Secure;
-    $random = Bytes::Random::Secure->new( Bits        => 16*8);
-  };
-  if ($@ or !$random) {
-    eval {
-      require Data::Entropy::Algorithms;
-      $random =Data::Entropy::Algorithms::rand_bits(16*8);
-    };
-  }
+  my $urandom;
+  my @overlay_users;
 
   print("Migratings passwords, if any...\n");
   my $sql = 'SELECT * FROM `Users`';
   my $sth = $dbh->prepare_cached($sql) or die( "Can't prepare '$sql': ".$dbh->errstr() );
   my $res = $sth->execute() or die("Can't execute: ".$sth->errstr());
   while ( my $user = $sth->fetchrow_hashref() ) {
+    # Hashes migrated by earlier releases can't be told apart from good ones, and
+    # all of them shared one salt per run: from Data::Entropy, seeded with rand()
+    # before its 0.008 (CVE-2025-1860), or a constant derived from the class name
+    # when Bytes::Random::Secure was installed.
+    if ( substr($user->{Password}, 0, 4) eq '-ZM-' ) {
+      push @overlay_users, $user->{Username};
+      next;
+    }
     my $scheme = substr($user->{Password}, 0, 1);
     if ($scheme eq '*') {
       print('-->'.$user->{Username}." password will be migrated\n");
-      my $salt = Crypt::Eksblowfish::Bcrypt::en_base64($random);
+      # Each user gets their own 16 byte bcrypt salt from the kernel CSPRNG.
+      # Without one, leave the legacy hash alone; auth.php still accepts it.
+      if ( !$urandom and open(my $fh, '<:raw', '/dev/urandom') ) {
+        $urandom = $fh;
+      }
+      my $salt_bytes;
+      read($urandom, $salt_bytes, 16) if $urandom;
+      if ( !defined($salt_bytes) or length($salt_bytes) != 16 ) {
+        print('Warning: unable to read /dev/urandom, not migrating '.$user->{Username}."'s password\n");
+        next;
+      }
+      my $salt = Crypt::Eksblowfish::Bcrypt::en_base64($salt_bytes);
       my $settings = '$2a$10$'.$salt;
       my $pass_hash = Crypt::Eksblowfish::Bcrypt::bcrypt($user->{Password},$settings);
       my $new_pass_hash = '-ZM-'.$pass_hash;
@@ -1070,6 +1080,14 @@ sub migratePasswords {
       my $res = $sth->execute($new_pass_hash, $user->{Username}) or die("Can't execute: ".$sth->errstr());
     }
   }
+  # The original hash input is gone, so these can only be rehashed when the user
+  # next logs in, which auth.php does automatically, or by resetting the password.
+  if ( @overlay_users ) {
+    Warning('User(s) '.join(', ', @overlay_users).' still use a legacy password hash from'
+      .' zmupdate.pl. It is upgraded to a standard bcrypt hash automatically the next time the'
+      .' user logs in to the web UI or API, or when the password is reset. Accounts that are no'
+      .' longer used can be disabled or deleted.');
+  }
 } # end sub migratePasswords
 
 sub migratePaths {
diff --git a/web/api/app/Controller/AppController.php b/web/api/app/Controller/AppController.php
index 81d1d1b39..2d7d731d9 100644
--- a/web/api/app/Controller/AppController.php
+++ b/web/api/app/Controller/AppController.php
@@ -83,6 +83,7 @@ class AppController extends Controller {
             return;
           } 
           ZM\Debug("Login successful for user \"$username\"");
+          migrateHash($username, $password);
         }
       }
 
diff --git a/web/includes/auth.php b/web/includes/auth.php
index 7e723cfb7..5d35eb907 100644
--- a/web/includes/auth.php
+++ b/web/includes/auth.php
@@ -47,15 +47,22 @@ function password_type($password) {
 }
 
 // this function migrates mysql hashing to bcrypt, if you are using PHP >= 5.5
-// will be called after successful login, only if mysql hashing is detected
-function migrateHash($user, $pass) {
+// will be called after a successful password login, once the global $user is set.
+// Only mysql and mysql+bcrypt (zmupdate.pl) hashes are rehashed.
+function migrateHash($username, $pass) {
+  global $user;
+  $password_type = password_type($user->Password());
+  if ($password_type != 'mysql' and $password_type != 'mysql+bcrypt') return;
+
   if (function_exists('password_hash')) {
-    ZM\Info("Migrating $user to bcrypt scheme");
+    ZM\Info("Migrating $username to bcrypt scheme");
     // let it generate its own salt, and ensure bcrypt as PASSWORD_DEFAULT may change later
     // we can modify this later to support argon2 etc as switch to its own password signature detection
     $bcrypt_hash = password_hash($pass, PASSWORD_BCRYPT);
-    dbQuery('UPDATE Users SET Password=? WHERE Username=?', array($bcrypt_hash, $user));
-    # Since password field has changed, existing auth_hash is no longer valid
+    dbQuery('UPDATE Users SET Password=? WHERE Id=?', array($bcrypt_hash, $user->Id()));
+    # Since password field has changed, existing auth_hash is no longer valid.
+    # getAuthUser() checks it against the new hash, so build it from that one.
+    $user->Password($bcrypt_hash);
     generateAuthHash(ZM_AUTH_HASH_IPS, true);
   } else {
     ZM\Info('Cannot migrate password scheme to bcrypt, as you are using PHP < 5.5');
@@ -604,6 +611,7 @@ if (ZM_OPT_USE_AUTH) {
         return;
       }
       $user = $ret[0];
+      migrateHash($_REQUEST['user'], $_REQUEST['pass']);
     } else if (!(empty($_REQUEST['username']) or empty($_REQUEST['password']))) {
       # Longer versions are used on login page
       $ret = validateUser($_REQUEST['username'], $_REQUEST['password']);
