Phase 5 Iteration 2: chữ ký số PKCS#7/PAdES + redact tỉa ký tự + lưu tối ưu
Engine — chữ ký số (sign.rs MỚI): - generate_self_signed_id: Digital ID tự ký RSA-2048 + X.509 (rcgen), ghi PEM - sign_pdf: đúng cơ chế Adobe — lopdf dựng sig dict (/SubFilter adbe.pkcs7.detached) + Widget field + AcroForm/SigFlags, ByteRange/Contents placeholder; serialize → vá ByteRange theo offset thật → SHA-256 trên 2 đoạn → CMS SignedData detached (crate cms, signed attrs messageDigest+contentType, RSA-SHA256) nhét DER vào Contents - verify_signatures: parse ByteRange/Contents, băm lại, kiểm chữ ký RSA trên signed attrs + messageDigest khớp + phủ toàn bộ file → phát hiện GIẢ MẠO - Bài học: cắt DER về đúng độ dài (bỏ padding hex '0'); bỏ qua /Contents của trang (tham chiếu), chỉ nhận /Contents <hex> của chữ ký Engine — redact tỉa theo KÝ TỰ (redact.rs): - Text chạm một phần: page_char_boxes xác định ký tự trong vùng, GIỮ các đoạn ngoài vùng, dựng lại đúng vị trí/baseline/màu (font nhúng gốc → cùng họ → mặc định). Guard: text xoay/không đọc được font → xoá nguyên (an toàn) - qpdf::optimize_save: --object-streams=generate + nén, dọn object rác Tauri + UI: - Commands: sig_create_id, sig_sign, sig_verify, security_optimize, pick_any_file, pick_save_pem - Thanh Bảo mật thêm: Tạo Digital ID, Ký số (dialog + tự kiểm tra sau khi ký), Kiểm tra chữ ký (modal bảng trạng thái + lý do), Lưu tối ưu Test (7 mới → 62/62 engine xanh ngoài 2 fixture qpdf Linux): - sign_then_verify_is_valid; tampering_after_signing_is_detected (phát hiện sửa sau khi ký); verify_reports_none_for_unsigned - redact tỉa ký tự: cụm mật biến mất, phần cùng dòng ngoài vùng vẫn còn - optimize_save không phình + giữ nội dung Deps: rsa, sha2, cms, x509-cert, rcgen, pkcs8, der, spki, const-oid. Docs: 15 mục 6 (iter 2 + giới hạn), 16 (S13–S19), README, roadmap (Phase 5 HOÀN TẤT), 14 (kế tiếp: Phase 6 Form). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HgnbPwSoRGNH7LHsDUmRpk
This commit is contained in:
@@ -112,6 +112,11 @@
|
||||
<button id="secEncrypt" title="Đặt mật khẩu mở file + quyền hạn (AES-256)">🔑 Đặt mật khẩu</button>
|
||||
<button id="secDecrypt" title="Gỡ mật khẩu (cần mật khẩu hiện tại)">🔓 Gỡ mật khẩu</button>
|
||||
<button id="secStripMeta" title="Xoá /Info (tác giả, phần mềm tạo...) và XMP metadata khỏi file">🧹 Xoá metadata</button>
|
||||
<span class="sep"></span>
|
||||
<button id="secCreateId" title="Tạo Digital ID tự ký (RSA-2048) để ký thử">🪪 Tạo Digital ID</button>
|
||||
<button id="secSign" title="Ký số tài liệu bằng Digital ID">✍️ Ký số</button>
|
||||
<button id="secVerify" title="Kiểm tra tính hợp lệ của chữ ký trong file">🔎 Kiểm tra chữ ký</button>
|
||||
<button id="secOptimize" title="Nén object stream + dọn object rác (giảm dung lượng)">📦 Lưu tối ưu</button>
|
||||
<span class="spacer"></span>
|
||||
<span id="secHint" class="status"></span>
|
||||
</div>
|
||||
|
||||
+117
@@ -2811,6 +2811,119 @@ async function stripMetadataAction() {
|
||||
}
|
||||
}
|
||||
|
||||
async function optimizeSaveAction() {
|
||||
const out = await invoke("pick_save_pdf");
|
||||
if (!out) return;
|
||||
try {
|
||||
await invoke("security_optimize", { input: state.path, output: out });
|
||||
$("status").textContent = `Đã lưu tối ưu (nén + dọn rác) → ${shortName(out)}`;
|
||||
loadDocument(out);
|
||||
} catch (e) {
|
||||
$("secHint").textContent = "Lỗi lưu tối ưu: " + e;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Chữ ký số (Phase 5 iteration 2) ---
|
||||
|
||||
function openCreateIdDialog() {
|
||||
const box = openModal("Tạo Digital ID tự ký", `
|
||||
<p class="muted">Tạo chứng chỉ tự ký (RSA-2048) để ký thử. Nếu có chữ ký của tổ chức phát hành (PFX/PEM) thì dùng thẳng nó ở bước Ký số.</p>
|
||||
<label>Tên hiển thị trong chữ ký</label>
|
||||
<input type="text" id="idCn" placeholder="Nguyễn Văn A">
|
||||
<div class="err" id="idErr"></div>
|
||||
<div class="foot"><button id="idCancel">Huỷ</button><button id="idOk" class="primary">Tạo & lưu…</button></div>
|
||||
`);
|
||||
box.querySelector("#idCancel").addEventListener("click", closeModal);
|
||||
box.querySelector("#idOk").addEventListener("click", async () => {
|
||||
const cn = box.querySelector("#idCn").value.trim();
|
||||
if (!cn) { box.querySelector("#idErr").textContent = "Cần nhập tên."; return; }
|
||||
const out = await invoke("pick_save_pem");
|
||||
if (!out) return;
|
||||
try {
|
||||
await invoke("sig_create_id", { commonName: cn, output: out });
|
||||
closeModal();
|
||||
$("secHint").textContent = `Đã tạo Digital ID → ${shortName(out)} (dùng ở bước Ký số)`;
|
||||
} catch (e) {
|
||||
box.querySelector("#idErr").textContent = "Lỗi: " + e;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function openSignDialog() {
|
||||
const box = openModal("Ký số tài liệu", `
|
||||
<label>Tên người ký (hiện trong chữ ký)</label>
|
||||
<input type="text" id="sgName" placeholder="Nguyễn Văn A">
|
||||
<label>Lý do ký</label>
|
||||
<input type="text" id="sgReason" value="Tôi đồng ý với nội dung tài liệu">
|
||||
<label>Digital ID (.pem)</label>
|
||||
<div class="row">
|
||||
<input type="text" id="sgId" readonly placeholder="Chưa chọn — bấm Chọn…">
|
||||
<button id="sgPickId">Chọn…</button>
|
||||
</div>
|
||||
<div class="err" id="sgErr"></div>
|
||||
<div class="foot"><button id="sgCancel">Huỷ</button><button id="sgOk" class="primary">Ký & lưu…</button></div>
|
||||
`);
|
||||
let idPath = null;
|
||||
box.querySelector("#sgPickId").addEventListener("click", async () => {
|
||||
const p = await invoke("pick_any_file");
|
||||
if (p) { idPath = p; box.querySelector("#sgId").value = shortName(p); }
|
||||
});
|
||||
box.querySelector("#sgCancel").addEventListener("click", closeModal);
|
||||
box.querySelector("#sgOk").addEventListener("click", async () => {
|
||||
const name = box.querySelector("#sgName").value.trim();
|
||||
const reason = box.querySelector("#sgReason").value.trim();
|
||||
const err = box.querySelector("#sgErr");
|
||||
if (!name) { err.textContent = "Cần nhập tên người ký."; return; }
|
||||
if (!idPath) { err.textContent = "Cần chọn Digital ID (.pem)."; return; }
|
||||
const out = await invoke("pick_save_pdf");
|
||||
if (!out) return;
|
||||
try {
|
||||
await invoke("sig_sign", { input: state.path, idPem: idPath, reason, signerName: name, output: out });
|
||||
closeModal();
|
||||
$("status").textContent = `Đã ký số → ${shortName(out)}`;
|
||||
loadDocument(out);
|
||||
// Tự kiểm tra chữ ký vừa tạo.
|
||||
verifySignaturesAction(out);
|
||||
} catch (e) {
|
||||
err.textContent = "Lỗi ký: " + e;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function verifySignaturesAction(pathOverride) {
|
||||
const target = typeof pathOverride === "string" ? pathOverride : state.path;
|
||||
let checks;
|
||||
try {
|
||||
checks = await invoke("sig_verify", { input: target });
|
||||
} catch (e) {
|
||||
$("secHint").textContent = "Lỗi kiểm tra: " + e;
|
||||
return;
|
||||
}
|
||||
const rows = checks.length
|
||||
? checks
|
||||
.map((c, i) => {
|
||||
const badge = c.valid
|
||||
? '<span class="sig-ok">✓ Hợp lệ</span>'
|
||||
: '<span class="sig-bad">✗ Không hợp lệ</span>';
|
||||
const detail = [];
|
||||
if (!c.cryptoValid) detail.push("chữ ký sai");
|
||||
if (!c.digestMatches) detail.push("nội dung đã bị sửa");
|
||||
if (!c.coversDocument) detail.push("có phần thêm sau khi ký");
|
||||
return `<tr><td>${i + 1}</td><td>${(c.signer || "").replace(/</g, "<")}</td><td>${badge}</td>
|
||||
<td class="muted">${detail.join("; ")}</td></tr>`;
|
||||
})
|
||||
.join("")
|
||||
: `<tr><td colspan="4" class="muted">Tài liệu chưa có chữ ký số nào.</td></tr>`;
|
||||
const box = openModal("Kết quả kiểm tra chữ ký", `
|
||||
<table class="sig-table">
|
||||
<thead><tr><th>#</th><th>Người ký</th><th>Trạng thái</th><th>Ghi chú</th></tr></thead>
|
||||
<tbody>${rows}</tbody>
|
||||
</table>
|
||||
<div class="foot"><button id="sigClose" class="primary">Đóng</button></div>
|
||||
`);
|
||||
box.querySelector("#sigClose").addEventListener("click", closeModal);
|
||||
}
|
||||
|
||||
// ---------- Sự kiện ----------
|
||||
|
||||
$("openBtn").addEventListener("click", openFile);
|
||||
@@ -2933,6 +3046,10 @@ $("secRedactClear").addEventListener("click", clearRedactMarks);
|
||||
$("secEncrypt").addEventListener("click", openEncryptDialog);
|
||||
$("secDecrypt").addEventListener("click", openDecryptDialog);
|
||||
$("secStripMeta").addEventListener("click", stripMetadataAction);
|
||||
$("secOptimize").addEventListener("click", optimizeSaveAction);
|
||||
$("secCreateId").addEventListener("click", openCreateIdDialog);
|
||||
$("secSign").addEventListener("click", openSignDialog);
|
||||
$("secVerify").addEventListener("click", () => verifySignaturesAction());
|
||||
|
||||
// Sửa nội dung (Phase 4)
|
||||
$("editModeBtn").addEventListener("click", toggleEditMode);
|
||||
|
||||
@@ -413,6 +413,12 @@ body.tool-active .annotlayer * { pointer-events: none; }
|
||||
box-sizing: content-box;
|
||||
}
|
||||
|
||||
/* Bảng kết quả kiểm tra chữ ký (Phase 5) */
|
||||
.sig-table { width: 100%; border-collapse: collapse; margin: 4px 0 12px; }
|
||||
.sig-table th, .sig-table td { text-align: left; padding: 5px 8px; border-bottom: 1px solid var(--border, #ddd); font-size: 13px; }
|
||||
.sig-ok { color: #1a8f3c; font-weight: 600; }
|
||||
.sig-bad { color: #d33; font-weight: 600; }
|
||||
|
||||
/* Đánh dấu redact (Phase 5): khối mờ đen viền đỏ, bấm để bỏ */
|
||||
.redact-mark {
|
||||
position: absolute;
|
||||
|
||||
Reference in New Issue
Block a user