实现文件保险箱的端到端加密
*未完全测试 *不保证数据安全性
This commit is contained in:
parent
555a65ae79
commit
f83c7ed325
@ -68,13 +68,15 @@
|
||||
|
||||
容量限制
|
||||
|
||||
文件保险箱(端到端加密)(未完全测试)(不保证可靠性)
|
||||
|
||||
计划实现的功能
|
||||
-------------------
|
||||
|
||||
文件保险箱(端到端加密)
|
||||
|
||||
passwordless
|
||||
|
||||
文件搜素
|
||||
|
||||
和管理员相关的功能
|
||||
|
||||
忘记密码
|
||||
|
@ -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');
|
||||
}
|
||||
}
|
@ -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'],
|
||||
|
@ -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,14 +11,14 @@ use yii\bootstrap5\ActiveForm;
|
||||
$this->title = '解锁文件保险箱';
|
||||
$this->params['breadcrumbs'][] = $this->title;
|
||||
?>
|
||||
<div class="vault-gateway">
|
||||
<div class="vault-gateway">
|
||||
<h1><?= Html::encode($this->title) ?></h1>
|
||||
|
||||
<p>要访问文件保险箱,你必须要提供正确的保险箱密码</p>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-lg-5">
|
||||
<?php $form = ActiveForm::begin(); ?>
|
||||
<?php $form = ActiveForm::begin(['id' => 'gateway-vault-form', 'action' => ['vault/auth'], 'method' => 'post']); ?>
|
||||
<?= $form->field($model, 'vault_secret')->passwordInput()->label('保险箱密码(不是登陆密码)') ?>
|
||||
<div class="form-group">
|
||||
<?= Html::submitButton('确认', ['class' => 'btn btn-primary']) ?>
|
||||
@ -25,4 +26,7 @@ $this->params['breadcrumbs'][] = $this->title;
|
||||
<?php ActiveForm::end(); ?>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<?php
|
||||
$this->registerJsFile('@web/js/vault_gateway_hook.js', ['position' => View::POS_END]);
|
||||
?>
|
@ -17,7 +17,7 @@ $this->params['breadcrumbs'][] = $this->title;
|
||||
|
||||
<div class="row">
|
||||
<div class="col-lg-5">
|
||||
<?php $form = ActiveForm::begin(); ?>
|
||||
<?php $form = ActiveForm::begin(['id' => 'init-vault-form', 'action' => ['vault/init'], 'method' => 'post']); ?>
|
||||
<?= $form->field($model, 'input_vault_secret')->label('保险箱密码(建议不要与登陆密码相同)')->passwordInput(['autofocus' => true]) ?>
|
||||
<div class="form-group">
|
||||
<?= Html::submitButton('初始化保险箱', ['class' => 'btn btn-primary']) ?>
|
||||
|
@ -120,7 +120,10 @@ $this->registerCssFile('@web/css/home_style.css');
|
||||
</td>
|
||||
<td>
|
||||
<?= Html::tag('i', '', ['class' => $item['type'] . ' file_icon']) ?>
|
||||
<?= Html::a($item['name'], ['vault/download', 'relativePath' => $relativePath], ['class' => 'file_name']) ?>
|
||||
<!-- --><?php //= Html::a($item['name'], ['vault/download', 'relativePath' => $relativePath], ['class' => 'file_name']) ?>
|
||||
<?= Html::beginTag('span', ['class' => 'file_name']) ?>
|
||||
<?= $item['name'] ?>
|
||||
<?= Html::endTag('span') ?>
|
||||
</td>
|
||||
<td class="file_info">
|
||||
<?= date('Y-m-d H:i:s', $item['lastModified']) ?>
|
||||
@ -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'],
|
||||
]) ?>
|
||||
<?= Html::button(Html::tag('i', '', ['class' => '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' => '删除']) ?>
|
||||
</td>
|
||||
@ -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]);
|
||||
?>
|
||||
|
||||
|
121
web/js/vault_core.js
Normal file
121
web/js/vault_core.js
Normal file
@ -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);
|
||||
}
|
61
web/js/vault_gateway_hook.js
Normal file
61
web/js/vault_gateway_hook.js
Normal file
@ -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']
|
||||
// );
|
||||
// }
|
@ -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) {
|
||||
|
Loading…
Reference in New Issue
Block a user