diff --git a/INSTALL.md b/INSTALL.md index 55890e08e..32a04992b 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -38,13 +38,14 @@ cp .env.example .env This command copies the `.env.example` into `.env`, creating the `.env` file in the process if it does not exist. 2. Open `.env` with a text editor (i.e. Notepad, VS Code, etc.) and make the necessary changes to the following variables: - - `APP_ENV`: If you are setting up a development environment, change this to `dev`. Otherwise, leave it as `prod`. - - `DATABASE_URL`: If you are hosting UDOIT on Docker or your local machine, leave it as it is. Otherwise, change it to your database URL. - - `BASE_URL`: If you are hosting UDOIT on Docker or your local machine, leave it as it is. Otherwise, change it to the URL of your instance of UDOIT. - - `WEBPACK_PUBLIC_PATH`: Uf you are hosting UDOIT on Docker or your local machine, leave it as it is. Otherwise, change it to match the `BASE_URL`in such a way that `/build` is located at the root of the `BASE_URL` (Example: If your `BASE_URL` is set to `http://127.0.0.1:8000`, your `WEBPACK_PUBLIC_PATH` should be `/build`). - - `APP_LMS`: `canvas` for Canvas LMS. `d2l` for D2l Brightspace LMS. - - `JWK_BASE_URL`: If you are self-hosting Canvas, you may set it to the URL of your instance of Canvas. (Example: `JWK_BASE_URL="https://canvas.dev.myschool.edu"`) - - `DEFAULT_LANG`: (optional) `en` for English. `es` for Spanish. This is English by default. +- `APP_ENV`: If you are setting up a development environment, change this to `dev` and follow the steps in [Installing Composer Dependencies](#installing-composer-dependencies) without the `--no-dev` flag to obtain all of the development packages. Otherwise, leave it as `prod`. +- `DATABASE_URL`: If you are hosting UDOIT on Docker or your local machine, leave it as it is. Otherwise, change it to your database URL. +- `BASE_URL`: If you are hosting UDOIT on Docker or your local machine, leave it as it is. Otherwise, change it to the URL of your instance of UDOIT. +- `WEBPACK_PUBLIC_PATH`: Uf you are hosting UDOIT on Docker or your local machine, leave it as it is. Otherwise, change it to match the `BASE_URL`in such a way that `/build` is located at the root of the `BASE_URL` (Example: If your `BASE_URL` is set to `http://127.0.0.1:8000`, your `WEBPACK_PUBLIC_PATH` should be `/build`). +- `APP_LMS`: `canvas` for Canvas LMS. `d2l` for D2l Brightspace LMS. +- `JWK_BASE_URL`: If you are self-hosting Canvas, you may set it to the URL of your instance of Canvas. (Example: `JWK_BASE_URL="https://canvas.dev.myschool.edu"`) +- `DEFAULT_LANG`: (optional) `en` for English. `es` for Spanish. This is English by default. +- `DATABASE_ENCODE_KEY`: UDOIT encrypts your API keys within the database using this key. You will need to generate a key that will be used for encryption using the `create-key` script, explained further below. ## Installation @@ -65,7 +66,20 @@ UDOIT uses Composer to install PHP dependencies. Running the following command w ``` *Note: This may take a while to fully initiate. This is normal.* -#### 4. Set Up Database + +#### 4. Create the encryption key +Once the containers are initialized, run the following command: + +``` + make create-key +``` +This will generate an encryption key that is used during encryption and decryption of sensitive API keys in the database. + +You **must** generate a key, otherwise UDOIT will not start correctly. + +If you're an existing user of UDOIT and you need to rotate your keys (such as in cases where you believe your keys may have been compromised), please visit the [Rotating Keys](https://github.com/ucfopen/UDOIT/wiki/Rotating-Keys) page in the UDOIT wiki for more information. + +#### 5. Set Up Database The following command applies migrations necessary to set up the database to store all UDOIT data. Please make sure the containers have fully spun up before running this command. ``` @@ -82,12 +96,13 @@ Type `yes` and proceed. The warning is expected and is a non issue. UDOIT should be installed and running as Docker containers. -#### To stop the UDOIT containers, run the following command: +#### After generating an encryption key and applying migrations, run the following command to stop the UDOIT containers: ``` make down ``` +You will need to stop UDOIT before loading it for the first time in order to allow the new encryption key to reload. -Please be sure to review the `makefile` for more information on what this command and others do. +Please be sure to review the `Makefile` for more information on what these commands do. If UDOIT is running without errors, you can move on to [installing it for your LMS](#connecting-udoit-to-an-lms)! If you're encountering errors, please check out the [wiki](https://github.com/ucfopen/UDOIT/wiki). @@ -106,7 +121,29 @@ UDOIT uses Composer to install PHP dependencies. Follow the upstream documentati > Remove the `--no-dev` flag if you set `APP_ENV=dev` in your `.env.local` file. -#### 3. Database Setup +#### 3. Encryption Key Setup +UDOIT encrypts your API keys in the database using a generated encryption key. + +This is done to protect against an attacker gaining access to your database and using the API keys to "act" as another user or UDOIT itself. + +In order to generate the key, first ensure that you have created a `.env`. Then, run the following command from the root directory of UDOIT: + + php scripts/create-key.php .env + +The script will then ask: `Create new key? (Y/n): ` + +Type `y` and then press enter. Then, you should see something like this (the generated key will be different): + +``` +Created new key: ha8HJt6CGAJdlO6S920eE6dUWVBOic+xJxN/O7Owu6s= +Updated .env with new key. +``` + +The script should automatically update the `DATABASE_ENCODE_KEY` field in your environment file with your generated key. Verify that your environment file has been updated by comparing the value of `DATABASE_ENCODE_KEY` with the generated key. + +> Note: Ensure you include the correct filename of your environment file when running the key generation script. If you do not pass in any filename, you will receive the following error: `Please specify the environment file to modify!` + +#### 4. Database Setup While UDOIT is configured to use MySQL or MariaDB by default, Symfony can be configured to work with other databases as well. See the Symfony documentation for details. Use Symfony to create the database with this command: @@ -125,7 +162,7 @@ If you are operating in a production environment you will need to generate the d php bin/console cache:warmup --env=prod -#### 4. JavaScript +#### 5. JavaScript UDOIT uses [node](https://nodejs.org) and [yarn](https://yarnpkg.com/) to compile the JavaScript. Install Node and Yarn on your system, then run: yarn install diff --git a/Makefile b/Makefile index 0b0d7cf0c..fe0565e76 100644 --- a/Makefile +++ b/Makefile @@ -4,6 +4,16 @@ ifneq (,$(wildcard ./.ins.env)) export endif +# environment variable location, default to .env +ENV_FILE ?= .env + +# ip address of database container +DB_IP ?= $(shell docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' udoit3-db) + +# spin up the containers +start: + docker compose -f docker-compose.nginx.yml up + # ────────────────────────────────────────────── # Variables # ────────────────────────────────────────────── @@ -87,6 +97,21 @@ admin-panel-retrieve-data: clean-cache exit 1; \ fi $(COMPOSE) run --rm php php bin/console app:admin-panel-retrieval $(foreach table,$(TABLES),--tables=$(table)) + +# ────────────────────────────────────────────── +# Encryption Key Generation/Rotation +# ────────────────────────────────────────────── + +# run rotate keys +rotate-keys: + @echo ENV_FILE: $(ENV_FILE) + @echo DB_IP: $(DB_IP) + docker exec -it udoit3-php php scripts/rotate-keys.php $(DB_IP) $(ENV_FILE) + +# run create key +create-key: + docker exec -it udoit3-php php scripts/create-key.php $(ENV_FILE) + # ────────────────────────────────────────────── # Institution Seeding # ────────────────────────────────────────────── diff --git a/scripts/create-key.php b/scripts/create-key.php new file mode 100644 index 000000000..525df9a14 --- /dev/null +++ b/scripts/create-key.php @@ -0,0 +1,52 @@ + \ No newline at end of file diff --git a/scripts/rotate-keys.php b/scripts/rotate-keys.php new file mode 100644 index 000000000..d93ac77ea --- /dev/null +++ b/scripts/rotate-keys.php @@ -0,0 +1,320 @@ + database encode key ###\nDATABASE_ENCODE_KEY=\"" . $newEncodeKey . "\"\n###> database encode key ###\n\n\n"; +} + +// create a base64-encoded key using sodium (should usually be 44 characters long) +function createKey() { + $key = sodium_crypto_secretbox_keygen(); + $final = base64_encode($key); + + return $final; +} + +// modify an env file to include the new key +function updateEnvFile($envFile, $newKey) { + $envContent = file_get_contents($envFile); + + // if DATABASE_ENCODE_KEY doesn't exist, then create the line and set it to the new key + if (!preg_match('/^DATABASE_ENCODE_KEY=".*"$/m', $envContent)) { + $envContent .= 'DATABASE_ENCODE_KEY="' . $newKey . '"' . PHP_EOL; + } + // otherwise, just replace DATABASE_ENCODE_KEY + else { + $envContent = preg_replace('/^DATABASE_ENCODE_KEY=".*"$/m', 'DATABASE_ENCODE_KEY="' . $newKey . '"', $envContent); + } + + file_put_contents($envFile, $envContent); + + print("Updated $envFile with new key.\n"); +} + +function replaceKey($oldEncodedKey, $newEncodedKey, $table, $host, $username, $password, $database, $port) { + // for the table "users", the column we want to change is called "api_key", + // while in "institution" its called "api_client_secret" + $keyColumn = ($table == "users") ? "api_key" : "api_client_secret"; + + $dsn = "mysql:host=$host;port=$port;dbname=$database;charset=utf8mb4"; + try { + $pdo = new PDO($dsn, $username, $password, [ + PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, + PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, + ]); + + $pdo->beginTransaction(); + + $sql_select = "SELECT * FROM $table"; + $stmt = $pdo->query($sql_select); + $rows = $stmt->fetchAll(); + + if (count($rows) > 0) { + foreach ($rows as $row) { + if (!empty($row[$keyColumn])) { + print("\n_______\n\n"); + print_r($row); + + print("* Old " . $keyColumn . ": " . $row[$keyColumn] . "\n"); + + try { + $decryptedKey = decryptKey($oldEncodedKey, $row[$keyColumn]); + } catch (SodiumException $e) { + echo "\nAn error occurred when trying to decrypt the key! The decryption key you entered might be incorrect. Ensure it is a base64-encoded string (usually 44 characters long).\n"; + $decryptedKey = null; + } + + if (empty($decryptedKey)) { + print("\nFailed to decrypt! The key in the database may not be encrypted, or the decryption key you entered is incorrect. Skipping...\n"); + continue; + } + print("\n* Decrypted " . $keyColumn . ": " . $decryptedKey . "\n\n"); + + $encryptedKey = encryptKey($newEncodedKey, $decryptedKey); + print("* New " . $keyColumn . ": " . $encryptedKey . "\n"); + + print("\n* Sanity Check (Decryption using new database key): " . decryptKey($newEncodedKey, $encryptedKey) . "\n"); + + print("_______\n\n"); + + $sql_update = "UPDATE $table SET $keyColumn = :encryptedKey WHERE id = :id"; + // echo "\n" . $sql_update . "\n"; + + $updateStmt = $pdo->prepare($sql_update); + $updateStmt->execute([ + ':encryptedKey' => $encryptedKey, + ':id' => $row['id'], + ]); + } else { + print("\nNo key found! Skipping...\n"); + } + } + } + + $valid = false; + while ($valid == false) { + $in = readline("\nFinished modifying " . $table . ". Commit changes? (Y/n) "); + + switch ($in) { + case "Y": + case "y": + echo "\nCommitting changes to your database...\n"; + $pdo->commit(); + $valid = true; + break; + case "N": + case "n": + $pdo->rollBack(); + echo "\nNo changes have been made to your database.\n"; + $valid = true; + break; + default: + echo "\nUnknown option!\n"; + break; + } + } + + $pdo = null; + } catch (Exception $e) { + print("Error: " . $e->getMessage() . "\n"); + return array(); + } +} + +// database variables +$host; +$username; +$password; +$database; +$port; + +$valid = false; +while ($valid == false) { + // if running makefile and docker, we should have the HOST_ADDR env variable set to the ip address of the udoit3-db container + if (!empty($argv[1])) { + $host = $argv[1]; + } + else { + $host = null; + } + + if (empty($host)) { + $host = readline("Enter IP address of the UDOIT database: "); + } + else { + echo "\nFound IP address of udoit3-db container: " . $host . "\n"; + } + + $username = readline('Enter admin username of UDOIT database (default is "root"): '); + if (empty($username)) { + $username = "root"; + } + + $password = readline('Enter admin password of UDOIT database (default is "root"): '); + if (empty($password)) { + $password = "root"; + } + + $database = readline('Enter database name (default is "udoit3"): '); + if (empty($database)) { + $database = "udoit3"; + } + + $port = readline('Enter UDOIT database port (default is "3306"): '); + if (empty($port)) { + $port = 3306; + } + + echo "\n****************"; + echo "\nIP Address: " . $host; + echo "\nUsername: " . $username; + echo "\nPassword: " . $password; + echo "\nDatabase: " . $database; + echo "\nPort: " . $port; + echo "\n****************\n\n"; + + + $in = readline("Is the database information correct? (Y/n): "); + switch ($in) { + case 'Y': + case 'y': + try { + // test the database connection + $dbTest = new PDO("mysql:host=$host;port=$port;dbname=$database;charset=utf8mb4", $username, $password); + echo "\nSuccessfully connected to database!\n"; + $dbTest = null; + $valid = true; + } + catch (PDOException $e) { + echo "\nUnable to connect to database: " . $e->getMessage() . "\n\n"; + $valid = false; + } + break; + default: + $host = null; + break; + } + +} + +$valid = false; +while ($valid == false) { + $oldEncodeKey = readline("Enter old encryption key: "); + + echo "\nEntered: \n" . $oldEncodeKey . "\n"; + $in = readline("\nAre you sure this is correct? Make ABSOLUTELY sure it is, otherwise your keys may be lost forever! (Y/n) "); + + switch ($in) { + case "Y": + case "y": + $valid = true; + break; + default: + break; + } +} + +$newEncodeKey = createKey(); + +// creating new key to replace old one that was previously hardcoded... +echo "\n****************\n"; +echo "CREATED NEW KEY:\n"; +echo $newEncodeKey; +echo "\n****************\n\n"; + +// $keyFile = fopen("key.txt", "w") or die("Unable to write key to file!"); +// fwrite($keyFile, $newEncodeKey); +// fclose($keyFile); + +// ask if we should automatically update the .env file +$updateEnv = readline("Would you like to automatically update the .env file? (Y/n): "); +if (strtolower($updateEnv) === 'y') { + if (!empty($argv[2])) { + $envFile = $argv[2]; + } + else { + // keep asking until we get a valid file path + while (empty($envFile) || !file_exists($envFile)) { + // prompt user for env file location + $envFile = readline("Please enter the path to your .env file (default is .env): "); + if (strtolower($envFile) === '') { + $envFile = ".env"; + } + + if (!file_exists($envFile)) { + echo "\nFile does not exist. Please try again. Otherwise, leave the prompt empty to skip updating the .env file automatically.\n"; + if (strtolower($envFile) === '') { + $envFile = null; + break; + } + } + } + } + + // check if file exists + if (!file_exists($envFile)) { + echo "\nUnable to find file: $envFile\n"; + printManualEncodeKeyInstructions($newEncodeKey); + } + else { + echo "\nFound .env file: $envFile\n"; + updateEnvFile($envFile, $newEncodeKey); + } + +} +else { + printManualEncodeKeyInstructions($newEncodeKey); +} + +$valid = false; +while ($valid == false) { + $in = readline("Please paste the new key to verify: "); + if ($in === $newEncodeKey) { + $valid = true; + } + else { + echo "\nThe keys do not match. Please try again.\n"; + } +} + +if (!empty($oldEncodeKey) && !empty($newEncodeKey)) { + echo "\n****************\n"; + echo "WORKING ON institution TABLE"; + echo "\n****************\n"; + replaceKey($oldEncodeKey, $newEncodeKey, "institution", $host, $username, $password, $database, $port); + + echo "\n****************\n"; + echo "WORKING ON users TABLE"; + echo "\n****************\n"; + replaceKey($oldEncodeKey, $newEncodeKey, "users", $host, $username, $password, $database, $port); +} + + +?> diff --git a/src/Entity/Institution.php b/src/Entity/Institution.php index 88183bc23..6fe9cd556 100644 --- a/src/Entity/Institution.php +++ b/src/Entity/Institution.php @@ -50,8 +50,6 @@ class Institution implements JsonSerializable #[ORM\OneToMany(targetEntity: "App\Entity\User", mappedBy: "institution")] private $users; - private $encodedKey = 'niLb/WbAODNi7E4ccHHa/pPU3Bd9h6z1NXmjA981D4o='; - #[ORM\Column(type: "string", nullable: true)] private $apiClientId; @@ -79,7 +77,7 @@ public function encryptDeveloperKey(): self // Private Methods private function encryptData($data): string { - $key = base64_decode($this->encodedKey); + $key = base64_decode($_ENV['DATABASE_ENCODE_KEY']); $nonce = random_bytes(SODIUM_CRYPTO_SECRETBOX_NONCEBYTES); $encrypted_data = sodium_crypto_secretbox($data, $nonce, $key); @@ -88,7 +86,7 @@ private function encryptData($data): string private function decryptData($encrypted): bool | string { - $key = base64_decode($this->encodedKey); + $key = base64_decode($_ENV['DATABASE_ENCODE_KEY']); $decoded = base64_decode($encrypted); $nonce = mb_substr($decoded, 0, SODIUM_CRYPTO_SECRETBOX_NONCEBYTES, '8bit'); $encrypted_text = mb_substr($decoded, SODIUM_CRYPTO_SECRETBOX_NONCEBYTES, NULL, '8bit'); diff --git a/src/Entity/User.php b/src/Entity/User.php index c304a28cd..abf195c39 100644 --- a/src/Entity/User.php +++ b/src/Entity/User.php @@ -49,7 +49,7 @@ class User implements UserInterface, JsonSerializable #[ORM\Column(type: "datetime")] private $lastLogin; - private $encodedKey = 'niLb/WbAODNi7E4ccHHa/pPU3Bd9h6z1NXmjA981D4o='; + private $encodedKey; #[ORM\OneToMany(targetEntity: Report::class, mappedBy: "author")] @@ -244,7 +244,7 @@ public function jsonSerialize(): array private function encryptData($data): string { - $key = base64_decode($this->encodedKey); + $key = base64_decode($_ENV['DATABASE_ENCODE_KEY']); $nonce = random_bytes(SODIUM_CRYPTO_SECRETBOX_NONCEBYTES); $encrypted_data = sodium_crypto_secretbox($data, $nonce, $key); @@ -253,7 +253,8 @@ private function encryptData($data): string private function decryptData($encrypted): bool | string { - $key = base64_decode($this->encodedKey); + + $key = base64_decode($_ENV['DATABASE_ENCODE_KEY']); $decoded = base64_decode($encrypted); $nonce = mb_substr($decoded, 0, SODIUM_CRYPTO_SECRETBOX_NONCEBYTES, '8bit'); $encrypted_text = mb_substr($decoded, SODIUM_CRYPTO_SECRETBOX_NONCEBYTES, NULL, '8bit');