diff --git a/README.md b/README.md index 4c1d4db..c02917f 100644 --- a/README.md +++ b/README.md @@ -68,13 +68,15 @@ 容量限制 +文件保险箱(端到端加密)(未完全测试)(不保证可靠性) + 计划实现的功能 ------------------- -文件保险箱(端到端加密) - passwordless +文件搜素 + 和管理员相关的功能 忘记密码 diff --git a/controllers/VaultController.php b/controllers/VaultController.php index 010c200..ba69fbc 100644 --- a/controllers/VaultController.php +++ b/controllers/VaultController.php @@ -7,9 +7,11 @@ namespace app\controllers; use app\models\UploadForm; +use app\models\User; use app\utils\FileSizeHelper; use app\utils\FileTypeDetector; use Yii; +use yii\base\Exception; use yii\filters\AccessControl; use yii\filters\VerbFilter; use yii\web\Controller; @@ -31,7 +33,7 @@ class VaultController extends Controller 'rules' => [ [ 'allow' => true, - 'actions' => ['index', 'download', 'delete', 'upload'], + 'actions' => ['index', 'download', 'delete', 'upload', 'init', 'auth'], 'roles' => ['user'], ], ], @@ -43,6 +45,8 @@ class VaultController extends Controller 'download' => ['GET'], 'delete' => ['POST'], 'upload' => ['POST'], + 'init' => ['POST'], + 'auth' => ['POST'], ], ], ] @@ -57,11 +61,15 @@ class VaultController extends Controller public function actionIndex($directory = null): Response|string { $model = Yii::$app->user->identity; - if ($model->vault_secret === null) { - return $this->render('_init',[ + if (empty($model->vault_secret)) { + return $this->render('_init', [ 'model' => $model, ]); - }//TODO + } elseif (Yii::$app->session->get('vault_auth') !== true) { + return $this->render('_gateway', [ + 'model' => new User(), + ]); + } $rootDataDirectory = Yii::getAlias(Yii::$app->params['dataDirectory']) . '/' . Yii::$app->user->id . '.secret'; if ($directory === '.' || $directory == null) { @@ -244,4 +252,45 @@ class VaultController extends Controller return Yii::$app->response->statusCode = 200; } } + + /** + * 初始化文件保险箱密码 + * @return Response + * @throws Exception + */ + public function actionInit(): Response + { + $model = Yii::$app->user->identity; // 获取当前用户模型 + if ($model->load(Yii::$app->request->post()) && $model->validate() && !empty($model->input_vault_secret)) { + $model->vault_secret = Yii::$app->getSecurity()->generatePasswordHash($model->input_vault_secret); + if ($model->save(false)) { // 保存用户模型 + Yii::$app->session->setFlash('success', '保险箱初始化成功,请牢记密码,否则无法恢复保险箱内文件'); + } else { + Yii::$app->session->setFlash('error', '保险箱初始化失败'); + } + } else { + Yii::$app->session->setFlash('error', '设定的密码未通过验证'); + } + return $this->redirect('index.php?r=vault%2Findex'); // render里面好像只能这么写了 + } + + /** + * @return Response + */ + public function actionAuth(): Response + { + $user = Yii::$app->user->identity; + $model = new User(); + if ($model->load(Yii::$app->request->post()) && !empty($model->vault_secret)) { + if (Yii::$app->getSecurity()->validatePassword($model->vault_secret, $user->vault_secret)) { + Yii::$app->session->set('vault_auth', true); + Yii::$app->session->setFlash('success', '文件保险箱密码验证成功'); + return $this->redirect('index.php?r=vault%2Findex'); + } + Yii::$app->session->setFlash('error', '保险箱密码错误'); + } else { + Yii::$app->session->setFlash('error', '密码未通过验证'); + } + return $this->redirect('index.php?r=vault%2Findex'); + } } \ No newline at end of file diff --git a/models/User.php b/models/User.php index 5467f85..6cc54b4 100644 --- a/models/User.php +++ b/models/User.php @@ -64,7 +64,8 @@ class User extends ActiveRecord implements IdentityInterface [['status', 'is_encryption_enabled', 'is_otp_enabled', 'dark_mode'], 'integer'], [['created_at', 'last_login'], 'safe'], [['bio', 'totp_input', 'recoveryCode_input', 'name'], 'string'], - [['encryption_key', 'otp_secret', 'recovery_codes', 'vault_secret', 'input_vault_secret'], 'string', 'max' => 255], + ['input_vault_secret', 'string', 'min' => 6, 'max' => 24], + [['encryption_key', 'otp_secret', 'recovery_codes', 'vault_secret'], 'string', 'max' => 255], [['last_login_ip'], 'string', 'max' => 45], [['username', 'password'], 'required', 'on' => 'login'], [['username', 'password', 'email', 'password2'], 'required', 'on' => 'register'], diff --git a/views/vault/_gateway.php b/views/vault/_gateway.php index 95ec5b0..3da4770 100644 --- a/views/vault/_gateway.php +++ b/views/vault/_gateway.php @@ -2,6 +2,7 @@ use yii\bootstrap5\Html; use yii\bootstrap5\ActiveForm; +use yii\web\View; /** @var yii\web\View $this */ /** @var app\models\User $model */ @@ -10,19 +11,22 @@ use yii\bootstrap5\ActiveForm; $this->title = '解锁文件保险箱'; $this->params['breadcrumbs'][] = $this->title; ?> -
-

title) ?>

+
+

title) ?>

-

要访问文件保险箱,你必须要提供正确的保险箱密码

+

要访问文件保险箱,你必须要提供正确的保险箱密码

-
-
- - field($model, 'vault_secret')->passwordInput()->label('保险箱密码(不是登陆密码)') ?> -
- 'btn btn-primary']) ?> +
+
+ 'gateway-vault-form', 'action' => ['vault/auth'], 'method' => 'post']); ?> + field($model, 'vault_secret')->passwordInput()->label('保险箱密码(不是登陆密码)') ?> +
+ 'btn btn-primary']) ?> +
+
-
-
+registerJsFile('@web/js/vault_gateway_hook.js', ['position' => View::POS_END]); +?> \ No newline at end of file diff --git a/views/vault/_init.php b/views/vault/_init.php index dbe74bd..aecd0ed 100644 --- a/views/vault/_init.php +++ b/views/vault/_init.php @@ -17,7 +17,7 @@ $this->params['breadcrumbs'][] = $this->title;
- + 'init-vault-form', 'action' => ['vault/init'], 'method' => 'post']); ?> field($model, 'input_vault_secret')->label('保险箱密码(建议不要与登陆密码相同)')->passwordInput(['autofocus' => true]) ?>
'btn btn-primary']) ?> diff --git a/views/vault/index.php b/views/vault/index.php index 79d1a30..76acbf2 100644 --- a/views/vault/index.php +++ b/views/vault/index.php @@ -120,7 +120,10 @@ $this->registerCssFile('@web/css/home_style.css'); $item['type'] . ' file_icon']) ?> - $relativePath], ['class' => 'file_name']) ?> + $relativePath], ['class' => 'file_name']) ?> + 'file_name']) ?> + + @@ -134,7 +137,8 @@ $this->registerCssFile('@web/css/home_style.css'); 'class' => 'btn btn-outline-primary download-btn', 'data-bs-toggle' => 'tooltip', 'data-bs-placement' => 'top', - 'data-bs-title' => '下载' + 'data-bs-title' => '下载', + 'data-filename' => $item['name'], ]) ?> 'fa-regular fa-trash-can']), ['value' => $relativePath, 'class' => 'btn btn-outline-danger delete-btn', 'data-bs-toggle' => 'tooltip', 'data-bs-placement' => 'top', 'data-bs-title' => '删除']) ?> @@ -160,5 +164,6 @@ echo Html::endForm(); Modal::end(); $this->registerJsFile('@web/js/vault_script.js', ['depends' => [JqueryAsset::class], 'position' => View::POS_END]); +$this->registerJsFile('@web/js/vault_core.js', ['position' => View::POS_END]); ?> diff --git a/web/js/vault_core.js b/web/js/vault_core.js new file mode 100644 index 0000000..f5ecc56 --- /dev/null +++ b/web/js/vault_core.js @@ -0,0 +1,121 @@ +const vaultRawKey = sessionStorage.getItem('vaultRawKey'); +async function generateEncryptionKeyFromPassword(password) { + const passwordBuffer = new TextEncoder().encode(password); + const key = await window.crypto.subtle.importKey( + 'raw', + passwordBuffer, + {name: 'PBKDF2'}, + false, + ['deriveKey'] + ); + return await window.crypto.subtle.deriveKey( + { + name: 'PBKDF2', + salt: new Uint8Array([]), + iterations: 100000, + hash: 'SHA-256' + }, + key, + {name: 'AES-GCM', length: 256}, + false, + ['encrypt', 'decrypt'] + ); +} +// 加密文件 +async function encryptFile(file, password) { + const iv = window.crypto.getRandomValues(new Uint8Array(12)); // 生成随机 IV + const passwordBuffer = new TextEncoder().encode(password); + const key = await window.crypto.subtle.importKey( + 'raw', + passwordBuffer, + {name: 'PBKDF2'}, + false, + ['deriveKey'] + ); + const derivedKey = await window.crypto.subtle.deriveKey( + { + name: 'PBKDF2', + salt: new Uint8Array([]), + iterations: 100000, + hash: 'SHA-256' + }, + key, + {name: 'AES-GCM', length: 256}, + false, + ['encrypt', 'decrypt'] + ); + + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = function(event) { + const plaintextData = event.target.result; + window.crypto.subtle.encrypt( + { name: 'AES-GCM', iv: iv }, // 使用随机生成的 IV + derivedKey, + plaintextData + ).then(encryptedData => { + const encryptedBlob = new Blob([iv, encryptedData], {type: file.type}); + resolve(encryptedBlob); + }).catch(error => { + reject(error); + }); + }; + reader.readAsArrayBuffer(file); + }); +} + +// 解密文件 +async function decryptFile(encryptedFile, password) { + return new Promise((resolve, reject) => { + const fileReader = new FileReader(); + fileReader.onload = async function(event) { + try { + const encryptedData = new Uint8Array(event.target.result); + const iv = encryptedData.slice(0, 12); // 从密文中提取 IV + const ciphertext = encryptedData.slice(12); + const passwordBuffer = new TextEncoder().encode(password); + const key = await window.crypto.subtle.importKey( + 'raw', + passwordBuffer, + {name: 'PBKDF2'}, + false, + ['deriveKey'] + ); + const derivedKey = await window.crypto.subtle.deriveKey( + { + name: 'PBKDF2', + salt: new Uint8Array([]), + iterations: 100000, + hash: 'SHA-256' + }, + key, + {name: 'AES-GCM', length: 256}, + false, + ['encrypt', 'decrypt'] + ); + const decryptedData = await window.crypto.subtle.decrypt( + { name: 'AES-GCM', iv: iv }, // 使用从密文中提取的 IV + derivedKey, + ciphertext + ); + const decryptedFile = new Blob([decryptedData], { type: encryptedFile.type }); + resolve(decryptedFile); + } catch (error) { + reject(error); + } + }; + fileReader.readAsArrayBuffer(encryptedFile); + }); +} +async function downloadAndDecryptFile(url, password, filename) { + const response = await fetch(url); + const encryptedFile = await response.blob(); + const decryptedFile = await decryptFile(encryptedFile, password); + const blob = new Blob([decryptedFile], {type: decryptedFile.type}); + const blobURL = window.URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = blobURL; + link.download = filename; + link.click(); + window.URL.revokeObjectURL(blobURL); +} \ No newline at end of file diff --git a/web/js/vault_gateway_hook.js b/web/js/vault_gateway_hook.js new file mode 100644 index 0000000..99e4c91 --- /dev/null +++ b/web/js/vault_gateway_hook.js @@ -0,0 +1,61 @@ +document.getElementById('gateway-vault-form').addEventListener('submit', function (event) { + event.preventDefault(); + var password = document.getElementById('password').value; + sessionStorage.setItem('vaultRawKey', password); + this.submit(); +}); +document.addEventListener('DOMContentLoaded', function () { + if (!(window.crypto && window.crypto.subtle)) { + console.log('浏览器不支持 Crypto API'); + alert('您的浏览器不支持加密功能,故无法使用文件保险箱功能,请使用现代浏览器。'); + window.location.href = 'index.php?r=site%2Findex'; + } +}); + +// async function generateEncryptionKeyFromPassword(password) { +// const passwordBuffer = new TextEncoder().encode(password); +// const key = await window.crypto.subtle.importKey( +// 'raw', +// passwordBuffer, +// {name: 'PBKDF2'}, +// false, +// ['deriveKey'] +// ); +// const encryptionKey = await window.crypto.subtle.deriveKey( +// { +// name: 'PBKDF2', +// salt: new Uint8Array([]), +// iterations: 100000, +// hash: 'SHA-256' +// }, +// key, +// {name: 'AES-GCM', length: 256}, +// false, +// ['encrypt', 'decrypt'] +// ); +// +// return encryptionKey; +// } +// +// function cryptoKeyToString(cryptoKey) { +// return window.crypto.subtle.exportKey('raw', cryptoKey).then(function (keyData) { +// return String.fromCharCode.apply(null, new Uint8Array(keyData)); +// }); +// } +// +// function stringToCryptoKey(keyString) { +// // 将字符串转换为 Uint8Array +// var keyData = new Uint8Array(keyString.length); +// for (var i = 0; i < keyString.length; ++i) { +// keyData[i] = keyString.charCodeAt(i); +// } +// +// // 使用 importKey 方法导入 CryptoKey 对象 +// return window.crypto.subtle.importKey( +// 'raw', +// keyData, +// {name: 'PBKDF2'}, +// false, +// ['deriveKey'] +// ); +// } \ No newline at end of file diff --git a/web/js/vault_script.js b/web/js/vault_script.js index a40b4d0..d881d09 100644 --- a/web/js/vault_script.js +++ b/web/js/vault_script.js @@ -2,8 +2,15 @@ var tooltipTriggerList = [].slice.call(document.querySelectorAll('[data-bs-toggl var tooltipList = tooltipTriggerList.map(function (tooltipTriggerEl) { return new bootstrap.Tooltip(tooltipTriggerEl) }); -$(document).on('click', '.download-btn', function () { - window.location.href = $(this).attr('value'); +$(document).on('click', '.download-btn', async function() { + const downloadUrl = $(this).attr('value'); + const filename = $(this).data('filename'); + + try { + await downloadAndDecryptFile(downloadUrl, vaultRawKey, filename); + } catch (error) { + console.error('Error downloading or decrypting the file:', error); + } }); $(document).on('click', '.delete-btn', function () { var relativePath = $(this).attr('value'); @@ -16,26 +23,34 @@ $(document).on('click', '.file-upload-btn', function () { $('#file-input').on('change', function () { uploadFiles(this.files); }); -$('#folder-input').on('change', function () { - uploadFiles(this.files); -}); $(document).on('click', '.refresh-btn', function () { window.location.reload(); }); $(document).on('click', '.single-download-btn', function () { - var relativePath = $('.select-item:checked').first().data('relativePath'); - window.location.href = 'index.php?r=vault%2Fdownload&relativePath=' + encodeURIComponent(relativePath); + var downloadBtn = $('.select-item:checked').closest('tr').find('.download-btn'); + if (downloadBtn.length > 0) { + downloadBtn.trigger('click'); + } else { + console.error('No file selected for download.'); + } }); -function uploadFiles(files) { +async function uploadFiles(files) { + // 这里问gpt的,加密方面实在不会 $('#progress-bar').show(); var formData = new FormData(); - for (var i = 0; i < files.length; i++) { - formData.append('files[]', files[i]); - } + var encryptionPromises = Array.from(files).map(file => encryptFile(file, vaultRawKey)); + var encryptedFiles = await Promise.all(encryptionPromises); + + encryptedFiles.forEach(function (encryptedFile, index) { + formData.append('files[]', new File([encryptedFile], files[index].name, {type: files[index].type})); + }); + + // 添加其他数据到 FormData 中 formData.append('targetDir', $('#target-dir').val()); formData.append('_csrf', $('meta[name="csrf-token"]').attr('content')); + // 创建 XMLHttpRequest 对象并发送 FormData var xhr = new XMLHttpRequest(); xhr.upload.onprogress = function (event) { if (event.lengthComputable) {