实现文件保险箱的端到端加密

*未完全测试
*不保证数据安全性
This commit is contained in:
Chenx221 2024-03-11 20:24:51 +08:00
parent 555a65ae79
commit f83c7ed325
Signed by: chenx221
GPG Key ID: D7A9EC07024C3021
9 changed files with 290 additions and 32 deletions

View File

@ -68,13 +68,15 @@
容量限制
文件保险箱(端到端加密)(未完全测试)(不保证可靠性)
计划实现的功能
-------------------
文件保险箱(端到端加密)
passwordless
文件搜素
和管理员相关的功能
忘记密码

View File

@ -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');
}
}

View File

@ -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'],

View File

@ -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]);
?>

View File

@ -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']) ?>

View File

@ -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
View 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);
}

View 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']
// );
// }

View File

@ -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) {