From 1da70030b74a2f5a2cb5ff9a99f81819540daf12 Mon Sep 17 00:00:00 2001
From: Graham Christensen <graham@floxdev.com>
Date: Thu, 15 Apr 2021 10:55:40 -0400
Subject: [PATCH] Users: transparently upgrade passwords to Argon2

Passwords that are sha1 will be transparently upgraded to argon2,
and future comparisons will use Argon2

Co-authored-by: Graham Christensen <graham@grahamc.com>
---
 flake.nix                     | 43 +++++++++++++++++++++++++++++++++++
 src/lib/Hydra/Schema/Users.pm | 24 ++++++++++++++++++-
 t/Schema/Users.t              | 21 +++++++++++++----
 3 files changed, 83 insertions(+), 5 deletions(-)

diff --git a/flake.nix b/flake.nix
index 8fac28dd..87acfa5f 100644
--- a/flake.nix
+++ b/flake.nix
@@ -70,6 +70,47 @@
             };
           };
 
+          CryptArgon2 = final.perlPackages.buildPerlModule {
+            pname = "Crypt-Argon2";
+            version = "0.010";
+            src = final.fetchurl {
+              url = "mirror://cpan/authors/id/L/LE/LEONT/Crypt-Argon2-0.010.tar.gz";
+              sha256 = "3ea1c006f10ef66fd417e502a569df15c4cc1c776b084e35639751c41ce6671a";
+            };
+            nativeBuildInputs = [ pkgs.ld-is-cc-hook ];
+            meta = {
+              description = "Perl interface to the Argon2 key derivation functions";
+              license = final.lib.licenses.cc0;
+            };
+          };
+
+          CryptPassphrase = final.buildPerlPackage {
+            pname = "Crypt-Passphrase";
+            version = "0.003";
+            src = final.fetchurl {
+              url = "mirror://cpan/authors/id/L/LE/LEONT/Crypt-Passphrase-0.003.tar.gz";
+              sha256 = "685aa090f8179a86d6896212ccf8ccfde7a79cce857199bb14e2277a10d240ad";
+            };
+            meta = {
+              description = "A module for managing passwords in a cryptographically agile manner";
+              license = with final.lib.licenses; [ artistic1 gpl1Plus ];
+            };
+          };
+
+          CryptPassphraseArgon2 = final.buildPerlPackage {
+            pname = "Crypt-Passphrase-Argon2";
+            version = "0.002";
+            src = final.fetchurl {
+              url = "mirror://cpan/authors/id/L/LE/LEONT/Crypt-Passphrase-Argon2-0.002.tar.gz";
+              sha256 = "3906ff81697d13804ee21bd5ab78ffb1c4408b4822ce020e92ecf4737ba1f3a8";
+            };
+            propagatedBuildInputs = with final.perlPackages; [ CryptArgon2 CryptPassphrase ];
+            meta = {
+              description = "An Argon2 encoder for Crypt::Passphrase";
+              license = with final.lib.licenses; [ artistic1 gpl1Plus ];
+            };
+          };
+
           DirSelf = final.buildPerlPackage {
             pname = "Dir-Self";
             version = "0.11";
@@ -267,6 +308,8 @@
                 CatalystViewTT
                 CatalystXScriptServerStarman
                 CatalystXRoleApplicator
+                CryptPassphrase
+                CryptPassphraseArgon2
                 CryptRandPasswd
                 DBDPg
                 DBDSQLite
diff --git a/src/lib/Hydra/Schema/Users.pm b/src/lib/Hydra/Schema/Users.pm
index ca650d9e..e11e0354 100644
--- a/src/lib/Hydra/Schema/Users.pm
+++ b/src/lib/Hydra/Schema/Users.pm
@@ -195,6 +195,7 @@ __PACKAGE__->many_to_many("projects", "projectmembers", "project");
 # Created by DBIx::Class::Schema::Loader v0.07049 @ 2020-02-06 12:22:36
 # DO NOT MODIFY THIS OR ANYTHING ABOVE! md5sum:4/WZ95asbnGmK+nEHb4sLQ
 
+use Crypt::Passphrase;
 use Digest::SHA1 qw(sha1_hex);
 use String::Compare::ConstantTime;
 
@@ -216,7 +217,28 @@ sub json_hint {
 sub check_password {
     my ($self, $password) = @_;
 
-    return String::Compare::ConstantTime::equals($self->password, sha1_hex($password));
+    my $authenticator = Crypt::Passphrase->new(
+        encoder    => 'Argon2',
+        validators => [
+            (sub {
+                my ($password, $hash) = @_;
+
+                return String::Compare::ConstantTime::equals($hash, sha1_hex($password));
+            })
+        ],
+    );
+
+    if ($authenticator->verify_password($password, $self->password)) {
+        if ($authenticator->needs_rehash($self->password)) {
+            $self->update({
+                "password" => $authenticator->hash_password($password),
+            });
+        }
+
+        return 1;
+    } else {
+        return 0;
+    }
 }
 
 1;
diff --git a/t/Schema/Users.t b/t/Schema/Users.t
index edfb9002..5f31af76 100644
--- a/t/Schema/Users.t
+++ b/t/Schema/Users.t
@@ -11,11 +11,20 @@ use Test2::V0;
 my $db = Hydra::Model::DB->new;
 hydra_setup($db);
 
-# Catalyst's default password checking is not constant time. To improve
-# the security of the system, we replaced the check password routine.
-# Verify comparing correct and incorrect passwords work.
+# Hydra used to store passwords, by default, as plain unsalted sha1 hashes.
+# We now upgrade these badly stored passwords with much stronger algorithms
+# when the user logs in. Implementing this meant reimplementing our password
+# checking ourselves, so also ensure that basic password checking works.
+#
+# This test:
+#
+# 1. creates a user with the legacy password
+# 2. validates that the wrong password is not considered valid
+# 3. validates that the correct password is valid
+# 4. checks that the checking of the correct password transparently upgraded
+#    the password's storage to a more secure algorithm.
 
-# Starting the user with a sha1 password
+# Starting the user with an unsalted sha1 password
 my $user = $db->resultset('Users')->create({
     "username" => "alice",
     "emailaddress" => 'alice@nixos.org',
@@ -24,6 +33,10 @@ my $user = $db->resultset('Users')->create({
 isnt($user, undef, "My user was created.");
 
 ok(!$user->check_password("barbaz"), "Checking the password, barbaz, is not right");
+
+is($user->password, "8843d7f92416211de9ebb963ff4ce28125932878", "The unsalted sha1 is in the database.");
 ok($user->check_password("foobar"), "Checking the password, foobar, is right");
+isnt($user->password, "8843d7f92416211de9ebb963ff4ce28125932878", "The user has had their password rehashed.");
+ok($user->check_password("foobar"), "Checking the password, foobar, is still right");
 
 done_testing;